Compare commits
44 Commits
4e098eefaa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da50403c76 | ||
|
|
ab0d4ed285 | ||
|
|
2dd419dbf4 | ||
|
|
785d036dc6 | ||
|
|
65fe68b87f | ||
|
|
d641d2248d | ||
|
|
12904b4902 | ||
|
|
1ebbcb62cc | ||
|
|
3e13512a83 | ||
|
|
66fd3feff8 | ||
|
|
016433abe6 | ||
|
|
a2189457f6 | ||
|
|
4637448c49 | ||
|
|
61e0688f73 | ||
|
|
0ff55f5e9c | ||
|
|
5e2ebe4d96 | ||
|
|
e8dd657b4f | ||
|
|
c62d8bc275 | ||
|
|
f869296832 | ||
|
|
9f430cebb5 | ||
|
|
7d05d37678 | ||
|
|
762770a063 | ||
|
|
3ff76228eb | ||
|
|
f01b0bcb19 | ||
|
|
7efd3b0a07 | ||
|
|
c81ee1b470 | ||
|
|
955c17a466 | ||
|
|
052ded7b6e | ||
|
|
321ea33bf7 | ||
|
|
288bd244dd | ||
|
|
d91609b3a4 | ||
|
|
5a575a29fe | ||
|
|
0a6c7ee2e1 | ||
|
|
4cc529b1c2 | ||
|
|
fbfcec4b47 | ||
|
|
43eb97407f | ||
|
|
9e4b149b64 | ||
|
|
1848486f1c | ||
|
|
d44cbc978b | ||
|
|
f243761f00 | ||
|
|
2a31c7ec69 | ||
|
|
69f68adffe | ||
|
|
5a1eff37d7 | ||
|
|
e8f9207a92 |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# pumpingStation — Claude Code context
|
||||
|
||||
Wet-well basin model and pump orchestration.
|
||||
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||
|
||||
## S88 classification
|
||||
|
||||
| Level | Colour | Placement lane |
|
||||
|---|---|---|
|
||||
| **Process Cell** | `#0c99d9` | L5 |
|
||||
|
||||
## Flow layout rules
|
||||
|
||||
When wiring this node into a multi-node demo or production flow, follow the
|
||||
placement rule set in the **EVOLV superproject**:
|
||||
|
||||
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||
|
||||
Key points for this node:
|
||||
- Place on lane **L5** (x-position per the lane table in the rule).
|
||||
- Stack same-level siblings vertically.
|
||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||
- Wrap in a Node-RED group box coloured `#0c99d9` (Process Cell).
|
||||
10
README.md
10
README.md
@@ -1 +1,9 @@
|
||||
# rotating machine
|
||||
# pumpingStation
|
||||
|
||||
Wet-well basin model and pump orchestration node for EVOLV.
|
||||
|
||||
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/modes/`](wiki/modes/) documents control-mode-specific behaviour such as the level-linear `startLevel` demand ramp.
|
||||
- [`wiki/diagrams/basin-model.drawio.svg`](wiki/diagrams/basin-model.drawio.svg) is the current source of truth for the generic basin model.
|
||||
|
||||
@@ -16,14 +16,31 @@
|
||||
category: "EVOLV",
|
||||
color: "#0c99d9", // color for the node based on the S88 schema
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
|
||||
// Define station-specific properties
|
||||
simulator: { value: false },
|
||||
basinVolume: { value: 1 }, // m³, total empty basin
|
||||
basinHeight: { value: 1 }, // m, floor to top
|
||||
heightInlet: { value: 0.8 }, // m, centre of inlet pipe above floor
|
||||
heightOutlet: { value: 0.2 }, // m, centre of outlet pipe above floor
|
||||
heightOverflow: { value: 0.9 }, // m, overflow elevation
|
||||
inflowLevel: { value: 0.8 }, // m, centre of inlet pipe above floor
|
||||
outflowLevel: { value: 0.2 }, // m, centre of outlet pipe above floor
|
||||
overflowLevel: { value: 0.9 }, // m, overflow elevation
|
||||
defaultFluid: { value: "wastewater" },
|
||||
inletPipeDiameter: { value: 0.3 }, // m
|
||||
outletPipeDiameter: { value: 0.3 }, // m
|
||||
pipelineLength: { value: 80 }, // m
|
||||
maxDischargeHead: { value: 24 }, // m
|
||||
staticHead: { value: 12 }, // m
|
||||
maxInflowRate: { value: 200 }, // m³/h
|
||||
temperatureReferenceDegC: { value: 15 },
|
||||
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
|
||||
enableDryRunProtection: { value: true },
|
||||
enableOverfillProtection: { value: true },
|
||||
dryRunThresholdPercent: { value: 2 },
|
||||
overfillThresholdPercent: { value: 98 },
|
||||
minHeightBasedOn: { value: "outlet" }, // basis for minimum height check: inlet or outlet
|
||||
processOutputFormat: { value: "process" },
|
||||
dbaseOutputFormat: { value: "influxdb" },
|
||||
|
||||
// Advanced reference information
|
||||
refHeight: { value: "NAP" }, // reference height
|
||||
@@ -47,7 +64,15 @@
|
||||
hasDistance: { value: false },
|
||||
distance: { value: 0 },
|
||||
distanceUnit: { value: "m" },
|
||||
distanceDescription: { value: "" }
|
||||
distanceDescription: { value: "" },
|
||||
|
||||
// control strategy
|
||||
controlMode: { value: "none" },
|
||||
startLevel: { value: null },
|
||||
minLevel: { value: null },
|
||||
maxLevel: { value: null },
|
||||
flowSetpoint: { value: null },
|
||||
flowDeadband: { value: null }
|
||||
|
||||
},
|
||||
|
||||
@@ -75,9 +100,9 @@
|
||||
// NODE SPECIFIC
|
||||
document.getElementById("node-input-basinVolume");
|
||||
document.getElementById("node-input-basinHeight");
|
||||
document.getElementById("node-input-heightInlet");
|
||||
document.getElementById("node-input-heightOutlet");
|
||||
document.getElementById("node-input-heightOverflow");
|
||||
document.getElementById("node-input-inflowLevel");
|
||||
document.getElementById("node-input-outflowLevel");
|
||||
document.getElementById("node-input-overflowLevel");
|
||||
document.getElementById("node-input-refHeight");
|
||||
document.getElementById("node-input-basinBottomRef");
|
||||
|
||||
@@ -86,6 +111,237 @@
|
||||
refHeightEl.value = this.refHeight || "NAP";
|
||||
}
|
||||
|
||||
const minHeightBasedOnEl = document.getElementById("node-input-minHeightBasedOn");
|
||||
if (minHeightBasedOnEl) {
|
||||
minHeightBasedOnEl.value = this.minHeightBasedOn;
|
||||
}
|
||||
|
||||
const dryRunToggle = document.getElementById("node-input-enableDryRunProtection");
|
||||
const dryRunPercent = document.getElementById("node-input-dryRunThresholdPercent");
|
||||
const overfillToggle = document.getElementById("node-input-enableOverfillProtection");
|
||||
const overfillPercent = document.getElementById("node-input-overfillThresholdPercent");
|
||||
|
||||
const toggleInput = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) { return; }
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!this.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(this.dryRunThresholdPercent) ? this.dryRunThresholdPercent : 2;
|
||||
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||
toggleInput(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
|
||||
if (overfillToggle && overfillPercent) {
|
||||
overfillToggle.checked = !!this.enableOverfillProtection;
|
||||
overfillPercent.value = Number.isFinite(this.overfillThresholdPercent) ? this.overfillThresholdPercent : 98;
|
||||
overfillToggle.addEventListener('change', () => toggleInput(overfillToggle, overfillPercent));
|
||||
toggleInput(overfillToggle, overfillPercent);
|
||||
}
|
||||
|
||||
const timeLeftInput = document.getElementById("node-input-timeleftToFullOrEmptyThresholdSeconds");
|
||||
if (timeLeftInput) {
|
||||
timeLeftInput.value = Number.isFinite(this.timeleftToFullOrEmptyThresholdSeconds)
|
||||
? this.timeleftToFullOrEmptyThresholdSeconds
|
||||
: 0;
|
||||
}
|
||||
|
||||
// control mode toggle UI
|
||||
const toggleModeSections = (val) => {
|
||||
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
|
||||
const active = document.getElementById(`ps-mode-${val}`);
|
||||
if (active) active.style.display = '';
|
||||
};
|
||||
|
||||
const modeSelect = document.getElementById('node-input-controlMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.value = this.controlMode || 'none';
|
||||
toggleModeSections(modeSelect.value);
|
||||
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||
}
|
||||
|
||||
const setNumberField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||
};
|
||||
|
||||
setNumberField('node-input-startLevel', this.startLevel);
|
||||
setNumberField('node-input-minLevel', this.minLevel);
|
||||
setNumberField('node-input-maxLevel', this.maxLevel);
|
||||
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
|
||||
setNumberField('node-input-flowDeadband', this.flowDeadband);
|
||||
|
||||
// Interactive diagram: place every threshold line/input at its
|
||||
// proportional y on the tank, plus compute derived safety levels
|
||||
// (dryRunLevel, overfillLevel) that are shown both in the diagram
|
||||
// and next to the safety-% fields. Same formulas as
|
||||
// specificClass._validateThresholdOrdering.
|
||||
const DIAG = { topY: 40, botY: 380 };
|
||||
const fNum = (id) => {
|
||||
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
};
|
||||
const yForLevel = (val, basinH) => {
|
||||
if (val == null || !basinH) return null;
|
||||
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
|
||||
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
|
||||
};
|
||||
// Place a row — line, label, input, unit all share the same y.
|
||||
// The diagram is a schematic ordered list (value order is
|
||||
// preserved, but the y-positions are distributed with a
|
||||
// guaranteed minimum gap for readability), not a strictly
|
||||
// proportional rendering.
|
||||
const placeItem = (id, y) => {
|
||||
const line = document.getElementById(`ps-line-${id}`);
|
||||
const label = document.getElementById(`ps-label-${id}`);
|
||||
const unit = document.getElementById(`ps-unit-${id}`);
|
||||
const fo = document.getElementById(`ps-fo-${id}`);
|
||||
const sub = document.getElementById(`ps-sub-${id}`);
|
||||
const lead = document.getElementById(`ps-leader-${id}`);
|
||||
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
|
||||
if (label) label.setAttribute('y', y + 4);
|
||||
if (unit) unit.setAttribute('y', y + 4);
|
||||
if (fo) fo.setAttribute('y', y - 11);
|
||||
if (sub) sub.setAttribute('y', y + 15);
|
||||
if (lead) lead.setAttribute('visibility', 'hidden');
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
const basinH = fNum('basinHeight') || 5;
|
||||
|
||||
// Derived safety levels (participate in the right-column stack)
|
||||
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
|
||||
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
const ovfPct = fNum('overfillThresholdPercent');
|
||||
const ovf = fNum('overflowLevel');
|
||||
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
||||
const ovfLvl = (ovf != null && ovfPct != null) ? ovf * (ovfPct / 100) : null;
|
||||
|
||||
// Right-column stack. TWO anchors: basinHeight pinned at the
|
||||
// tank rim (top) and outflowLevel pinned at its proportional y
|
||||
// (bottom). Everything between is nudged to maintain a minimum
|
||||
// vertical gap via two passes — top-down from the rim, then
|
||||
// bottom-up from the outlet — so the dashed lines keep their
|
||||
// value-order and outlet stays near the floor where it belongs.
|
||||
const items = [
|
||||
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
||||
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
||||
{ id: 'maxLevel', yIdeal: yForLevel(fNum('maxLevel'), basinH) },
|
||||
{ id: 'startLevel', yIdeal: yForLevel(fNum('startLevel'), basinH) },
|
||||
{ id: 'minLevel', yIdeal: yForLevel(fNum('minLevel'), basinH) },
|
||||
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
|
||||
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
|
||||
].filter(it => it.yIdeal != null);
|
||||
|
||||
const GAP = 36;
|
||||
items.sort((a, b) => a.yIdeal - b.yIdeal);
|
||||
for (const it of items) it.y = it.yIdeal;
|
||||
// Pass 1: top-down — push DOWN to maintain GAP; pinned items don't move
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
if (items[i].pinned) continue;
|
||||
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
|
||||
}
|
||||
// Pass 2: bottom-up — push UP so outflow's pin propagates up the stack
|
||||
for (let i = items.length - 2; i >= 0; i--) {
|
||||
if (items[i].pinned) continue;
|
||||
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
|
||||
}
|
||||
for (const it of items) placeItem(it.id, it.y);
|
||||
|
||||
// Zone labels between adjacent thresholds (italic, centered).
|
||||
// Hidden if either bracketing threshold is missing, or the gap
|
||||
// is too small to read (< 14 px).
|
||||
const placeZone = (zoneId, topId, botId) => {
|
||||
const el = document.getElementById(`ps-zone-${zoneId}`);
|
||||
if (!el) return;
|
||||
const top = items.find(it => it.id === topId);
|
||||
const bot = items.find(it => it.id === botId);
|
||||
if (!top || !bot || (bot.y - top.y) < 14) {
|
||||
el.setAttribute('visibility', 'hidden'); return;
|
||||
}
|
||||
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
||||
el.setAttribute('visibility', 'visible');
|
||||
};
|
||||
placeZone('spare', 'overflowLevel', 'maxLevel');
|
||||
placeZone('sewage', 'maxLevel', 'startLevel');
|
||||
placeZone('buffer1', 'startLevel', 'minLevel');
|
||||
placeZone('buffer2', 'minLevel', 'dryRunLevel');
|
||||
// "Dead volume" sits inside the blue band between outflowLevel and the floor
|
||||
const outflowPinned = items.find(it => it.id === 'outflowLevel');
|
||||
const deadLbl = document.getElementById('ps-zone-dead');
|
||||
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
|
||||
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
|
||||
deadLbl.setAttribute('visibility', 'visible');
|
||||
} else if (deadLbl) {
|
||||
deadLbl.setAttribute('visibility', 'hidden');
|
||||
}
|
||||
|
||||
// Inlet arrow — sole item on the left, no stacking concerns
|
||||
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
||||
if (inflowY != null) {
|
||||
const line = document.getElementById('ps-line-inflowLevel');
|
||||
const lbl = document.getElementById('ps-label-inflowLevel');
|
||||
const sub = document.getElementById('ps-sub-inflowLevel');
|
||||
const fo = document.getElementById('ps-fo-inflowLevel');
|
||||
const unit = document.getElementById('ps-unit-inflowLevel');
|
||||
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
|
||||
if (lbl) lbl.setAttribute('y', inflowY - 4);
|
||||
if (sub) sub.setAttribute('y', inflowY + 8);
|
||||
if (fo) fo.setAttribute('y', inflowY - 11);
|
||||
if (unit) unit.setAttribute('y', inflowY + 4);
|
||||
}
|
||||
|
||||
// Dead-volume band: from the (possibly-nudged) outflow line
|
||||
// down to the floor. Use the nudged y so the band meets the
|
||||
// outflow line exactly.
|
||||
const outflowItem = items.find(it => it.id === 'outflowLevel');
|
||||
const deadvol = document.getElementById('ps-deadvol');
|
||||
if (deadvol && outflowItem) {
|
||||
deadvol.setAttribute('y', outflowItem.y);
|
||||
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
|
||||
}
|
||||
|
||||
// dryRunLevel label text (derived, read-only)
|
||||
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||
if (dryLbl) dryLbl.textContent = dryLvl != null
|
||||
? `dryRunLevel ≈ ${dryLvl.toFixed(2)} m (safety — from %)`
|
||||
: 'dryRunLevel ≈ — m (safety — from %)';
|
||||
|
||||
// Safety-section readouts (second view, beneath the diagram)
|
||||
const d1 = document.getElementById('derived-dryRunLevel');
|
||||
if (d1) d1.textContent = dryLvl != null ? `→ dryRunLevel ≈ ${dryLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
|
||||
const d2 = document.getElementById('derived-overfillLevel');
|
||||
if (d2) d2.textContent = ovfLvl != null ? `→ overfillLevel ≈ ${ovfLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
|
||||
|
||||
// Ordering warning ribbon
|
||||
const warn = document.getElementById('ps-warning');
|
||||
const issues = [];
|
||||
const pairs = [
|
||||
['outflowLevel', 'inflowLevel', '<'],
|
||||
['inflowLevel', 'overflowLevel', '<'],
|
||||
['minLevel', 'startLevel', '<='],
|
||||
['startLevel', 'maxLevel', '<'],
|
||||
['maxLevel', 'overflowLevel', '<='],
|
||||
];
|
||||
for (const [a, b, op] of pairs) {
|
||||
const av = fNum(a), bv = fNum(b);
|
||||
if (av == null || bv == null) continue;
|
||||
if (op === '<' ? !(av < bv) : !(av <= bv)) issues.push(`${a} ${op} ${b}`);
|
||||
}
|
||||
if (warn) {
|
||||
if (issues.length) { warn.setAttribute('visibility', 'visible'); warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`; }
|
||||
else { warn.setAttribute('visibility', 'hidden'); }
|
||||
}
|
||||
};
|
||||
['basinHeight','overflowLevel','maxLevel','startLevel','minLevel','inflowLevel','outflowLevel',
|
||||
'dryRunThresholdPercent','overfillThresholdPercent','minHeightBasedOn'].forEach((id) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (el) { el.addEventListener('input', redraw); el.addEventListener('change', redraw); }
|
||||
});
|
||||
setTimeout(redraw, 60);
|
||||
|
||||
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
||||
},
|
||||
@@ -98,14 +354,28 @@
|
||||
|
||||
//node specific
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "NAP";
|
||||
node.minHeightBasedOn = document.getElementById("node-input-minHeightBasedOn").value || "outlet";
|
||||
node.simulator = document.getElementById("node-input-simulator").checked;
|
||||
|
||||
["basinVolume","basinHeight","heightInlet","heightOutlet","heightOverflow","basinBottomRef"]
|
||||
["basinVolume","basinHeight","inflowLevel","outflowLevel","overflowLevel","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
|
||||
.forEach(field => {
|
||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
||||
});
|
||||
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "";
|
||||
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked;
|
||||
node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked;
|
||||
|
||||
// control strategy
|
||||
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
|
||||
|
||||
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||
node.startLevel = parseNum('node-input-startLevel');
|
||||
node.minLevel = parseNum('node-input-minLevel');
|
||||
node.maxLevel = parseNum('node-input-maxLevel');
|
||||
node.flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||
node.flowDeadband = parseNum('node-input-flowDeadband');
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
@@ -115,7 +385,7 @@
|
||||
|
||||
<script type="text/html" data-template-name="pumpingStation">
|
||||
|
||||
<!-- Simulator toggle -->
|
||||
<h4>Simulation</h4>
|
||||
<div class="form-row">
|
||||
<label for="node-input-simulator"><i class="fa fa-play-circle"></i> Simulator</label>
|
||||
<input type="checkbox" id="node-input-simulator" style="width:20px;vertical-align:baseline;" />
|
||||
@@ -123,34 +393,165 @@
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Basin parameters</h4>
|
||||
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">Heights are measured from the basin floor (0 m). Enter values next to each line — the diagram scales to whatever you enter.</p>
|
||||
|
||||
<!-- Basin geometry -->
|
||||
<style>
|
||||
#ps-basin-diagram input[type=number] {
|
||||
width: 100%; height: 20px; box-sizing: border-box;
|
||||
font-size: 11px; padding: 1px 4px; margin: 0;
|
||||
border: 1px solid #ccc; border-radius: 3px; background: #fff;
|
||||
}
|
||||
#ps-basin-diagram input[type=number]:focus { outline: 1px solid #0c99d9; border-color: #0c99d9; }
|
||||
</style>
|
||||
|
||||
<svg id="ps-basin-diagram" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 430"
|
||||
style="display:block;width:100%;max-width:540px;margin:0 0 12px 0;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
|
||||
font-family="Arial,sans-serif" font-size="11">
|
||||
<defs>
|
||||
<marker id="ps-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Tank body -->
|
||||
<rect x="200" y="40" width="120" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
|
||||
<!-- Dead-volume band (y + height updated dynamically below outflowLevel) -->
|
||||
<rect id="ps-deadvol" x="201" width="118" fill="#AACCE0" />
|
||||
<!-- basinVolume — pinned above the rim -->
|
||||
<text id="ps-label-basinVolume" x="330" y="19" fill="#333" font-weight="600">basin volume</text>
|
||||
<foreignObject id="ps-fo-basinVolume" x="425" y="4" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinVolume" min="0" step="0.1" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-basinVolume" x="500" y="19" fill="#555">m³</text>
|
||||
|
||||
<!-- Zone labels (mid-tank italic, positioned dynamically at midpoint between adjacent thresholds) -->
|
||||
<text id="ps-zone-spare" x="260" text-anchor="middle" fill="#B78200" font-size="10" font-style="italic" visibility="hidden">Spare volume before spilling</text>
|
||||
<text id="ps-zone-sewage" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + tank buffer</text>
|
||||
<text id="ps-zone-buffer1" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
|
||||
<text id="ps-zone-buffer2" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
|
||||
<text id="ps-zone-dead" x="260" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead volume</text>
|
||||
|
||||
|
||||
<!-- basinHeight — always at tank rim (y=40 in viewBox coords) -->
|
||||
<line id="ps-line-basinHeight" x1="195" y1="40" x2="325" y2="40" stroke="#333" stroke-width="1.5" />
|
||||
<text id="ps-label-basinHeight" x="330" y="44" fill="#333">basinHeight</text>
|
||||
<foreignObject id="ps-fo-basinHeight" x="425" y="29" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-basinHeight" x="500" y="44" fill="#555">m</text>
|
||||
|
||||
<!-- overflowLevel -->
|
||||
<line id="ps-line-overflowLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-overflowLevel" x="330" fill="#C0392B">overflowLevel</text>
|
||||
<foreignObject id="ps-fo-overflowLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-overflowLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-overflowLevel" x="500" fill="#555">m</text>
|
||||
|
||||
<!-- maxLevel -->
|
||||
<line id="ps-line-maxLevel" x1="195" x2="325" stroke="#D68910" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-maxLevel" x="330" fill="#D68910">maxLevel</text>
|
||||
<foreignObject id="ps-fo-maxLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-maxLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-maxLevel" x="500" fill="#555">m</text>
|
||||
|
||||
<!-- startLevel -->
|
||||
<line id="ps-line-startLevel" x1="195" x2="325" stroke="#1E8449" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-startLevel" x="330" fill="#1E8449">startLevel</text>
|
||||
<foreignObject id="ps-fo-startLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-startLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-startLevel" x="500" fill="#555">m</text>
|
||||
|
||||
<!-- Inlet — arrow + input on the left -->
|
||||
<line id="ps-line-inflowLevel" x1="140" x2="200" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
|
||||
<text id="ps-label-inflowLevel" x="135" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
|
||||
<text id="ps-sub-inflowLevel" x="135" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
|
||||
<foreignObject id="ps-fo-inflowLevel" x="5" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-inflowLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-inflowLevel" x="80" fill="#555">m</text>
|
||||
|
||||
<!-- minLevel -->
|
||||
<line id="ps-line-minLevel" x1="195" x2="325" stroke="#6C3483" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-minLevel" x="330" fill="#6C3483">minLevel</text>
|
||||
<foreignObject id="ps-fo-minLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-minLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-minLevel" x="500" fill="#555">m</text>
|
||||
|
||||
<!-- dryRunLevel (derived, read-only) -->
|
||||
<line id="ps-line-dryRunLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
|
||||
<text id="ps-label-dryRunLevel" x="330" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel ≈ — m (safety — from %)</text>
|
||||
|
||||
<!-- Outlet — arrow on right, input below the threshold column -->
|
||||
<line id="ps-line-outflowLevel" x1="320" x2="360" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
|
||||
<text id="ps-label-outflowLevel" x="365" fill="#1F4E79" font-weight="bold">Outlet</text>
|
||||
<text id="ps-sub-outflowLevel" x="365" fill="#777" font-size="9">top of pipe</text>
|
||||
<foreignObject id="ps-fo-outflowLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-outflowLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-outflowLevel" x="500" fill="#555">m</text>
|
||||
|
||||
<!-- Floor / datum -->
|
||||
<line x1="195" y1="380" x2="325" y2="380" stroke="#000" stroke-width="2" />
|
||||
<text x="330" y="384" fill="#000">0 m (datum)</text>
|
||||
|
||||
<!-- Leader lines: shown when the input row had to be nudged off its threshold's ideal y -->
|
||||
<line id="ps-leader-basinHeight" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-overflowLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-maxLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-startLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-minLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-dryRunLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-outflowLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
|
||||
<!-- Ordering-warning ribbon -->
|
||||
<text id="ps-warning" x="260" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
|
||||
</svg>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Control Strategy</h4>
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinVolume"><i class="fa fa-cube"></i> Basin Volume (m³)</label>
|
||||
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinHeight"><i class="fa fa-arrows-v"></i> Basin Height (m)</label>
|
||||
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
||||
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
|
||||
<select id="node-input-controlMode">
|
||||
<option value="none">None / Manual</option>
|
||||
<option value="levelbased">Level-based</option>
|
||||
<option value="flowbased">Flow-based</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Inlet/Outlet elevations -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightInlet"><i class="fa fa-long-arrow-up"></i> Inlet Elevation (m)</label>
|
||||
<input type="number" id="node-input-heightInlet" min="0" step="0.01" />
|
||||
<div id="ps-mode-levelbased" class="ps-mode-section">
|
||||
<p style="font-size:12px;color:#777;margin:0;">Level-based uses <code>minLevel</code> / <code>startLevel</code> / <code>maxLevel</code> from the diagram above.</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightOutlet"><i class="fa fa-long-arrow-down"></i> Outlet Elevation (m)</label>
|
||||
<input type="number" id="node-input-heightOutlet" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightOverflow"><i class="fa fa-tint"></i> Overflow Level (m)</label>
|
||||
<input type="number" id="node-input-heightOverflow" min="0" step="0.01" />
|
||||
|
||||
<div id="ps-mode-flowbased" class="ps-mode-section" style="display:none">
|
||||
<div class="form-row">
|
||||
<label for="node-input-flowSetpoint">Flow setpoint</label>
|
||||
<input type="number" id="node-input-flowSetpoint" placeholder="m3/h" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-flowDeadband">Deadband</label>
|
||||
<input type="number" id="node-input-flowDeadband" placeholder="m3/h" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Reference</h4>
|
||||
|
||||
<!-- Reference data -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-minHeightBasedOn"><i class="fa fa-arrows-v"></i> Minimum Height Based On</label>
|
||||
<select id="node-input-minHeightBasedOn" style="width:60%;">
|
||||
<option value="inlet">Inlet Elevation</option>
|
||||
<option value="outlet">Outlet Elevation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
|
||||
<select id="node-input-refHeight" style="width:60%;">
|
||||
@@ -159,10 +560,65 @@
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin Bottom (m Refheight)</label>
|
||||
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin floor above datum (m)</label>
|
||||
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Safety</h4>
|
||||
|
||||
<!-- Safety settings -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-timeleftToFullOrEmptyThresholdSeconds"><i class="fa fa-clock-o"></i> Time To Empty/Full (s)</label>
|
||||
<input type="number" id="node-input-timeleftToFullOrEmptyThresholdSeconds" min="0" step="1" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableDryRunProtection">
|
||||
<i class="fa fa-shield"></i> Dry-run Protection
|
||||
</label>
|
||||
<input type="checkbox" id="node-input-enableDryRunProtection" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Prevent pumps from running on low volume</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-dryRunThresholdPercent" style="padding-left:20px;">Low Volume Threshold (%)</label>
|
||||
<input type="number" id="node-input-dryRunThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||
<span id="derived-dryRunLevel" style="margin-left:8px;color:#777;font-size:12px;">→ dryRunLevel ≈ — m</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableOverfillProtection">
|
||||
<i class="fa fa-exclamation-triangle"></i> Overfill Protection
|
||||
</label>
|
||||
<input type="checkbox" id="node-input-enableOverfillProtection" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Stop filling when approaching overflow</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-overfillThresholdPercent" style="padding-left:20px;">High Volume Threshold (%)</label>
|
||||
<input type="number" id="node-input-overfillThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||
<span id="derived-overfillLevel" style="margin-left:8px;color:#777;font-size:12px;">→ overfillLevel ≈ — m</span>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<h3>Output Formats</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||
<option value="process">process</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Shared asset/logger/position menus -->
|
||||
<div id="asset-fields-placeholder"></div>
|
||||
<div id="logger-fields-placeholder"></div>
|
||||
|
||||
123
simulations/README.md
Normal file
123
simulations/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Evaluation harness
|
||||
|
||||
Scenario-based evaluation for pumpingStation. Each scenario scripts a stream of inputs against a configured station, ticks the simulator at 1 s resolution, records every state, and prints a summary + event log + expectation check. Separate from unit tests (`test/`) — those verify individual pieces of logic in isolation; scenarios check end-to-end behaviour over time with realistic input trajectories.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# One scenario
|
||||
node simulations/run.js levelbased-steady
|
||||
|
||||
# All scenarios at once
|
||||
node simulations/run.js --all
|
||||
```
|
||||
|
||||
Per-tick records are written to `simulations/logs/<scenario>.jsonl` for post-hoc analysis (e.g. streaming into InfluxDB for Grafana, or pandas / jq for one-off exploration).
|
||||
|
||||
## Scenario file shape
|
||||
|
||||
```js
|
||||
// simulations/scenarios/<name>.js
|
||||
module.exports = {
|
||||
name: 'scenario-identifier',
|
||||
description: 'one sentence — what the scenario is testing',
|
||||
durationSec: 1200,
|
||||
|
||||
config: { /* PumpingStation config, same shape as nodeClass builds */ },
|
||||
|
||||
setup: async (ps) => {
|
||||
// Optional. Wire fake MGCs, calibrate initial level, etc.
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
// Called every tick (t in seconds). Drive inflow, mode changes,
|
||||
// operator actions, etc.
|
||||
ps.setManualInflow(0.005, Date.now(), 'm3/s');
|
||||
},
|
||||
|
||||
expectations: [
|
||||
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
|
||||
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Supported expectation types
|
||||
|
||||
| Type | Semantics |
|
||||
|---|---|
|
||||
| `max_level_bounded` | max 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` |
|
||||
| `safety_trips_eq` | total ticks with `safetyActive` must equal `value` |
|
||||
| `safety_trips_gt` | total ticks with `safetyActive` must be `> value` |
|
||||
| `end_state_eq` | final record's `field` must equal `value` |
|
||||
| `threshold_issues_eq` | startup guardrail issue count must equal `value` |
|
||||
|
||||
Add new expectation types in `run.js` (`evalExpectation`).
|
||||
|
||||
## Output
|
||||
|
||||
Example run:
|
||||
|
||||
```
|
||||
═══ Scenario: levelbased-steady ═══
|
||||
Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.
|
||||
Duration: 1200s, 1s ticks
|
||||
|
||||
─── Samples (every 10%) ───
|
||||
t(s) level(m) vol(m3) dir netFlow(m3/s) src demand safe
|
||||
────────────────────────────────────────────────────────────────────────────────────────
|
||||
0 2.00 20.00 steady 0 — 0% ·
|
||||
120 2.64 26.40 draining -0.0026 predicted 62% ·
|
||||
240 2.30 23.00 draining -0.0004 predicted 68% ·
|
||||
...
|
||||
|
||||
─── Events (3) ───
|
||||
t= 15s direction steady → filling
|
||||
t= 134s direction filling → draining
|
||||
|
||||
─── Metrics ───
|
||||
level min=2.00 max=2.73 end=2.33 m
|
||||
percControl min=0% max=73% end=66%
|
||||
safety trips=0 ticks
|
||||
threshold issues=0 at startup
|
||||
|
||||
─── Expectations ───
|
||||
✓ no safety trips: 0 ticks with safetyActive (expected 0)
|
||||
✓ level stays below overflow: max level = 2.73 m (bound: ≤ 4.5)
|
||||
✓ level stays above outflow: min level = 2.00 m (bound: ≥ 0.2)
|
||||
✓ no threshold issues on init: 0 threshold issues at startup (expected 0)
|
||||
|
||||
Log: simulations/logs/levelbased-steady.jsonl (1200 records)
|
||||
✅ PASS
|
||||
```
|
||||
|
||||
## Why separate from `test/`?
|
||||
|
||||
| | `test/` | `simulations/` |
|
||||
|---|---|---|
|
||||
| runner | `node --test` | `node simulations/run.js` |
|
||||
| scope | one function / small behaviour | end-to-end scenario over time |
|
||||
| duration | milliseconds | seconds to minutes (simulated) |
|
||||
| assertion style | tight, exact (`assert.equal`) | tolerance / bounds / event counts |
|
||||
| output | TAP | summary table + JSONL for analysis |
|
||||
| purpose | catch regressions | analyse how the system responds to input |
|
||||
|
||||
Unit tests live under `test/basic/`, `test/integration/`, `test/edge/`. Scenarios live here under `simulations/scenarios/`.
|
||||
|
||||
## Sending logs to Grafana (optional)
|
||||
|
||||
The JSONL output has one record per tick. To stream into InfluxDB for Grafana viewing, adapt a small consumer:
|
||||
|
||||
```bash
|
||||
jq -c '{
|
||||
measurement: "pumping_station_eval",
|
||||
tags: { scenario: "'$SCENARIO'" },
|
||||
fields: { level: .level, volume: .volume, demand: .percControl, safety: (.safetyActive|if . then 1 else 0 end) },
|
||||
timestamp: (.t | tonumber | . * 1000000000)
|
||||
}' simulations/logs/$SCENARIO.jsonl \
|
||||
| influx write --bucket=telemetry ...
|
||||
```
|
||||
|
||||
The `t` field is seconds from the scenario start (not wall-clock), so point the Grafana time range at `now() - $duration` after running.
|
||||
40
simulations/formatters/table.js
Normal file
40
simulations/formatters/table.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// ASCII table summary of scenario samples.
|
||||
// Used by simulations/run.js.
|
||||
|
||||
function pad(s, n, left = false) {
|
||||
s = String(s ?? '');
|
||||
if (s.length >= n) return s.slice(0, n);
|
||||
return left ? s.padStart(n) : s.padEnd(n);
|
||||
}
|
||||
|
||||
function num(x, digits = 2) {
|
||||
return Number.isFinite(x) ? x.toFixed(digits) : '—';
|
||||
}
|
||||
|
||||
function formatTable(records, sampleEvery = 1) {
|
||||
if (!records.length) return ' (no records)';
|
||||
const header = ['t(s)', 'level(m)', 'vol(m3)', 'dir', 'netFlow(m3/s)', 'src', 'demand', 'safe'];
|
||||
const rows = [];
|
||||
for (let i = 0; i < records.length; i += sampleEvery) rows.push(records[i]);
|
||||
if (rows[rows.length - 1] !== records[records.length - 1]) rows.push(records[records.length - 1]);
|
||||
|
||||
const widths = [6, 9, 9, 10, 14, 14, 8, 5];
|
||||
const lines = [];
|
||||
lines.push(header.map((h, i) => pad(h, widths[i], true)).join(' '));
|
||||
lines.push(widths.map((w) => '─'.repeat(w)).join(' '));
|
||||
for (const r of rows) {
|
||||
lines.push([
|
||||
pad(r.t, widths[0], true),
|
||||
pad(num(r.level, 2), widths[1], true),
|
||||
pad(num(r.volume, 2), widths[2], true),
|
||||
pad(r.direction ?? '—', widths[3], true),
|
||||
pad(num(r.netFlow, 5), widths[4], true),
|
||||
pad(r.flowSource ?? '—', widths[5], true),
|
||||
pad(num(r.percControl, 0) + '%', widths[6], true),
|
||||
pad(r.safetyActive ? '⚠' : '·', widths[7], true),
|
||||
].join(' '));
|
||||
}
|
||||
return lines.map((l) => ' ' + l).join('\n');
|
||||
}
|
||||
|
||||
module.exports = { formatTable };
|
||||
2
simulations/logs/.gitignore
vendored
Normal file
2
simulations/logs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.jsonl
|
||||
!.gitignore
|
||||
197
simulations/run.js
Normal file
197
simulations/run.js
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
// Scenario runner for pumpingStation. Usage:
|
||||
//
|
||||
// node simulations/run.js <scenario> # run one
|
||||
// node simulations/run.js --all # run all scenarios
|
||||
//
|
||||
// Each scenario lives in simulations/scenarios/<name>.js and exports:
|
||||
// { name, description, durationSec, config, setup?, inputs, expectations? }
|
||||
//
|
||||
// The runner ticks the station once per simulated second, records every
|
||||
// state into simulations/logs/<name>.jsonl, prints a summary table + event log,
|
||||
// and checks expectations.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const PumpingStation = require('../src/specificClass');
|
||||
const { formatTable } = require('./formatters/table');
|
||||
|
||||
function loadScenario(name) {
|
||||
return require(path.join(__dirname, 'scenarios', name));
|
||||
}
|
||||
|
||||
function snapshot(t, ps) {
|
||||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
return {
|
||||
t,
|
||||
level: lvl,
|
||||
volume: vol,
|
||||
direction: ps.state?.direction ?? null,
|
||||
netFlow: ps.state?.netFlow ?? null,
|
||||
flowSource: ps.state?.flowSource ?? null,
|
||||
timeleft: ps.state?.seconds ?? null,
|
||||
percControl: ps.percControl,
|
||||
mode: ps.mode,
|
||||
safetyActive: !!ps.safetyControllerActive,
|
||||
};
|
||||
}
|
||||
|
||||
function evalExpectation(ex, records) {
|
||||
const levels = records.map((r) => r.level).filter(Number.isFinite);
|
||||
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
|
||||
const last = records[records.length - 1] || {};
|
||||
switch (ex.type) {
|
||||
case 'max_level_bounded': {
|
||||
const v = Math.max(...levels);
|
||||
return { ok: v <= ex.value, msg: `max level = ${v.toFixed(2)} m (bound: ≤ ${ex.value})` };
|
||||
}
|
||||
case 'min_level_bounded': {
|
||||
const v = Math.min(...levels);
|
||||
return { ok: v >= ex.value, msg: `min level = ${v.toFixed(2)} m (bound: ≥ ${ex.value})` };
|
||||
}
|
||||
case 'max_demand_bounded': {
|
||||
const v = Math.max(...demands);
|
||||
return { ok: v <= ex.value, msg: `max demand = ${v.toFixed(0)} % (bound: ≤ ${ex.value})` };
|
||||
}
|
||||
case 'safety_trips_eq': {
|
||||
const n = records.filter((r) => r.safetyActive).length;
|
||||
return { ok: n === ex.value, msg: `${n} ticks with safetyActive (expected ${ex.value})` };
|
||||
}
|
||||
case 'safety_trips_gt': {
|
||||
const n = records.filter((r) => r.safetyActive).length;
|
||||
return { ok: n > ex.value, msg: `${n} ticks with safetyActive (expected > ${ex.value})` };
|
||||
}
|
||||
case 'end_state_eq': {
|
||||
return { ok: last[ex.field] === ex.value, msg: `end ${ex.field} = ${last[ex.field]} (expected ${ex.value})` };
|
||||
}
|
||||
case 'threshold_issues_eq': {
|
||||
const n = (records[0] && records[0].thresholdIssues) || 0;
|
||||
return { ok: n === ex.value, msg: `${n} threshold issues at startup (expected ${ex.value})` };
|
||||
}
|
||||
default:
|
||||
return { ok: false, msg: `unknown expectation type: ${ex.type}` };
|
||||
}
|
||||
}
|
||||
|
||||
function events(records) {
|
||||
const out = [];
|
||||
let prev = null;
|
||||
for (const r of records) {
|
||||
if (!prev) { prev = r; continue; }
|
||||
if (r.direction !== prev.direction) out.push({ t: r.t, kind: 'direction', from: prev.direction, to: r.direction });
|
||||
if (r.safetyActive !== prev.safetyActive) out.push({ t: r.t, kind: 'safety', active: r.safetyActive });
|
||||
if (r.mode !== prev.mode) out.push({ t: r.t, kind: 'mode', from: prev.mode, to: r.mode });
|
||||
prev = r;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runScenario(name) {
|
||||
const scenario = loadScenario(name);
|
||||
|
||||
// Use simulated time so the volume integrator sees 1 s per tick.
|
||||
// The class reads Date.now() internally; monkey-patching lets it
|
||||
// advance at scenario pace rather than wall-clock.
|
||||
const realNow = Date.now;
|
||||
let simTime = realNow();
|
||||
Date.now = () => simTime;
|
||||
|
||||
try {
|
||||
const ps = new PumpingStation(scenario.config);
|
||||
if (scenario.setup) await scenario.setup(ps);
|
||||
|
||||
const duration = scenario.durationSec ?? 600;
|
||||
const logDir = path.join(__dirname, 'logs');
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, `${scenario.name}.jsonl`);
|
||||
const log = fs.createWriteStream(logPath);
|
||||
|
||||
const records = [];
|
||||
for (let t = 0; t < duration; t += 1) {
|
||||
simTime += 1000; // advance 1 simulated second
|
||||
if (scenario.inputs) scenario.inputs(t, ps);
|
||||
ps.tick();
|
||||
const snap = snapshot(t, ps);
|
||||
snap.thresholdIssues = ps.thresholdIssues?.length ?? 0;
|
||||
records.push(snap);
|
||||
log.write(JSON.stringify(snap) + '\n');
|
||||
}
|
||||
// Drain so the file is fully written before we return.
|
||||
await new Promise((resolve, reject) => { log.end(); log.on('finish', resolve); log.on('error', reject); });
|
||||
|
||||
return { ps, records, scenario, duration, logPath };
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAndReport(name) {
|
||||
const { ps, records, scenario, duration, logPath } = await runScenario(name);
|
||||
|
||||
// Output
|
||||
console.log(`\n═══ Scenario: ${scenario.name} ═══`);
|
||||
console.log(scenario.description);
|
||||
console.log(`Duration: ${duration}s, 1s ticks`);
|
||||
|
||||
console.log('\n─── Samples (every 10%) ───');
|
||||
console.log(formatTable(records, Math.max(1, Math.floor(duration / 10))));
|
||||
|
||||
const evts = events(records);
|
||||
console.log(`\n─── Events (${evts.length}) ───`);
|
||||
if (!evts.length) console.log(' (none)');
|
||||
for (const e of evts) {
|
||||
if (e.kind === 'direction') console.log(` t=${String(e.t).padStart(4)}s direction ${e.from} → ${e.to}`);
|
||||
else if (e.kind === 'safety') console.log(` t=${String(e.t).padStart(4)}s safety ${e.active ? 'ACTIVE ⚠' : 'cleared'}`);
|
||||
else if (e.kind === 'mode') console.log(` t=${String(e.t).padStart(4)}s mode ${e.from} → ${e.to}`);
|
||||
}
|
||||
|
||||
console.log('\n─── Metrics ───');
|
||||
const levels = records.map((r) => r.level).filter(Number.isFinite);
|
||||
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
|
||||
const trips = records.filter((r) => r.safetyActive).length;
|
||||
if (levels.length) {
|
||||
console.log(` level min=${Math.min(...levels).toFixed(2)} max=${Math.max(...levels).toFixed(2)} end=${levels[levels.length-1].toFixed(2)} m`);
|
||||
}
|
||||
if (demands.length) {
|
||||
console.log(` percControl min=${Math.min(...demands).toFixed(0)}% max=${Math.max(...demands).toFixed(0)}% end=${demands[demands.length-1].toFixed(0)}%`);
|
||||
}
|
||||
console.log(` safety trips=${trips} ticks`);
|
||||
console.log(` threshold issues=${ps.thresholdIssues?.length ?? 0} at startup`);
|
||||
|
||||
let allOk = true;
|
||||
if (scenario.expectations?.length) {
|
||||
console.log('\n─── Expectations ───');
|
||||
for (const ex of scenario.expectations) {
|
||||
const { ok, msg } = evalExpectation(ex, records);
|
||||
allOk = allOk && ok;
|
||||
console.log(` ${ok ? '✓' : '✗'} ${ex.name}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nLog: ${path.relative(process.cwd(), logPath)} (${records.length} records)`);
|
||||
console.log(allOk ? '✅ PASS' : '❌ FAIL');
|
||||
return allOk;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const arg = process.argv[2];
|
||||
if (!arg) {
|
||||
console.error('Usage: node simulations/run.js <scenario> | --all');
|
||||
console.error('Available:', fs.readdirSync(path.join(__dirname, 'scenarios')).map((f) => f.replace(/\.js$/, '')).join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
if (arg === '--all') {
|
||||
const names = fs.readdirSync(path.join(__dirname, 'scenarios')).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, ''));
|
||||
let allOk = true;
|
||||
for (const name of names) {
|
||||
try { allOk = (await runAndReport(name)) && allOk; }
|
||||
catch (err) { console.error(`ERROR in ${name}:`, err.message); allOk = false; }
|
||||
}
|
||||
process.exit(allOk ? 0 : 1);
|
||||
}
|
||||
try { process.exit((await runAndReport(arg)) ? 0 : 1); }
|
||||
catch (err) { console.error('ERROR:', err.message, '\n', err.stack); process.exit(1); }
|
||||
}
|
||||
|
||||
main();
|
||||
60
simulations/scenarios/levelbased-steady.js
Normal file
60
simulations/scenarios/levelbased-steady.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Steady sewer inflow, level-based control, pumps should settle.
|
||||
//
|
||||
// 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
|
||||
// startLevel and maxLevel) at roughly the point where demand matches
|
||||
// inflow. No safety trips should fire.
|
||||
|
||||
module.exports = {
|
||||
name: 'levelbased-steady',
|
||||
description: 'Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.',
|
||||
durationSec: 1200,
|
||||
|
||||
config: {
|
||||
general: { name: 'EvalSteady', id: 'eval-steady', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
enableOverfillProtection: true,
|
||||
overfillThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
|
||||
setup: async (ps) => {
|
||||
// Stub MGC: its pumps collectively deliver (demand/100) × MAX_OUTFLOW.
|
||||
const MAX_OUTFLOW = 0.012; // m³/s
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||
},
|
||||
handleInput: async (_source, demand) => {
|
||||
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||
const outflow = (d / 100) * MAX_OUTFLOW;
|
||||
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
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
ps.setManualInflow(0.008, Date.now(), 'm3/s'); // ≈ 29 m³/h
|
||||
},
|
||||
|
||||
expectations: [
|
||||
{ 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 above outflow', type: 'min_level_bounded', value: 0.2 },
|
||||
{ name: 'no threshold issues on init', type: 'threshold_issues_eq', value: 0 },
|
||||
],
|
||||
};
|
||||
60
simulations/scenarios/levelbased-storm.js
Normal file
60
simulations/scenarios/levelbased-storm.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Storm surge — inflow triples briefly, pumps should saturate at 100%,
|
||||
// level rises toward overflow then recedes.
|
||||
//
|
||||
// Expectation: during the surge (t=300..600), demand reaches 100% and
|
||||
// level may transiently climb above maxLevel. Overflow safety should
|
||||
// fire if the surge overwhelms pump capacity; dry-run should not fire.
|
||||
|
||||
module.exports = {
|
||||
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.',
|
||||
durationSec: 1500,
|
||||
|
||||
config: {
|
||||
general: { name: 'EvalStorm', id: 'eval-storm', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
enableOverfillProtection: true,
|
||||
overfillThresholdPercent: 95,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
|
||||
setup: async (ps) => {
|
||||
const MAX_OUTFLOW = 0.012; // m³/s pumps cannot keep up with 3× surge
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||
},
|
||||
handleInput: async (_src, demand) => {
|
||||
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||
const outflow = (d / 100) * MAX_OUTFLOW;
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
||||
},
|
||||
};
|
||||
ps.calibratePredictedLevel(2.5);
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
const surge = (t >= 300 && t < 600) ? 0.024 : 0.008;
|
||||
ps.setManualInflow(surge, Date.now(), 'm3/s');
|
||||
},
|
||||
|
||||
expectations: [
|
||||
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
|
||||
// Level may exceed maxLevel transiently but must stay under basinHeight
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
66
simulations/scenarios/safety-dry-run-trip.js
Normal file
66
simulations/scenarios/safety-dry-run-trip.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Dry-run safety trip — manual mode, fixed high demand, zero inflow.
|
||||
// Levelbased control would taper demand as the level drops (its ramp),
|
||||
// stalling drainage before safety fires. Manual mode holds demand
|
||||
// constant so the level actually reaches the dry-run threshold.
|
||||
|
||||
module.exports = {
|
||||
name: 'safety-dry-run-trip',
|
||||
description: 'Manual mode, constant 100 % demand, zero inflow; expect safety to force-stop downstream pumps when level reaches the dry-run threshold.',
|
||||
durationSec: 600,
|
||||
|
||||
config: {
|
||||
general: { name: 'EvalDryRun', id: 'eval-dry-run', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'manual',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 50,
|
||||
enableOverfillProtection: false,
|
||||
overfillThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
|
||||
setup: async (ps) => {
|
||||
const MAX_OUTFLOW = 0.04;
|
||||
let mgcRunning = true; // gets toggled by safety's shutdown call
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1', id: 'mgc1' }, functionality: { positionVsParent: 'downstream' } },
|
||||
turnOffAllMachines: () => {
|
||||
mgcRunning = false;
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||
},
|
||||
handleInput: async (_src, demand) => {
|
||||
if (!mgcRunning) return;
|
||||
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value((d / 100) * MAX_OUTFLOW, Date.now(), 'm3/s');
|
||||
},
|
||||
};
|
||||
// Need a downstream machine for safety to shut down
|
||||
ps.machines['pump1'] = {
|
||||
config: { general: { name: 'pump1', id: 'pump1' }, functionality: { positionVsParent: 'downstream' } },
|
||||
_isOperationalState: () => mgcRunning,
|
||||
handleInput: async (_src, action) => {
|
||||
if (action === 'execSequence') mgcRunning = false;
|
||||
},
|
||||
};
|
||||
ps.calibratePredictedLevel(2.5);
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
ps.setManualInflow(0, Date.now(), 'm3/s');
|
||||
if (ps.mode === 'manual') ps.forwardDemandToChildren(100);
|
||||
},
|
||||
|
||||
expectations: [
|
||||
{ name: 'safety engages at some point', type: 'safety_trips_gt', value: 0 },
|
||||
{ name: 'level never goes below outflow pipe', type: 'min_level_bounded', value: 0.2 },
|
||||
],
|
||||
};
|
||||
173
src/nodeClass.js
173
src/nodeClass.js
@@ -44,13 +44,29 @@ class nodeClass {
|
||||
basin: {
|
||||
volume: uiConfig.basinVolume,
|
||||
height: uiConfig.basinHeight,
|
||||
heightInlet: uiConfig.heightInlet,
|
||||
heightOutlet: uiConfig.heightOutlet,
|
||||
heightOverflow: uiConfig.heightOverflow,
|
||||
inflowLevel: uiConfig.inflowLevel,
|
||||
outflowLevel: uiConfig.outflowLevel,
|
||||
overflowLevel: uiConfig.overflowLevel,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: uiConfig.refHeight,
|
||||
minHeightBasedOn: uiConfig.minHeightBasedOn,
|
||||
basinBottomRef: uiConfig.basinBottomRef,
|
||||
},
|
||||
control:{
|
||||
mode: uiConfig.controlMode,
|
||||
levelbased:{
|
||||
minLevel:uiConfig.minLevel,
|
||||
startLevel:uiConfig.startLevel,
|
||||
maxLevel:uiConfig.maxLevel
|
||||
}
|
||||
},
|
||||
safety:{
|
||||
enableDryRunProtection: uiConfig.enableDryRunProtection,
|
||||
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
|
||||
enableOverfillProtection: uiConfig.enableOverfillProtection,
|
||||
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
|
||||
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,65 +102,60 @@ class nodeClass {
|
||||
|
||||
_updateNodeStatus() {
|
||||
const ps = this.source;
|
||||
try {
|
||||
// --- Basin & measurements -------------------------------------------------
|
||||
const maxVolBeforeOverflow = ps.basin?.maxVolOverflow ?? ps.basin?.maxVol ?? 0;
|
||||
const volumeMeasurement = ps.measurements.type("volume").variant("measured").position("atEquipment");
|
||||
const currentVolume = volumeMeasurement.getCurrentValue("m3") ?? 0;
|
||||
const netFlowMeasurement = ps.measurements.type("netFlowRate").variant("predicted").position("atEquipment");
|
||||
const netFlowM3s = netFlowMeasurement?.getCurrentValue("m3/s") ?? 0;
|
||||
const netFlowM3h = netFlowM3s * 3600;
|
||||
const percentFull = ps.measurements.type("volume").variant("procent").position("atEquipment").getCurrentValue() ?? 0;
|
||||
|
||||
// --- State information ----------------------------------------------------
|
||||
const direction = ps.state?.direction || "unknown";
|
||||
const secondsRemaining = ps.state?.seconds ?? null;
|
||||
|
||||
const timeRemaining = secondsRemaining ? `${Math.round(secondsRemaining / 60)}` : 0 + " min";
|
||||
|
||||
// --- Icon / colour selection ---------------------------------------------
|
||||
let symbol = "❔";
|
||||
let fill = "grey";
|
||||
|
||||
switch (direction) {
|
||||
case "filling":
|
||||
symbol = "⬆️";
|
||||
fill = "blue";
|
||||
break;
|
||||
case "draining":
|
||||
symbol = "⬇️";
|
||||
fill = "orange";
|
||||
break;
|
||||
case "stable":
|
||||
symbol = "⏸️";
|
||||
fill = "green";
|
||||
break;
|
||||
default:
|
||||
symbol = "❔";
|
||||
fill = "grey";
|
||||
break;
|
||||
const pickVariant = (type, prefer = ['measured', 'predicted'], position = 'atEquipment', unit) => {
|
||||
for (const variant of prefer) {
|
||||
const chain = ps.measurements.type(type).variant(variant).position(position);
|
||||
const value = unit ? chain.getCurrentValue(unit) : chain.getCurrentValue();
|
||||
if (value != null) return { value, variant };
|
||||
}
|
||||
return { value: null, variant: null };
|
||||
};
|
||||
|
||||
// --- Status text ----------------------------------------------------------
|
||||
const textParts = [
|
||||
`${symbol} ${percentFull.toFixed(1)}%`,
|
||||
`V=${currentVolume.toFixed(2)} / ${maxVolBeforeOverflow.toFixed(2)} m³`,
|
||||
`net=${netFlowM3h.toFixed(1)} m³/h`,
|
||||
`t≈${timeRemaining}`
|
||||
];
|
||||
const vol = pickVariant('volume', ['measured', 'predicted'], 'atEquipment', 'm3');
|
||||
const volPercent = pickVariant('volumePercent', ['measured','predicted'], 'atEquipment'); // already unitless
|
||||
const level = pickVariant('level', ['measured', 'predicted'], 'atEquipment', 'm');
|
||||
const netFlow = pickVariant('netFlowRate', ['measured', 'predicted'], 'atEquipment', 'm3/h');
|
||||
|
||||
return {
|
||||
fill,
|
||||
shape: "dot",
|
||||
text: textParts.join(" | ")
|
||||
};
|
||||
} catch (error) {
|
||||
this.node.error("Error in updateNodeStatus: " + error.message);
|
||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
||||
const maxVolBeforeOverflow = ps.basin?.maxVolAtOverflow ?? ps.basin?.maxVol ?? 0;
|
||||
const currentVolume = vol.value ?? 0;
|
||||
const currentvolPercent = volPercent.value ?? 0;
|
||||
const netFlowM3h = netFlow.value ?? 0;
|
||||
|
||||
const direction = ps.state?.direction ?? 'unknown';
|
||||
const secondsRemaining = ps.state?.seconds ?? null;
|
||||
const timeRemainingMinutes = secondsRemaining != null ? Math.round(secondsRemaining / 60) : null;
|
||||
|
||||
const badgePieces = [];
|
||||
badgePieces.push(`${currentvolPercent.toFixed(1)}% `);
|
||||
badgePieces.push(
|
||||
`V=${currentVolume.toFixed(2)} / ${maxVolBeforeOverflow.toFixed(2)} m³`
|
||||
);
|
||||
badgePieces.push(`net: ${netFlowM3h.toFixed(0)} m³/h`);
|
||||
if (timeRemainingMinutes != null) {
|
||||
badgePieces.push(`t≈${timeRemainingMinutes} min)`);
|
||||
}
|
||||
|
||||
const { symbol, fill } = (() => {
|
||||
switch (direction) {
|
||||
case 'filling': return { symbol: '⬆️', fill: 'blue' };
|
||||
case 'draining': return { symbol: '⬇️', fill: 'orange' };
|
||||
case 'steady': return { symbol: '⏸️', fill: 'green' };
|
||||
default: return { symbol: '❔', fill: 'grey' };
|
||||
}
|
||||
})();
|
||||
|
||||
badgePieces[0] = `${symbol} ${badgePieces[0]}`;
|
||||
|
||||
return {
|
||||
fill,
|
||||
shape: 'dot',
|
||||
text: badgePieces.join(' | ')
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
// any time based functions here
|
||||
_startTickLoop() {
|
||||
setTimeout(() => {
|
||||
@@ -167,8 +178,8 @@ class nodeClass {
|
||||
//pumping station needs time based ticks to recalc level when predicted
|
||||
this.source.tick();
|
||||
const raw = this.source.getOutput();
|
||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
||||
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
|
||||
|
||||
// Send only updated outputs on ports 0 & 1
|
||||
this.node.send([processMsg, influxMsg]);
|
||||
@@ -181,18 +192,53 @@ class nodeClass {
|
||||
this.node.on('input', (msg, send, done) => {
|
||||
switch (msg.topic) {
|
||||
//example
|
||||
/*case 'simulator':
|
||||
this.source.toggleSimulation();
|
||||
case 'changemode':
|
||||
this.source.changeMode(msg.payload);
|
||||
break;
|
||||
default:
|
||||
this.source.handleInput(msg);
|
||||
break;
|
||||
*/
|
||||
case 'registerChild': {
|
||||
// Register this node as a child of the parent node
|
||||
const childId = msg.payload;
|
||||
const childObj = this.RED.nodes.getNode(childId);
|
||||
this.source.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
|
||||
this.source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
|
||||
break;
|
||||
}
|
||||
case 'calibratePredictedVolume': {
|
||||
const injectedVol = parseFloat(msg.payload);
|
||||
this.source.calibratePredictedVolume(injectedVol);
|
||||
break;
|
||||
}
|
||||
case 'calibratePredictedLevel': {
|
||||
const injectedLevel = parseFloat(msg.payload);
|
||||
this.source.calibratePredictedLevel(injectedLevel);
|
||||
break;
|
||||
}
|
||||
case 'q_in': {
|
||||
// payload can be number or { value, unit, timestamp }
|
||||
const val = Number(msg.payload);
|
||||
const unit = msg?.unit;
|
||||
const ts = msg?.timestamp || Date.now();
|
||||
this.source.setManualInflow(val, ts, unit);
|
||||
break;
|
||||
}
|
||||
case 'Qd': {
|
||||
// Manual demand: operator sets the target output via a
|
||||
// dashboard slider. Only accepted when PS is in 'manual'
|
||||
// mode — mirrors how rotatingMachine gates commands by
|
||||
// mode (virtualControl vs auto).
|
||||
const demand = Number(msg.payload);
|
||||
if (!Number.isFinite(demand)) {
|
||||
this.source.logger.warn(`Invalid Qd value: ${msg.payload}`);
|
||||
break;
|
||||
}
|
||||
if (this.source.mode === 'manual') {
|
||||
this.source.forwardDemandToChildren(demand).catch((err) =>
|
||||
this.source.logger.error(`Failed to forward demand: ${err.message}`)
|
||||
);
|
||||
} else {
|
||||
this.source.logger.debug(
|
||||
`Qd ignored in ${this.source.mode} mode. Switch to manual to use the demand slider.`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -207,6 +253,7 @@ class nodeClass {
|
||||
this.node.on('close', (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
this.node.status({}); // clear node status badge
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
1625
src/specificClass.js
1625
src/specificClass.js
File diff suppressed because it is too large
Load Diff
295
test/basic/specificClass.test.js
Normal file
295
test/basic/specificClass.test.js
Normal file
@@ -0,0 +1,295 @@
|
||||
// Basic unit tests for PumpingStation (domain logic, no Node-RED).
|
||||
// Run with: node --test test/basic/specificClass.test.js
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PumpingStation = require('../../src/specificClass');
|
||||
|
||||
// Standard config shape. Override any section by passing { section: {...} }.
|
||||
function makeConfig(overrides = {}) {
|
||||
const base = {
|
||||
general: {
|
||||
name: 'TestStation',
|
||||
id: 'ps-test',
|
||||
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,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
basinBottomRef: 0,
|
||||
minHeightBasedOn: 'outlet',
|
||||
},
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: false,
|
||||
enableOverfillProtection: false,
|
||||
dryRunThresholdPercent: 2,
|
||||
overfillThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
};
|
||||
for (const k of Object.keys(overrides)) {
|
||||
base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k])
|
||||
? { ...base[k], ...overrides[k] }
|
||||
: overrides[k];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
test('Basin geometry — derived values', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('surfaceArea = volume / height', () => {
|
||||
assert.equal(ps.basin.surfaceArea, 10); // 50 / 5
|
||||
});
|
||||
await t.test('maxVol = height × area ≡ volEmptyBasin', () => {
|
||||
assert.equal(ps.basin.maxVol, 50);
|
||||
assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin);
|
||||
});
|
||||
await t.test('maxVolAtOverflow = overflowLevel × area', () => {
|
||||
assert.equal(ps.basin.maxVolAtOverflow, 45); // 4.5 × 10
|
||||
});
|
||||
await t.test('minVolAtInflow = inflowLevel × area', () => {
|
||||
assert.equal(ps.basin.minVolAtInflow, 30); // 3 × 10
|
||||
});
|
||||
await t.test('minVolAtOutflow = outflowLevel × area', () => {
|
||||
assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9); // 0.2 × 10
|
||||
});
|
||||
await t.test('minVol honours minHeightBasedOn=outlet', () => {
|
||||
assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9);
|
||||
});
|
||||
await t.test('minVol honours minHeightBasedOn=inlet', () => {
|
||||
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
|
||||
assert.equal(ps2.basin.minVol, 30);
|
||||
});
|
||||
});
|
||||
|
||||
test('Level ↔ volume roundtrip', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('_calcVolumeFromLevel multiplies by area', () => {
|
||||
assert.equal(ps._calcVolumeFromLevel(2), 20);
|
||||
});
|
||||
await t.test('_calcVolumeFromLevel clamps negatives to 0', () => {
|
||||
assert.equal(ps._calcVolumeFromLevel(-3), 0);
|
||||
});
|
||||
await t.test('_calcLevelFromVolume divides by area', () => {
|
||||
assert.equal(ps._calcLevelFromVolume(20), 2);
|
||||
});
|
||||
await t.test('_calcLevelFromVolume clamps negatives to 0', () => {
|
||||
assert.equal(ps._calcLevelFromVolume(-10), 0);
|
||||
});
|
||||
await t.test('roundtrip preserves level', () => {
|
||||
const v = ps._calcVolumeFromLevel(2.7);
|
||||
assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10);
|
||||
});
|
||||
});
|
||||
|
||||
test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
await t.test('valid config returns no issues', () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
assert.equal(ps.thresholdIssues.length, 0);
|
||||
});
|
||||
|
||||
await t.test('minLevel > startLevel flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel'));
|
||||
});
|
||||
|
||||
await t.test('startLevel == maxLevel flagged (must be strict <)', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||
});
|
||||
|
||||
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 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel'));
|
||||
});
|
||||
|
||||
await t.test('overflowLevel > basinHeight flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel'));
|
||||
});
|
||||
|
||||
await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => {
|
||||
// With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3.
|
||||
// dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails.
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
hydraulics: { minHeightBasedOn: 'inlet' },
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel'));
|
||||
});
|
||||
});
|
||||
|
||||
test('Direction derivation — _deriveDirection', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('positive flow above dead-band → filling', () => {
|
||||
assert.equal(ps._deriveDirection(0.01), 'filling');
|
||||
});
|
||||
await t.test('negative flow below dead-band → draining', () => {
|
||||
assert.equal(ps._deriveDirection(-0.01), 'draining');
|
||||
});
|
||||
await t.test('flow inside dead-band → steady', () => {
|
||||
assert.equal(ps._deriveDirection(0), 'steady');
|
||||
assert.equal(ps._deriveDirection(1e-5), 'steady');
|
||||
assert.equal(ps._deriveDirection(-1e-5), 'steady');
|
||||
});
|
||||
});
|
||||
|
||||
test('Mode change — changeMode', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('valid mode swap updates this.mode', () => {
|
||||
ps.changeMode('manual');
|
||||
assert.equal(ps.mode, 'manual');
|
||||
});
|
||||
await t.test('rejected mode leaves this.mode unchanged', () => {
|
||||
ps.changeMode('manual');
|
||||
ps.changeMode('notamode');
|
||||
assert.equal(ps.mode, 'manual');
|
||||
});
|
||||
});
|
||||
|
||||
test('Calibration — predicted volume and level', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('calibratePredictedVolume rewrites volume series', () => {
|
||||
ps.calibratePredictedVolume(25);
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 25) < 1e-9);
|
||||
});
|
||||
await t.test('calibratePredictedVolume also writes level (= vol / area)', () => {
|
||||
ps.calibratePredictedVolume(30);
|
||||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
assert.ok(Math.abs(lvl - 3) < 1e-9); // 30 / 10
|
||||
});
|
||||
await t.test('calibratePredictedLevel writes level + volume = level × area', () => {
|
||||
ps.calibratePredictedLevel(2.5);
|
||||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
|
||||
assert.ok(Math.abs(vol - 25) < 1e-9); // 2.5 × 10
|
||||
});
|
||||
});
|
||||
|
||||
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
let turnOffCalls = 0;
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => { turnOffCalls++; },
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(turnOffCalls, 1);
|
||||
});
|
||||
|
||||
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 42; // simulated previous demand
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => { throw new Error('should not be called in dead zone'); },
|
||||
};
|
||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 42); // unchanged
|
||||
});
|
||||
|
||||
await t.test('level ≥ startLevel → 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); // midpoint of startLevel=2 and maxLevel=4
|
||||
await ps._controlLevelBased();
|
||||
// lerp(3, [2,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('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
||||
await ps._controlLevelBased();
|
||||
assert.ok(ps.percControl >= 100);
|
||||
});
|
||||
});
|
||||
|
||||
test('getOutput — flattens basin + state + demand', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 37;
|
||||
|
||||
await t.test('includes basin geometry fields', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.volEmptyBasin, 50);
|
||||
assert.equal(out.maxVolAtOverflow, 45);
|
||||
assert.equal(out.minVolAtInflow, 30);
|
||||
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
|
||||
});
|
||||
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.ok('direction' in out);
|
||||
assert.ok('flowSource' in out);
|
||||
assert.ok('timeleft' in out);
|
||||
});
|
||||
await t.test('includes percControl', () => {
|
||||
assert.equal(ps.getOutput().percControl, 37);
|
||||
});
|
||||
});
|
||||
|
||||
test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.setManualInflow(0.05, Date.now(), 'm3/s'); // 0.05 m³/s
|
||||
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);
|
||||
});
|
||||
18
wiki/README.md
Normal file
18
wiki/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# pumpingStation — Documentation
|
||||
|
||||
All docs and diagrams for this node live in this folder so they version-lock with the code they describe.
|
||||
|
||||
## Pages
|
||||
|
||||
- **[Functional Description](functional-description.md)** — operator-facing reference derived from `src/specificClass.js`: basin model, net-flow selection, safety interlocks, registration topology.
|
||||
- **[Control modes](modes/README.md)** — one page per control mode (`levelbased`, `flowbased`, …) describing how the mode uses the shared basin model to compute demand.
|
||||
|
||||
## Diagrams
|
||||
|
||||
Editable draw.io SVGs live in [`diagrams/`](diagrams/). See [`diagrams/README.md`](diagrams/README.md) for the editing workflow — open the `.drawio.svg` in [draw.io](https://app.diagrams.net/), edit it, then export back to SVG with the source embedded.
|
||||
|
||||
The basin model is the shared physical canvas ([`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg)); per-mode transfer-function diagrams live under [`diagrams/modes/`](diagrams/modes/). Mode-specific thresholds such as `startLevel` belong in those mode diagrams, not in the generic basin model.
|
||||
|
||||
## Part of
|
||||
|
||||
This node is a git submodule of [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV). The EVOLV superproject has its own [`wiki/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki) with platform-level docs (architecture, concepts, shared manuals).
|
||||
71
wiki/diagrams/README.md
Normal file
71
wiki/diagrams/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Diagrams
|
||||
|
||||
Editable source diagrams for the pumpingStation wiki. The current diagrams are **`.drawio.svg` files with the draw.io source embedded**, so anyone can edit the SVG directly in [draw.io](https://app.diagrams.net/) without touching any Markdown.
|
||||
|
||||
## File roles
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `<name>.drawio` | Optional native draw.io XML source, if a diagram also keeps a standalone source file. |
|
||||
| `<name>.drawio.svg` | SVG export of the same diagram (with source embedded). What the wiki actually renders, and what round-trips back into draw.io. |
|
||||
|
||||
An optional standalone `.drawio` file can be committed beside the SVG, but the embedded-source SVG is enough for the wiki to render and for the next editor to pick up from exactly where the last one left off.
|
||||
|
||||
## Editing workflow
|
||||
|
||||
1. **Clone** the repo (you likely already have it if you're editing):
|
||||
```bash
|
||||
git clone https://gitea.wbd-rd.nl/RnD/pumpingStation.git
|
||||
cd pumpingStation/wiki/diagrams
|
||||
```
|
||||
2. **Open** the `.drawio.svg` file in draw.io:
|
||||
- Web: [app.diagrams.net](https://app.diagrams.net/) → *Open Existing Diagram*, or drag-and-drop.
|
||||
- Desktop: [drawio-desktop](https://github.com/jgraph/drawio-desktop/releases).
|
||||
3. **Edit** — move shapes, change labels, adjust layout.
|
||||
4. **Export** to SVG with the source embedded:
|
||||
- `File → Export as → SVG…`
|
||||
- Check **Include a copy of my diagram** ← this is what lets future edits round-trip through the SVG.
|
||||
- Save next to the source as `<name>.drawio.svg` (overwrite).
|
||||
5. **Commit & push** the edited SVG, plus the `.drawio` file if one exists:
|
||||
```bash
|
||||
git add wiki/diagrams/<name>.drawio.svg
|
||||
git commit -m "Update <name>: <what changed>"
|
||||
git push
|
||||
```
|
||||
|
||||
## Referencing a diagram from a wiki page
|
||||
|
||||
In any Markdown page under `wiki/`:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up in exports.
|
||||
|
||||
## Naming
|
||||
|
||||
- kebab-case, one concept per diagram.
|
||||
- Current diagrams:
|
||||
|
||||
| Diagram | Shows |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
## Making a brand-new diagram
|
||||
|
||||
1. Open draw.io, start blank.
|
||||
2. Draw it.
|
||||
3. `File → Export as → SVG…` with **Include a copy of my diagram** checked → save as `wiki/diagrams/<name>.drawio.svg`.
|
||||
4. Reference from the wiki page with ``.
|
||||
5. Add an entry to the table above.
|
||||
6. Commit the new `.drawio.svg` and updated `.md` together.
|
||||
|
||||
## These starters are rough
|
||||
|
||||
Some diagrams are still rough — layout is approximate, colors and fonts may be defaults, and alignment may need refinement. They're meant to be improved in draw.io as the model settles.
|
||||
|
||||
Open the `.drawio.svg` in draw.io and it will load the editable model. The SVG has the draw.io XML embedded in a `content="…"` attribute on the root `<svg>` element — that's what lets draw.io re-open its own SVG exports.
|
||||
6
wiki/diagrams/basin-model.drawio.svg
Normal file
6
wiki/diagrams/basin-model.drawio.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 686 KiB |
162
wiki/diagrams/control-zones.drawio.svg
Normal file
162
wiki/diagrams/control-zones.drawio.svg
Normal file
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 660" font-family="Arial, sans-serif" font-size="13" content="<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="control-zones" id="controlZones">
|
||||
<mxGraphModel dx="1000" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="700" pageHeight="800" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="levelbased mode — three zones" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="20" width="500" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="axis" value="" style="endArrow=classic;html=1;strokeColor=#000;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="280" y="600" as="sourcePoint" />
|
||||
<mxPoint x="280" y="80" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="axis_label" value="level" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="240" y="60" width="50" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="overflow" value="heightOverflow — weir crest (spill → measure)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="130" width="380" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="overflow_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="140" as="sourcePoint" />
|
||||
<mxPoint x="290" y="140" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="run_band" value="RUN — linear 0 → 100 %" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#1E8449;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="160" width="220" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="maxflow" value="maxFlowLevel — 100 % demand" style="text;html=1;fontSize=12;align=left;fontColor=#D68910;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="265" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="maxflow_tick" value="" style="endArrow=none;html=1;strokeColor=#D68910;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="275" as="sourcePoint" />
|
||||
<mxPoint x="295" y="275" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="ramp_label" value="(ramp — demand scales linearly with level)" style="text;html=1;fontSize=11;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="300" width="320" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="startlevel" value="startLevel — 0 % demand (ramp starts)" style="text;html=1;fontSize=12;align=left;fontColor=#1E8449;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="335" width="340" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="start_tick" value="" style="endArrow=none;html=1;strokeColor=#1E8449;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="345" as="sourcePoint" />
|
||||
<mxPoint x="295" y="345" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="dead_band" value="DEAD ZONE — hysteresis, keep last cmd" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF8E1;strokeColor=#F57C00;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="360" width="220" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="inlet" value="heightInlet — inflow pipe" style="text;html=1;fontSize=12;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="395" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="inlet_tick" value="" style="endArrow=none;html=1;strokeColor=#1F4E79;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="405" as="sourcePoint" />
|
||||
<mxPoint x="290" y="405" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="stoplevel" value="stopLevel — unconditional STOP" style="text;html=1;fontSize=12;align=left;fontColor=#6C3483;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="440" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="stop_tick" value="" style="endArrow=none;html=1;strokeColor=#6C3483;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="450" as="sourcePoint" />
|
||||
<mxPoint x="295" y="450" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="stop_band" value="pumps OFF (MGC shutdown)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#F4ECF7;strokeColor=#6C3483;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="465" width="220" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="outlet" value="heightOutlet — outflow pipe (dry-run trip here)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="510" width="360" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outlet_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="520" as="sourcePoint" />
|
||||
<mxPoint x="290" y="520" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="floor" value="0 (floor)" style="text;html=1;fontSize=11;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="580" width="60" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>">
|
||||
<title>levelbased mode — three zones</title>
|
||||
<defs>
|
||||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#000" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<text x="350" y="30" text-anchor="middle" font-weight="bold" font-size="16">levelbased mode — three zones</text>
|
||||
|
||||
<!-- Vertical level axis -->
|
||||
<line x1="280" y1="600" x2="280" y2="80" stroke="#000" stroke-width="2" marker-end="url(#arr)" />
|
||||
<text x="260" y="75" text-anchor="end" font-weight="bold" font-size="13">level</text>
|
||||
|
||||
<!-- heightOverflow -->
|
||||
<line x1="270" y1="140" x2="290" y2="140" stroke="#B22222" stroke-width="2" />
|
||||
<text x="300" y="144" fill="#B22222" font-size="12">heightOverflow — weir crest (spill → measure)</text>
|
||||
|
||||
<!-- RUN band -->
|
||||
<rect x="300" y="160" width="240" height="110" fill="#E8F5E9" stroke="#1E8449" />
|
||||
<text x="420" y="220" text-anchor="middle" font-size="13" fill="#1E8449" font-weight="bold">RUN</text>
|
||||
<text x="420" y="238" text-anchor="middle" font-size="12" fill="#1E8449">linear 0 → 100 %</text>
|
||||
|
||||
<!-- maxFlowLevel -->
|
||||
<line x1="265" y1="275" x2="295" y2="275" stroke="#D68910" stroke-width="3" />
|
||||
<text x="305" y="279" fill="#D68910" font-size="12" font-weight="bold">maxFlowLevel — 100 % demand</text>
|
||||
|
||||
<!-- Ramp label -->
|
||||
<text x="305" y="314" font-size="11" font-style="italic">(ramp — demand scales linearly with level)</text>
|
||||
|
||||
<!-- startLevel -->
|
||||
<line x1="265" y1="345" x2="295" y2="345" stroke="#1E8449" stroke-width="3" />
|
||||
<text x="305" y="349" fill="#1E8449" font-size="12" font-weight="bold">startLevel — 0 % demand (ramp starts)</text>
|
||||
|
||||
<!-- DEAD ZONE band -->
|
||||
<rect x="300" y="360" width="240" height="80" fill="#FFF8E1" stroke="#F57C00" />
|
||||
<text x="420" y="390" text-anchor="middle" font-size="13" fill="#B78200" font-weight="bold">DEAD ZONE</text>
|
||||
<text x="420" y="408" text-anchor="middle" font-size="12" fill="#B78200">hysteresis — keep last cmd</text>
|
||||
|
||||
<!-- heightInlet (inside dead zone) -->
|
||||
<line x1="270" y1="405" x2="290" y2="405" stroke="#1F4E79" stroke-width="2" />
|
||||
<text x="550" y="409" fill="#1F4E79" font-size="12">heightInlet</text>
|
||||
|
||||
<!-- stopLevel -->
|
||||
<line x1="265" y1="450" x2="295" y2="450" stroke="#6C3483" stroke-width="3" />
|
||||
<text x="305" y="454" fill="#6C3483" font-size="12" font-weight="bold">stopLevel — unconditional STOP</text>
|
||||
|
||||
<!-- STOP band -->
|
||||
<rect x="300" y="465" width="240" height="80" fill="#F4ECF7" stroke="#6C3483" />
|
||||
<text x="420" y="500" text-anchor="middle" font-size="13" fill="#6C3483" font-weight="bold">pumps OFF</text>
|
||||
<text x="420" y="518" text-anchor="middle" font-size="12" fill="#6C3483">(MGC shutdown)</text>
|
||||
|
||||
<!-- heightOutlet -->
|
||||
<line x1="270" y1="540" x2="290" y2="540" stroke="#B22222" stroke-width="2" />
|
||||
<text x="305" y="544" fill="#B22222" font-size="12">heightOutlet — outflow pipe (dry-run trip)</text>
|
||||
|
||||
<!-- floor -->
|
||||
<line x1="265" y1="600" x2="295" y2="600" stroke="#000" stroke-width="2" />
|
||||
<text x="305" y="604" font-size="11">0 (floor)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
6
wiki/diagrams/modes/basin-mode-level-linear.drawio.svg
Normal file
6
wiki/diagrams/modes/basin-mode-level-linear.drawio.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 271 KiB |
99
wiki/diagrams/safety-rules.drawio.svg
Normal file
99
wiki/diagrams/safety-rules.drawio.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620" font-family="Arial, sans-serif" font-size="13" content="<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="safety-rules" id="safetyRules">
|
||||
<mxGraphModel dx="1200" dy="700" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="900" pageHeight="700" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="Safety rules — asymmetric by direction" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="20" width="600" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="dryrun_box" value="DRY-RUN&#10;(direction = draining)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#E65100;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="80" width="340" height="340" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_upstream" value="upstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="140" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_downstream" value="downstream children — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="170" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_machinegroups" value="machineGroups — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="200" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_control" value="control loop — BLOCKED" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="230" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_note" value="safetyControllerActive = true&#10;&#10;Pumps must stop before sucking air." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="290" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="overfill_box" value="OVERFILL&#10;(direction = filling)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="480" y="80" width="340" height="340" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_upstream" value="upstream children — STOP ⚠" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#C62828;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="140" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_downstream" value="downstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="170" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_machinegroups" value="machineGroups — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="200" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_control" value="control loop — ACTIVE" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="230" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_note" value="Level control keeps commanding downstream MGC.&#10;&#10;⚠ &quot;upstream STOP&quot; is only correct in a cascaded layout. In a gravity-sewer station the inflow can&apos;t be stopped — log the spill instead." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="290" width="300" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="trigger_title" value="Triggers (either condition fires the rule):" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="450" width="740" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="trigger_list" value="• vol &lt; triggerLowVol (triggerLowVol = minVol × (1 + pct/100))&#10;• vol &gt; triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)&#10;• remainingTime &lt; timeleftToFullOrEmptyThresholdSeconds (if enabled)" style="text;html=1;fontSize=12;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="480" width="740" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>">
|
||||
<title>Safety rules — asymmetric by direction</title>
|
||||
|
||||
<text x="450" y="30" text-anchor="middle" font-weight="bold" font-size="16">Safety rules — asymmetric by direction</text>
|
||||
|
||||
<!-- DRY-RUN box -->
|
||||
<rect x="80" y="80" width="340" height="340" fill="#FFF3E0" stroke="#E65100" stroke-width="2" />
|
||||
<text x="250" y="112" text-anchor="middle" font-weight="bold" font-size="14">DRY-RUN</text>
|
||||
<text x="250" y="130" text-anchor="middle" font-size="13" fill="#6F4A19">(direction = draining)</text>
|
||||
|
||||
<text x="100" y="162" font-size="13">upstream children — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="100" y="188" font-size="13" fill="#E65100">downstream children — <tspan font-weight="bold">STOP</tspan></text>
|
||||
<text x="100" y="214" font-size="13" fill="#E65100">machineGroups — <tspan font-weight="bold">STOP</tspan></text>
|
||||
<text x="100" y="240" font-size="13" fill="#E65100">control loop — <tspan font-weight="bold">BLOCKED</tspan></text>
|
||||
|
||||
<line x1="100" y1="268" x2="400" y2="268" stroke="#E65100" stroke-dasharray="3 3" />
|
||||
<text x="100" y="294" font-size="12" font-style="italic">safetyControllerActive = true</text>
|
||||
<text x="100" y="316" font-size="12" font-style="italic">Pumps must stop before sucking air.</text>
|
||||
|
||||
<!-- OVERFILL box -->
|
||||
<rect x="480" y="80" width="340" height="340" fill="#FFEBEE" stroke="#C62828" stroke-width="2" />
|
||||
<text x="650" y="112" text-anchor="middle" font-weight="bold" font-size="14">OVERFILL</text>
|
||||
<text x="650" y="130" text-anchor="middle" font-size="13" fill="#7A1919">(direction = filling)</text>
|
||||
|
||||
<text x="500" y="162" font-size="13" fill="#C62828">upstream children — <tspan font-weight="bold">STOP</tspan> ⚠</text>
|
||||
<text x="500" y="188" font-size="13">downstream children — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="500" y="214" font-size="13">machineGroups — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="500" y="240" font-size="13">control loop — <tspan font-weight="bold">ACTIVE</tspan></text>
|
||||
|
||||
<line x1="500" y1="268" x2="800" y2="268" stroke="#C62828" stroke-dasharray="3 3" />
|
||||
<text x="500" y="294" font-size="12" font-style="italic">Level control keeps commanding downstream MGC.</text>
|
||||
<text x="500" y="324" font-size="12" font-style="italic" fill="#C62828">⚠ "upstream STOP" is only correct in a cascaded layout.</text>
|
||||
<text x="500" y="342" font-size="12" font-style="italic" fill="#C62828">In a gravity-sewer station the inflow can't be</text>
|
||||
<text x="500" y="360" font-size="12" font-style="italic" fill="#C62828">stopped — log the spill instead.</text>
|
||||
|
||||
<!-- Triggers block -->
|
||||
<text x="80" y="470" font-weight="bold" font-size="13">Triggers (either condition fires the rule):</text>
|
||||
<text x="100" y="498" font-size="12">• vol < triggerLowVol (triggerLowVol = minVol × (1 + pct/100))</text>
|
||||
<text x="100" y="520" font-size="12">• vol > triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)</text>
|
||||
<text x="100" y="542" font-size="12">• remainingTime < timeleftToFullOrEmptyThresholdSeconds (if enabled)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
352
wiki/functional-description.md
Normal file
352
wiki/functional-description.md
Normal file
@@ -0,0 +1,352 @@
|
||||
---
|
||||
title: pumpingStation — Functional Description
|
||||
node: pumpingStation
|
||||
updated: 2026-04-22
|
||||
status: draft
|
||||
---
|
||||
|
||||
# pumpingStation — Functional Description
|
||||
|
||||
The `pumpingStation` node models an S88 **Process Cell**: a wet-well basin with inflow and outflow, wrapped around one or more pump controllers. Every second it recomputes the basin's water balance, picks the most trustworthy net-flow source, runs its safety interlocks, and finally commands its children (individual pumps, `machineGroupControl`, or nested pumping stations) so the level stays inside the safe operating band.
|
||||
|
||||
This page is the operator-facing reference, derived from [`src/specificClass.js`](../src/specificClass.js). For the 3-tier code layout see [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md); for the atomic pump model see the [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki).
|
||||
|
||||
> **Diagrams on this page are editable.** Sources live in [`diagrams/`](diagrams/) — open the `.drawio` file in [draw.io](https://app.diagrams.net/), export to SVG, commit. See [`diagrams/README.md`](diagrams/README.md).
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Node category | EVOLV |
|
||||
| S88 level | Process Cell (`#0c99d9`, lane L5) |
|
||||
| Inputs | 1 (message-driven) |
|
||||
| Outputs | 3 — `process` / `dbase` / `parent` |
|
||||
| Tick period | 1 s |
|
||||
| Basin model | Rectangular prismatic — `volume = level × surfaceArea` |
|
||||
| Canonical units (internal) | Pa, m³/s, W, K, m, m³ |
|
||||
| Control modes implemented | `levelbased`, `manual` (placeholders for `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid`) |
|
||||
| Default flow dead-band | `1e-4 m³/s` (≈ 0.36 m³/h) |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Construct.** The node merges the user's editor config over the schema defaults, creates the measurement store, and seeds the predicted volume at the basin's operational floor (`minVol`).
|
||||
2. **Register children.** Sensors, pumps, machine groups, and nested stations register via the Port-2 handshake. The station subscribes only to the *highest-level aggregator* for predicted flow to avoid double-counting (MGC if present, otherwise the individual pump).
|
||||
3. **Tick loop (1 s).** `_updatePredictedVolume → _selectBestNetFlow → _safetyController → _controlLogic → state snapshot → output`.
|
||||
|
||||
## Editor configuration
|
||||
|
||||
Every field on the pumpingStation editor maps directly to the config schema in `generalFunctions/src/configs/pumpingStation.json`.
|
||||
|
||||
### Basin geometry (section `basin`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Basin Volume (m³)** | `1` | Total geometric storage volume from basin floor to rim. |
|
||||
| **Basin Height (m)** | `1` | Physical wall height from floor to rim. |
|
||||
| **Inlet Elevation (m)** | `2` | Bottom/invert of the incoming sewer pipe, measured from the basin floor. This is the level where backing up into the inlet starts to matter hydraulically. |
|
||||
| **Outlet Elevation (m)** | `0.2` | Top of the pump-suction/outlet pipe, measured from the basin floor. This is the practical lower hydraulic reference for pump protection. |
|
||||
| **Inlet Pipe Diameter (m)** | `0.4` | Nominal incoming sewer pipe diameter. Used with `inflowLevel` to distinguish pipe bottom, centre, and crown in future hydraulic upgrades. |
|
||||
| **Outlet Pipe Diameter (m)** | `0.4` | Nominal pump-suction/outlet pipe diameter. Used with `outflowLevel` to distinguish pipe top, centre, and invert in future hydraulic upgrades. |
|
||||
| **Overflow Level (m)** | `2.5` | Physical overflow-weir crest, measured from the floor. At or above this level the basin is actually spilling. |
|
||||
|
||||
Constant cross-section is assumed: `surfaceArea = volume / height`. All derived volumes (`minVolAtOutflow`, `minVolAtInflow`, `maxVolAtOverflow`, `maxVol`) are computed once in `initBasinProperties()` and kept on `station.basin`.
|
||||
|
||||
The current runtime still uses the level fields directly for its volume math. Pipe diameters are part of the basin model contract so later hydraulic logic can reason about pipe invert/crown and not silently treat every pipe elevation as a centreline.
|
||||
|
||||
### Hydraulics (section `hydraulics`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Minimum Height Based On** | `outlet` | `outlet` → `minVol = outflowLevel × area` (includes the buffer). `inlet` → `minVol = inflowLevel × area` (buffer treated as unavailable). |
|
||||
| **Reference Height** | `NAP` | Vertical datum: `NAP` / `EVRF` / `EGM2008`. Metadata only — not used in math today. |
|
||||
| **Basin Bottom (m Refheight)** | `0` | Absolute elevation of the basin floor, for cross-basin comparisons. |
|
||||
|
||||
### Control (section `control`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Control mode** | `levelbased` | Active control strategy. Schema enumerates seven modes; today `levelbased` is fully implemented, `manual` forwards demand via `Qd`, others are placeholders. |
|
||||
| **minLevel (m)** | `1` | Below this level → unconditional MGC shutdown. |
|
||||
| **startLevel (m)** | `1` | Mode-specific threshold. In `levelbased`, this is the bottom of the linear scaling range (0 % demand). It is not part of the generic basin model because other modes can define a different start policy. |
|
||||
| **maxLevel (m)** | `4` | Upper normal operating/storage level used by the active mode. In `levelbased`, this is where demand reaches 100 %. |
|
||||
| **Flow setpoint** | `0` | Flow-based target (m³/h). Placeholder until `flowbased` is wired. |
|
||||
| **Deadband** | `0` | Flow-based deadband (m³/h). Placeholder. |
|
||||
|
||||
### Safety (section `safety`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **High Volume Threshold (%)** | `98` | Safety margin below physical overflow: `highVolumeSafetyVol = maxVolAtOverflow × pct/100`. Actual overflowing is still the boolean condition `level >= overflowLevel`. |
|
||||
|
||||
### Output formats
|
||||
|
||||
- **Process Output** — format for Port 0 (`process` / `json` / `csv`).
|
||||
- **Database Output** — format for Port 1 (`influxdb` / `json` / `csv`).
|
||||
|
||||
> **Tip — always configure every field.** The pumpingStation mixes geometry and control thresholds freely. Leaving `overflowLevel` at the schema default of 2.5 m while sizing the basin for 10 m walls produces nonsensical fill-percentages and spurious safety events. See the [EVOLV flow-layout rules §9](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) for the completeness rule.
|
||||
|
||||
## Input topics
|
||||
|
||||
All commands enter on the single input port. `msg.topic` selects the handler; `msg.payload` carries the argument.
|
||||
|
||||
### `changemode`
|
||||
|
||||
```json
|
||||
{ "topic": "changemode", "payload": "manual" }
|
||||
```
|
||||
|
||||
Switches the active control strategy. The new mode must be in `config.control.allowedModes` — unknown values are rejected with a warning. Typical transitions: `levelbased ⇄ manual` for operator override during maintenance.
|
||||
|
||||
### `calibratePredictedVolume`
|
||||
|
||||
```json
|
||||
{ "topic": "calibratePredictedVolume", "payload": 3.4 }
|
||||
```
|
||||
|
||||
Hard-reset the predicted volume time-series to the supplied value (m³). Also rewrites the predicted level (derived from the constant-area geometry) and resets the internal flow-integrator state. Use this when a trustworthy measured level becomes available.
|
||||
|
||||
### `calibratePredictedLevel`
|
||||
|
||||
```json
|
||||
{ "topic": "calibratePredictedLevel", "payload": 1.8 }
|
||||
```
|
||||
|
||||
Same as above, but caller supplies a level (m). The predicted volume is recomputed via `volume = level × surfaceArea`.
|
||||
|
||||
### `q_in`
|
||||
|
||||
```json
|
||||
{ "topic": "q_in", "payload": 300, "unit": "l/s" }
|
||||
```
|
||||
|
||||
Inject a **manual inflow** into the basin. Registered as a predicted flow under the synthetic child `manual-qin` at position `in`. Useful when no physical inflow sensor is wired but the inflow is known externally (e.g. fed from a sewer model).
|
||||
|
||||
### `Qd`
|
||||
|
||||
```json
|
||||
{ "topic": "Qd", "payload": 75 }
|
||||
```
|
||||
|
||||
Forward a manual demand to every child aggregator (MGC first, then any direct pumps). **Only honoured when `config.control.mode === 'manual'`** — in any other mode the command is logged and discarded. Mirrors how `rotatingMachine` gates commands behind its mode field. The interpretation of the number depends on the child's scaling (`absolute` = m³/h, `normalized` = 0–100 %).
|
||||
|
||||
### `registerChild`
|
||||
|
||||
Internal. Child nodes (measurements, rotatingMachines, machineGroupControls, nested pumpingStations) emit this on their Port 2 a few hundred ms after deploy. The station resolves the Node-RED node id back to the source object and registers it via `childRegistrationUtils`.
|
||||
|
||||
## Output ports
|
||||
|
||||
### Port 0 — process data
|
||||
|
||||
Delta-compressed payload (only changed fields per tick). Keys follow the standard 4-segment format `<type>.<variant>.<position>.<childId>` plus a handful of top-level state fields merged in by `getOutput()`:
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `volume.predicted.atequipment.default` | Running predicted volume from the flow integrator (m³). |
|
||||
| `volume.measured.atequipment.default` | Volume derived from a `measured` level sensor (m³). |
|
||||
| `level.predicted.atequipment.default` | Predicted level = `volume / area` (m). |
|
||||
| `level.measured.<position>.<childId>` | Raw level sensor reading (m). |
|
||||
| `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.out.<childId>` | Outflow contribution from a registered child. |
|
||||
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
|
||||
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow − outflow). |
|
||||
| `direction` | `filling` / `draining` / `steady` / `unknown`. |
|
||||
| `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). |
|
||||
| `volEmptyBasin`, `inflowLevel`, `overflowLevel`, `maxVol`, `maxVolAtOverflow`, `minVol`, `minVolAtInflow`, `minVolAtOutflow`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
|
||||
| `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.
|
||||
|
||||
### Port 1 — dbase (InfluxDB)
|
||||
|
||||
Line-protocol payload for the `telemetry` bucket. Tags stay low-cardinality (station name, asset type); fields carry the numeric state. See [EVOLV — InfluxDB Schema Design](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/concepts/influxdb-schema-design.md).
|
||||
|
||||
### Port 2 — parent
|
||||
|
||||
`{ topic: "registerChild", payload: <this-node-id>, positionVsParent, distance }` — fired once ~100 ms after deploy so an upstream cascade can discover this station. Nested stations use this to register with an outer `pumpingStation` parent.
|
||||
|
||||
## Basin model
|
||||
|
||||
The basin is modelled as a rectangular prism with constant cross-section. Everything derives from `volume = level × surfaceArea`, with every level measured upward from the basin floor.
|
||||
|
||||

|
||||
|
||||
*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`.
|
||||
|
||||
`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).
|
||||
|
||||
The pipe labels are intentional:
|
||||
|
||||
- `inflowLevel` is the bottom/invert of the incoming sewer pipe.
|
||||
- `outflowLevel` is the top of the pump-suction/outlet pipe.
|
||||
|
||||
This avoids hiding hydraulic consequences behind ambiguous pipe-centre elevations. Pipe diameters are part of the model contract so later versions can derive pipe centre/crown/invert where needed.
|
||||
|
||||
`dryRunLevel` and `highVolumeSafetyLevel` are derived safety points. They provide margin before the two hard physical conditions:
|
||||
|
||||
- Actual dry-run risk is at or below the pumpable lower hydraulic reference.
|
||||
- Actual overflowing is the boolean condition `level >= overflowLevel`.
|
||||
|
||||
The high-volume safety point exists so the station can still react before the basin is physically spilling. Once `overflowLevel` is reached, the model should report overflowing rather than treating that point as a controllable threshold.
|
||||
|
||||
**minHeightBasedOn** — which pipe defines `minVol`, the operational floor used for the initial seed, the dry-run trigger, and the 0 % point of the fill percentage:
|
||||
|
||||
```
|
||||
outlet (default): inlet:
|
||||
|
||||
● maxVolAtOverflow ● maxVolAtOverflow
|
||||
│ │
|
||||
● inflowLevel ● inflowLevel ─── minVol
|
||||
│ │
|
||||
● outflowLevel ──── minVol ● outflowLevel
|
||||
│ │
|
||||
● floor ● floor
|
||||
|
||||
Buffer counts as usable stock. Buffer reserved; 0% fill
|
||||
starts at the inlet.
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Net-flow selection
|
||||
|
||||
Every tick, `_selectBestNetFlow()` walks a priority ladder and returns the first net flow that clears the dead-band (`|flow| ≥ flowThreshold`):
|
||||
|
||||
```
|
||||
priority source note
|
||||
|
||||
1 ────● measured.flow real sensors on inflow/outflow
|
||||
│
|
||||
2 ────● predicted.flow manual q_in + pump-curve outputs
|
||||
│
|
||||
3 ────● level:measured dL/dt × surfaceArea
|
||||
│
|
||||
4 ────● level:predicted dL/dt of the integrator
|
||||
│
|
||||
5 ────● steady (fallback) warn, return { value: 0, source: null }
|
||||
```
|
||||
|
||||
Both **measured** and **predicted** variants are always computed and stored, regardless of which one drives control. The active source surfaces on Port 0 as `flowSource`, so operators can watch sensor drift (measured diverges from predicted), validate the volume integrator, and diagnose "which source was active when X happened?".
|
||||
|
||||
The inflow / outflow alias map is deliberately wide so measurements (`upstream`/`downstream`) and predicted-flow subscriptions (`in`/`out`) both feed the same aggregator:
|
||||
|
||||
```js
|
||||
flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }
|
||||
```
|
||||
|
||||
## Control logic
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
| Mode | Status | Page |
|
||||
|---|---|---|
|
||||
| `levelbased` | ✅ implemented | [modes/levelbased.md](modes/levelbased.md) |
|
||||
| `manual` | ✅ implemented (via `Qd` topic) | — |
|
||||
| `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid` | 🚧 placeholder in code | — |
|
||||
|
||||
See [`modes/README.md`](modes/README.md) for the index and page template.
|
||||
|
||||
## Safety controller
|
||||
|
||||
`_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`.
|
||||
|
||||
> ⚠️ **Known limitation — gravity-sewer context.** The "upstream STOP" action only makes sense in a **cascaded** station layout where the upstream equipment is an EVOLV-controllable pump or station. In a conventional wastewater wet-well the inflow is gravity-fed from the municipal sewer and **cannot be stopped** — attempting to would back up toilets. For that case the correct response at the high-volume safety point is to alarm early and keep downstream pumps at maximum demand. If `level >= overflowLevel`, the station should report actual overflowing as a boolean and, later, estimate/log spill over the weir for compliance reporting. The current code fires `execSequence: shutdown` on upstream children regardless of what they are; that should be gated on "is the upstream actually controllable?" and supplemented with overflow-rate tracking. Tracked as follow-up work.
|
||||
|
||||
A missing volume reading is treated as a hard fault: every direct machine is sent `execSequence: shutdown` and `safetyControllerActive` latches. Calibrate predicted volume (`calibratePredictedVolume`) or wire a level measurement to recover.
|
||||
|
||||
## Registration — which children count as flow?
|
||||
|
||||
`_registerPredictedFlowChild` subscribes only to the *highest-level aggregator* to prevent double-counting.
|
||||
|
||||
```
|
||||
Without MGC: With MGC:
|
||||
|
||||
[ PumpingStation ] [ PumpingStation ]
|
||||
│ │ │ │
|
||||
│ │ │ [ MGC ]
|
||||
│ │ │ │ │ │
|
||||
● ● ● ● ● ●
|
||||
(each pump subscribed (only MGC is subscribed;
|
||||
directly) MGC aggregates its pumps)
|
||||
|
||||
N flow subscriptions. 1 flow subscription.
|
||||
Risk: double-count if an Pumps' flow is already
|
||||
MGC is added later. inside the MGC total.
|
||||
```
|
||||
|
||||
Measurement children register separately via `_registerMeasurementChild` and feed the `measured` variant — they never collide with the predicted-flow subscription. Nested `pumpingStation` children are always subscribed and expose their net flow at the parent's position.
|
||||
|
||||
## Node status badge
|
||||
|
||||
Updated every second by `_updateNodeStatus` in `nodeClass.js`:
|
||||
|
||||
```
|
||||
⬆️ 42.3% | V=4.57 / 10.80 m³ | net: 180 m³/h | t≈12 min
|
||||
```
|
||||
|
||||
| Symbol | Direction | Badge colour |
|
||||
|---|---|---|
|
||||
| ⬆️ | `filling` | blue |
|
||||
| ⬇️ | `draining` | orange |
|
||||
| ⏸️ | `steady` | green |
|
||||
| ❔ | `unknown` / missing measurements | grey |
|
||||
|
||||
## Example flow
|
||||
|
||||
The canonical end-to-end demo lives in the EVOLV superproject at [`examples/pumpingstation-3pumps-dashboard/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/examples/pumpingstation-3pumps-dashboard). It wires three `rotatingMachine` pumps beneath an MGC beneath a `pumpingStation`, with the dashboard layout rule set (see the [EVOLV flow-layout rules](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md)) — a useful template for any new station.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `fill %` exceeds 100 % or is negative | Basin geometry inconsistent — e.g. `overflowLevel > basinHeight`, or `outflowLevel > inflowLevel`. | Cross-check `0 < outflowLevel < inflowLevel < overflowLevel <= basinHeight` in the editor. |
|
||||
| Pumps never start in `levelbased` | Level is stuck in the DEAD ZONE between `minLevel` and `startLevel`, or `startLevel == maxLevel` so the scaling range collapses. | Widen the mode control band. In sewer-gravity cases, `startLevel` is normally below `inflowLevel` so the station starts draining before the incoming sewer pipe is hydraulically affected. |
|
||||
| "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. |
|
||||
| `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. |
|
||||
| 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. |
|
||||
|
||||
## Running it locally
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://gitea.wbd-rd.nl/RnD/EVOLV.git
|
||||
cd EVOLV
|
||||
docker compose up -d
|
||||
# Node-RED: http://localhost:1880 InfluxDB: :8086 Grafana: :3000
|
||||
```
|
||||
|
||||
Then in Node-RED: **Import ▸ Examples ▸ EVOLV ▸ pumpingStation** (or open `examples/pumpingstation-3pumps-dashboard/flow.json`).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd nodes/pumpingStation
|
||||
npm test
|
||||
```
|
||||
|
||||
Unit tests live in `test/specificClass.test.js` — construction, basin derivation, measurement registration, net-flow selection, safety interlocks, and calibration.
|
||||
|
||||
## Related
|
||||
|
||||
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki) — atomic pump model beneath pumpingStation / MGC.
|
||||
- [measurement wiki](https://gitea.wbd-rd.nl/RnD/measurement/wiki) — sensor conditioning for inflow, outflow, level, and pressure inputs.
|
||||
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki) — how MGC coordinates multiple pumps.
|
||||
- [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md) — the entry → nodeClass → specificClass pattern.
|
||||
- [EVOLV — Group Optimization](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/group-optimization.md) — pump-group scheduling theory.
|
||||
- [EVOLV — flow-layout rules](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) — the lane / group / channel layout rules used by the demo flows.
|
||||
38
wiki/modes/README.md
Normal file
38
wiki/modes/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Control modes
|
||||
|
||||
Each page describes one `pumpingStation` control mode and how it uses the shared [basin model](../functional-description.md#basin-model) — specifically, how it uses the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and computes the demand it sends to the MGC.
|
||||
|
||||
The two **safety** thresholds (`dryRunLevel` and `overflowLevel`) are mode-independent and are enforced by the safety layer outside any mode. They never appear in a mode's policy.
|
||||
|
||||
## Template
|
||||
|
||||
Every mode page follows the same structure:
|
||||
|
||||
1. **At a glance** — one sentence + small fact table (inputs, output, status)
|
||||
2. **Diagram** — one or more, per tier (see below)
|
||||
3. **Inputs** — what signals the mode reads
|
||||
4. **Threshold policy** — how it uses / adjusts `minLevel`, `startLevel`, `maxLevel`
|
||||
5. **Demand formula** — pseudocode for Tier 1/2, objective function for Tier 3
|
||||
6. **Edge cases** — cold start, sensor dropout, interaction with safety layer
|
||||
7. **Related** — links to other modes + functional description
|
||||
|
||||
The three **tiers** classify modes by how dynamic the decision surface is:
|
||||
|
||||
| Tier | Curve | Example modes | Diagram type |
|
||||
|---|---|---|---|
|
||||
| **1** — static | Memoryless `demand = f(x)`; single curve | `levelbased`, `manual` | Single-curve transfer function |
|
||||
| **2** — parameterised | Shape fixed, curve moves with θ(t) | `flowbased`, `pressureBased`, `percentageBased`, `powerBased` | Transfer function + parameter overlay / family |
|
||||
| **3** — horizon-based | Optimisation, no fixed curve | `hybrid-optimal`, `mpc`, weather-aware | Block diagram of signal flow + scenario time-series |
|
||||
|
||||
## Implementation status
|
||||
|
||||
| Mode | Tier | Status | Page |
|
||||
|---|---|---|---|
|
||||
| `levelbased` | 1 | ✅ implemented | [levelbased.md](levelbased.md) |
|
||||
| `manual` | 1 | ✅ implemented (via `Qd` topic) | — |
|
||||
| `flowbased` | 2 | 🚧 code placeholder, template | [flowbased.md](flowbased.md) |
|
||||
| `pressureBased` | 2 | 🚧 code placeholder | — |
|
||||
| `percentageBased` | 2 | 🚧 code placeholder | — |
|
||||
| `powerBased` | 2 | 🚧 code placeholder, template | [powerbased.md](powerbased.md) |
|
||||
| `hybrid` | 3 | 🚧 code placeholder | — |
|
||||
| `mpc` | 3 | 🚧 not in code yet, template | [mpc.md](mpc.md) |
|
||||
83
wiki/modes/flowbased.md
Normal file
83
wiki/modes/flowbased.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Flow-based mode
|
||||
mode: flowbased
|
||||
tier: 2
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# Flow-based mode — *Tier 2 template*
|
||||
|
||||
> **Status — not yet implemented.** The `flowbased` entry is a placeholder in `_controlLogic`. This page reserves the shape and documents the intended design so all Tier-2 modes share the same layout.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 2 — parameterised transfer function |
|
||||
| Signal driving demand | measured outflow (actual pumps) |
|
||||
| Secondary inputs | integrator + derivative state (for PID) |
|
||||
| Output | demand 0–100 % via PID correction |
|
||||
| Thresholds adjusted at runtime? | No (but the demand can move independently of level) |
|
||||
| Use when | The station has a flow sensor on the outlet and you want to hold a target outflow rate regardless of basin level |
|
||||
|
||||
## Diagram
|
||||
|
||||
**Primary plot.** Demand vs *outflow-error* (not level!) is the meaningful transfer function for flow-based control. The curve is a classic PID surface — proportional slope times error, plus integral + derivative terms.
|
||||
|
||||
**Secondary plot.** Level still enters as gates (STOP below `minLevel`, don't overfill above `maxLevel`) — same thresholds as levelbased, but the mode doesn't *use* level to pick demand.
|
||||
|
||||
```
|
||||
Placeholder image — replace with:
|
||||
diagrams/modes/flowbased.drawio.svg (demand vs outflow-error, showing Kp slope)
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| measured outflow | sum of `flow.measured.*` at outflow positions | error = (flowSetpoint − measuredOutflow) |
|
||||
| `config.control.flowBased.flowSetpoint` | editor, static | target outflow in m³/h |
|
||||
| `config.control.flowBased.flowDeadband` | editor, static | zone around setpoint where PID output holds |
|
||||
| `config.control.flowBased.pid.{kp, ki, kd, ...}` | editor / schema | PID gains + rate limits |
|
||||
| current level | fallback → threshold gates | only used for `minLevel`/`maxLevel` bounds |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
The **control** thresholds (`minLevel`, `startLevel`, `maxLevel`) are still enforced but for different reasons than levelbased:
|
||||
|
||||
| Threshold | Role in flowbased |
|
||||
|---|---|
|
||||
| `minLevel` | If level drops below, force demand=0 regardless of PID output (prevents pump undercut) |
|
||||
| `startLevel` | unused — demand is driven by error, not level |
|
||||
| `maxLevel` | If level climbs above, force demand=100 regardless of PID output (prevents spill) |
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
error = flowSetpoint − measuredOutflow
|
||||
|
||||
if level < minLevel:
|
||||
demand = 0 # pump-undercut guard
|
||||
elif level > maxLevel:
|
||||
demand = 100 # anti-spill guard
|
||||
else:
|
||||
# normal PID branch
|
||||
P = Kp × error
|
||||
I += Ki × error × dt # with anti-windup clamp
|
||||
D = Kd × d(error)/dt # with low-pass filter
|
||||
demand = clamp(P + I + D, 0, 100) # with rate limits Δup/Δdown
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cold start, no prior outflow measurement.** PID state starts at 0; first error is `flowSetpoint`. Integral term will build up — rate-limit the demand ramp to avoid over-shoot.
|
||||
- **Sensor dropout on the outflow meter.** Fall back to predicted outflow (sum of pump curve predictions). Log a warning — PID on predicted-only is unreliable.
|
||||
- **Setpoint step change.** PID with derivative filter + rate limits handles this gracefully; without filter, the D-kick would saturate output.
|
||||
- **Safety layer interaction.** Same as levelbased — `dryRunLevel` and `overflowLevel` override the PID output. See [functional description § Safety](../functional-description.md#safety-controller).
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model + shared safety layer
|
||||
- [modes/README.md](README.md) — mode index + page template
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 reference implementation
|
||||
84
wiki/modes/levelbased.md
Normal file
84
wiki/modes/levelbased.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: Level-based mode
|
||||
mode: levelbased
|
||||
status: implemented
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Signal driving demand | basin level (measured, predicted fallback) |
|
||||
| Output | demand 0–100 % forwarded to every MGC child |
|
||||
| Thresholds adjusted at runtime? | No — static from editor config |
|
||||
| Use when | Inflow is sewer-gravity (no smart metering) and operator wants a predictable, inspectable response |
|
||||
|
||||
## 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).*
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| 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.startLevel` | editor, static | where demand-ramp starts |
|
||||
| `config.control.levelbased.maxLevel` | editor, static | where demand saturates at 100 % |
|
||||
|
||||
The three control thresholds are the **only** mode-specific configuration. Nothing here is recomputed at runtime.
|
||||
|
||||
## Threshold policy
|
||||
|
||||
| Threshold | Source | Adjustable at runtime? |
|
||||
|---|---|---|
|
||||
| `minLevel` | `config.control.levelbased.minLevel` | No |
|
||||
| `startLevel` | `config.control.levelbased.startLevel` | No |
|
||||
| `maxLevel` | `config.control.levelbased.maxLevel` | 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.
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
if level < minLevel:
|
||||
demand = 0
|
||||
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
|
||||
elif level < startLevel:
|
||||
demand = <previous demand> # dead zone — hold last command (hysteresis)
|
||||
elif level <= maxLevel:
|
||||
demand = lerp(level, [startLevel, maxLevel], [0 %, 100 %])
|
||||
else:
|
||||
demand = 100 % # saturated; MGC clamps internally if overshoot
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cold start with level in the dead zone.** `demand` has no prior value; it defaults to `0`. Pumps stay OFF until the level first crosses `startLevel` upward. Once it does, normal ramp-and-hold behaviour engages.
|
||||
- **Level sensor drops out mid-run.** `_selectBestNetFlow` falls back to predicted level (computed from the volume integrator) — the mode doesn't care which variant wins, it just reads the chosen level.
|
||||
- **Both sensor and predictor unavailable.** The mode's preconditions fail; `_controlLogic` logs a warning and exits without issuing a command. The last-known demand is held, which is safe.
|
||||
- **Level crosses `maxLevel` upward.** Demand saturates at 100 %. Level may still continue rising if inflow > station capacity — this is the scenario that trips the overflow-safety layer (see below).
|
||||
- **Level crosses `dryRunLevel` downward.** The **safety layer** (not this mode) force-shuts all downstream pumps regardless of what demand the mode is commanding. The mode's demand is effectively overridden until level climbs back above `dryRunLevel + hysteresis_margin`.
|
||||
- **Level crosses `overflowLevel` upward.** The safety layer logs the spill event and raises an alarm. The mode continues commanding at 100 % — which is what you want, because the pumps should keep draining as fast as physically possible. (See [functional description § Safety controller](../functional-description.md#safety-controller) for the gravity-sewer caveat.)
|
||||
|
||||
## Why this is worth migrating off of
|
||||
|
||||
Level-based is fine for steady-state sewer inflows. It has two known weaknesses:
|
||||
|
||||
1. **Predictable, not proactive.** It can't *pre-empty* the basin ahead of a forecasted storm or a power-price peak. Modes like `weather-aware` or `powerBased` can — by moving `startLevel` down or up at runtime.
|
||||
2. **Thresholds assume pump capacity is fixed.** If you add or remove pumps, the `startLevel ↔ maxLevel` band that gave smooth 0-100 % coverage no longer matches the new capacity. Flow-based and percentage-based modes are less brittle to capacity changes because they close the loop on *what you actually measure* (outflow or fill %) rather than *what you assume the level→capacity map is*.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model, net-flow selection, safety layer (shared across all modes)
|
||||
- [modes/README.md](README.md) — mode index + template
|
||||
- Other mode pages: *to be written* (`flowbased.md`, `pressurebased.md`, `percentagebased.md`, `powerbased.md`, `hybrid.md`, `manual.md`)
|
||||
149
wiki/modes/mpc.md
Normal file
149
wiki/modes/mpc.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: MPC (Model-Predictive Control)
|
||||
mode: mpc
|
||||
tier: 3
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# MPC mode — *Tier 3 template*
|
||||
|
||||
> **Status — not yet implemented.** Not even in the schema today. This page reserves the shape for when the time comes.
|
||||
|
||||
## Why this is Tier 3
|
||||
|
||||
The levelbased/flowbased/powerBased modes are all **memoryless or near-memoryless transfer functions**. You give them the current state; they give you a demand. You can draw them as 2D plots.
|
||||
|
||||
MPC is different. At each tick the controller solves an optimisation over a prediction horizon:
|
||||
|
||||
```
|
||||
minimise Σ cost(state(t+k), command(t+k)) for k = 0 .. N
|
||||
subject to forecast, physical limits, power budget, spill penalty, ...
|
||||
```
|
||||
|
||||
The *command* that's emitted at time `t` is merely the first step of that plan; next tick the forecast shifts and the optimiser re-runs. There's no fixed `demand = f(level)` curve — the curve is remade every tick.
|
||||
|
||||
That's why Tier-3 modes get **block diagrams + scenario time-series**, not transfer functions.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 3 — optimisation-based |
|
||||
| Signal driving demand | full state (level, flow, power) + **forecasts** (inflow, grid price, weather) |
|
||||
| Secondary inputs | cost weights, horizon length, solver config |
|
||||
| Output | demand + planned trajectory over horizon |
|
||||
| Thresholds adjusted at runtime? | Effectively yes — the optimiser treats them as soft constraints |
|
||||
| Use when | Available forecasts beat reactive control, or multi-objective optimisation is needed |
|
||||
|
||||
## Diagram 1 — signal flow (block diagram)
|
||||
|
||||
```
|
||||
Placeholder image — replace with:
|
||||
diagrams/modes/mpc-block.drawio.svg
|
||||
|
||||
Blocks:
|
||||
|
||||
[sensors] [inflow forecast] [grid price] [weather API]
|
||||
│ │ │ │
|
||||
└─────────────┴──────────────────┴──────────────┘
|
||||
│
|
||||
┌─────▼──────┐
|
||||
│ state + │
|
||||
│ forecast │
|
||||
│ bundle │
|
||||
└─────┬──────┘
|
||||
│
|
||||
┌─────▼───────────────────┐
|
||||
│ MPC solver │
|
||||
│ • horizon N │
|
||||
│ • cost weights w │
|
||||
│ • constraints C │
|
||||
│ • linearised model │
|
||||
└─────┬───────────────────┘
|
||||
│
|
||||
┌─────▼───────┐
|
||||
│ command[0] │ ── the step we act on now
|
||||
│ command[1] │
|
||||
│ ... │
|
||||
│ command[N] │ ── re-planned next tick
|
||||
└─────┬───────┘
|
||||
│
|
||||
┌─────────▼─────────┐
|
||||
│ safety layer clip │ ← dryRun / overflow always apply
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
demand → MGC
|
||||
```
|
||||
|
||||
## Diagram 2 — scenario time-series
|
||||
|
||||
A much more useful way to evaluate MPC is to plot *what it did* over a simulated scenario: level, planned vs actual demand, the cost function breakdown, the active constraints. The [simulations harness](../../simulations/README.md) is built for exactly this — MPC will need a dedicated scenario like `mpc-storm-with-forecast.js`.
|
||||
|
||||
```
|
||||
Placeholder — replace with:
|
||||
diagrams/modes/mpc-scenario.drawio.svg
|
||||
|
||||
Stacked time-series showing:
|
||||
1. basin level over time (with forecast shadow and horizon)
|
||||
2. demand over time (with the re-planning edges visible)
|
||||
3. cost breakdown: energy vs spill-penalty vs ramp-penalty
|
||||
4. active constraints over time (colored bands)
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| current state | `measurements` container | initial condition for optimiser |
|
||||
| inflow forecast | external — sewer model / weather API | drives the cost integral |
|
||||
| grid-price forecast | external — market feed / schedule | weights energy cost |
|
||||
| cost weights `w` | config | trades off spill vs energy vs ramp |
|
||||
| horizon `N` | config | 15–60 minutes typical |
|
||||
| model parameters | config / learned | basin dynamics, pump curves |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
Levels appear in the optimiser as **soft constraints** (penalties in the cost function):
|
||||
|
||||
| Threshold | Role in MPC |
|
||||
|---|---|
|
||||
| `dryRunLevel`, `overflowLevel` | hard constraints — if the optimiser's plan crosses them, safety layer clips |
|
||||
| `minLevel`, `maxLevel` | soft constraints — penalty weight `w_level` applied to excursions |
|
||||
| `startLevel` | advisory only — optimiser doesn't inherently care, but may be used in cost weights for rule-of-thumb alignment with human expectations |
|
||||
|
||||
So unlike Tier-1/2 where thresholds directly gate the action, here they shape the objective.
|
||||
|
||||
## Demand formula
|
||||
|
||||
Not a formula — an optimisation problem:
|
||||
|
||||
```text
|
||||
state, forecast, constraints = gather_inputs()
|
||||
plan = mpc_solver.solve(
|
||||
state0 = state,
|
||||
forecast = forecast,
|
||||
horizon = N,
|
||||
model = basin_dynamics + pump_curves,
|
||||
cost = w_energy × Σ power(k)
|
||||
+ w_spill × Σ max(0, level(k) − overflowLevel)²
|
||||
+ w_undercut × Σ max(0, minLevel − level(k))²
|
||||
+ w_ramp × Σ (command(k) − command(k-1))²,
|
||||
constraints = pump_limits + power_budget + rate_limits,
|
||||
)
|
||||
demand = plan.command[0]
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Solver timeout.** Fall back to the previous plan's step, or to a levelbased curve as a safe default. Log.
|
||||
- **Bad forecast (persistent bias).** Optimiser can chase a wrong prediction for many ticks. Adaptive forecast bias correction, or a watchdog comparing forecast-vs-realised, is essential.
|
||||
- **Infeasibility.** If constraints can't be satisfied (e.g. power budget and maxLevel simultaneously during a severe storm), relax soft constraints in priority order (ramp first, then maxLevel, then energy) — never relax dryRun/overflow.
|
||||
- **Safety takeover.** The safety layer still overrides. MPC should *anticipate* safety trips in its cost function (big penalty for trajectories that invoke them), not hit them.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model + safety layer
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 — the "default" MPC falls back to
|
||||
- [modes/powerbased.md](powerbased.md) — Tier 2 — MPC generalises the clip idea into full optimisation
|
||||
- [simulations/README.md](../../simulations/README.md) — where MPC simulation scenarios will live
|
||||
83
wiki/modes/powerbased.md
Normal file
83
wiki/modes/powerbased.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Power-based mode
|
||||
mode: powerBased
|
||||
tier: 2
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# Power-based mode — *Tier 2 template*
|
||||
|
||||
> **Status — not yet implemented.** Placeholder. This page documents the intended shape of a grid-aware / netcongestion-aware station.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 2 — parameterised transfer function |
|
||||
| Signal driving demand | basin level (primary), **max-power budget** (clip) |
|
||||
| Secondary inputs | measured pump power, live grid-price / peak-hours signal |
|
||||
| Output | demand 0–100 % clipped so `Σ pump power ≤ maxPowerKW(t)` |
|
||||
| Thresholds adjusted at runtime? | `maxPowerKW(t)` yes — level thresholds no |
|
||||
| Use when | Grid has peak-hour tariffs or net-congestion caps |
|
||||
|
||||
## Diagram — the levelbased curve with a moving clip ceiling
|
||||
|
||||
```
|
||||
demand % ← dashed line: levelbased curve
|
||||
100 ┤ ╱ ─────── ← solid: clip at powerBudget(t)
|
||||
│ ╱ clip lowers
|
||||
│ ╱ during grid peak
|
||||
│ ╱ ─────────
|
||||
│ ╱ ╱
|
||||
│ ╱ ╱
|
||||
│ ╱ ╱
|
||||
0 ┼────────●───────●─────────────────────► level
|
||||
startLevel maxLevel
|
||||
|
||||
↑ the family of curves:
|
||||
clip=100% (grid idle),
|
||||
clip=70% (shoulder),
|
||||
clip=40% (peak).
|
||||
```
|
||||
|
||||
The *shape* stays levelbased; the *ceiling* drops when the grid is strained. That's the Tier-2 signature: same input axis, parameter shifts the curve.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| current level | as in levelbased | primary input |
|
||||
| `config.control.powerBased.maxPowerKW` | editor, static | hard cap on station power |
|
||||
| `config.control.powerBased.powerControlMode` | `limit` / `optimize` | whether to just clip or to schedule |
|
||||
| live grid signal (future) | external topic or forecast | modulates the cap over time |
|
||||
| measured pump power | `power.measured.*` from children | real-time feedback against the cap |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
Level thresholds (`minLevel`, `startLevel`, `maxLevel`) are **identical to levelbased** — they define the shape of the underlying curve. What's new is a runtime-varying ceiling `demandCap(t)` derived from the power budget.
|
||||
|
||||
`demandCap(t) = 100 × (maxPowerKW(t) / nominalStationPowerAtFull)` — where `maxPowerKW(t)` may come from config (static `limit` mode) or an external grid-price feed (dynamic).
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
rawDemand = levelbasedDemand(level) # the underlying Tier-1 curve
|
||||
demandCap = min(100, 100 × maxPowerKW(t) / nominalStationPower)
|
||||
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.
|
||||
|
||||
## 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).
|
||||
- **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.
|
||||
- **Measured vs predicted pump power.** Cap is enforced on predicted (decisions are made before the pump responds). Reconcile against measured for logging/diagnostics.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md)
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 reference (the curve that powerBased clips)
|
||||
- [modes/flowbased.md](flowbased.md) — other Tier-2 example with different control variable
|
||||
Reference in New Issue
Block a user