Compare commits
20 Commits
530f84ae5b
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a83a85e958 | ||
|
|
e041877ae4 | ||
|
|
8216480950 | ||
|
|
dfaa0c3ae8 | ||
|
|
6e727d929b | ||
| ef07f2a5b2 | |||
|
|
2d68a4f504 | ||
|
|
a3536b7b7f | ||
|
|
f5c6282478 | ||
|
|
df18e97b8b | ||
|
|
2e4ad8d3f1 | ||
|
|
d4de3cf5c5 | ||
|
|
304df7f135 | ||
|
|
03440e1e6c | ||
|
|
2c7fe1792f | ||
|
|
6e89e4916f | ||
|
|
285fd01a5d | ||
|
|
fe5fa3577b | ||
|
|
8507ee4e02 | ||
|
|
b825ac1d6d |
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay
|
||||||
|
# in sync — anything that shouldn't be committed AND shouldn't ship in the
|
||||||
|
# npm tarball goes in both files.
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
31
.npmignore
Normal file
31
.npmignore
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# === Mirrors .gitignore — items below this block are also excluded from
|
||||||
|
# the npm tarball. Kept here verbatim so npm pack doesn't fall back to
|
||||||
|
# the .gitignore inheritance (silent + surprising). ===
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# === Dev-only content the npm tarball doesn't need ===
|
||||||
|
# Tests + their harness — Node-RED loads the entry .js, not the test tree.
|
||||||
|
test/
|
||||||
|
*.test.js
|
||||||
|
|
||||||
|
# Wiki, screenshots, drawio diagrams — useful in the repo, big in the pack.
|
||||||
|
wiki/
|
||||||
|
|
||||||
|
# Local simulation harness + scenario data (dev-only). 870+ KB on disk.
|
||||||
|
simulations/
|
||||||
|
|
||||||
|
# Build/maintenance tooling not used at runtime.
|
||||||
|
tools/
|
||||||
|
|
||||||
|
# Project memory + IDE configs.
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
.repo-mem/
|
||||||
|
CLAUDE.md
|
||||||
|
CLAUDE.local.md
|
||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -21,3 +21,20 @@ Key points for this node:
|
|||||||
- Stack same-level siblings vertically.
|
- Stack same-level siblings vertically.
|
||||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
- 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).
|
- Wrap in a Node-RED group box coloured `#0c99d9` (Process Cell).
|
||||||
|
|
||||||
|
## Folder & File Layout
|
||||||
|
|
||||||
|
Every per-node file MUST use the folder name (`pumpingStation`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
|
||||||
|
|
||||||
|
| Path | Required name |
|
||||||
|
|---|---|
|
||||||
|
| Entry file | `pumpingStation.js` |
|
||||||
|
| Editor HTML | `pumpingStation.html` |
|
||||||
|
| Node adapter | `src/nodeClass.js` |
|
||||||
|
| Domain logic | `src/specificClass.js` |
|
||||||
|
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
|
||||||
|
| Tests | `test/{basic,integration,edge}/*.test.js` |
|
||||||
|
| Example flows | `examples/*.flow.json` |
|
||||||
|
|
||||||
|
|
||||||
|
When adding new files, read the rule above first to avoid drift.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Hand-maintained for Phase 2; the `## Inputs` table is generated from
|
|||||||
| `cmd.calibrate.volume` | `calibratePredictedVolume` | numeric (number or numeric string) — m³ | Resets the predicted-volume series and seeds it with the supplied value; recomputes level. |
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` | numeric (number or numeric string) — m³ | Resets the predicted-volume series and seeds it with the supplied value; recomputes level. |
|
||||||
| `cmd.calibrate.level` | `calibratePredictedLevel` | numeric — metres | Resets the predicted-level series and seeds it with the supplied value; recomputes volume. |
|
| `cmd.calibrate.level` | `calibratePredictedLevel` | numeric — metres | Resets the predicted-level series and seeds it with the supplied value; recomputes volume. |
|
||||||
| `set.inflow` | `q_in` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a manual inflow measurement onto the predicted-flow series. `unit` may be on the message (`msg.unit`) or inside the object payload. |
|
| `set.inflow` | `q_in` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a manual inflow measurement onto the predicted-flow series. `unit` may be on the message (`msg.unit`) or inside the object payload. |
|
||||||
|
| `set.outflow` | `q_out` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a measured outflow value into the basin balance. Same payload conventions as `set.inflow`. |
|
||||||
| `set.demand` | `Qd` | numeric — child setpoint demand | Forwards the demand to direct children (machineGroups / machines / stations). Only honoured in `manual` mode; in other modes the call is logged at `debug` and discarded. |
|
| `set.demand` | `Qd` | numeric — child setpoint demand | Forwards the demand to direct children (machineGroups / machines / stations). Only honoured in `manual` mode; in other modes the call is logged at `debug` and discarded. |
|
||||||
|
|
||||||
Aliases log a one-time deprecation warning the first time they fire.
|
Aliases log a one-time deprecation warning the first time they fire.
|
||||||
|
|||||||
@@ -1,340 +1,479 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "ps_basic_tab",
|
"id": "77f00aef1c966167",
|
||||||
"type": "tab",
|
"type": "tab",
|
||||||
"label": "PumpingStation - Basic",
|
"label": "PumpingStation - Basic",
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
|
"info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_title",
|
|
||||||
"type": "comment",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "PumpingStation - Basic\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nA 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\noverflow at 3.2 m). controlMode = levelbased, manual demand allowed\nonly when set.mode = manual.\n\nHOW TO USE:\n 1. Deploy the flow.\n 2. Click \"set.mode = manual\" so set.demand is honoured.\n 3. Click \"set.inflow = 60 m3/h\" to push wastewater into the basin.\n 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n 5. Click \"calibrate volume 25 m3\" to jump straight to half-full.\n\nAliases (changemode, q_in, Qd, …) still work but log a deprecation\nwarning - fresh flows use the canonical names.",
|
|
||||||
"info": "",
|
|
||||||
"x": 600,
|
|
||||||
"y": 40,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_inj_mode",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "set.mode = manual",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "manual",
|
|
||||||
"vt": "str"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "set.mode",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": false,
|
|
||||||
"onceDelay": "",
|
|
||||||
"x": 200,
|
|
||||||
"y": 160,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_basic_node"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_inj_mode_lvl",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "set.mode = levelbased",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "levelbased",
|
|
||||||
"vt": "str"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "set.mode",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": false,
|
|
||||||
"onceDelay": "",
|
|
||||||
"x": 220,
|
|
||||||
"y": 200,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_basic_node"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_inj_inflow",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "set.inflow = 60 m3/h",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "60",
|
|
||||||
"vt": "num"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "set.inflow",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": false,
|
|
||||||
"onceDelay": "",
|
|
||||||
"x": 200,
|
|
||||||
"y": 260,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_basic_node"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_inj_demand",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "set.demand = 40 %",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "40",
|
|
||||||
"vt": "num"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "set.demand",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": false,
|
|
||||||
"onceDelay": "",
|
|
||||||
"x": 200,
|
|
||||||
"y": 300,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_basic_node"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_inj_calvol",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "calibrate volume 25 m3",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "25",
|
|
||||||
"vt": "num"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "cmd.calibrate.volume",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": false,
|
|
||||||
"onceDelay": "",
|
|
||||||
"x": 220,
|
|
||||||
"y": 360,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_basic_node"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_inj_callvl",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "calibrate level 1.5 m",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "1.5",
|
|
||||||
"vt": "num"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "cmd.calibrate.level",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": false,
|
|
||||||
"onceDelay": "",
|
|
||||||
"x": 220,
|
|
||||||
"y": 400,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_basic_node"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_node",
|
|
||||||
"type": "pumpingStation",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "Pumping Station",
|
|
||||||
"simulator": false,
|
|
||||||
"basinVolume": 50,
|
|
||||||
"basinHeight": 3.5,
|
|
||||||
"inflowLevel": 3,
|
|
||||||
"outflowLevel": 0.2,
|
|
||||||
"overflowLevel": 3.2,
|
|
||||||
"defaultFluid": "wastewater",
|
|
||||||
"inletPipeDiameter": 0.3,
|
|
||||||
"outletPipeDiameter": 0.3,
|
|
||||||
"pipelineLength": 80,
|
|
||||||
"maxDischargeHead": 24,
|
|
||||||
"staticHead": 12,
|
|
||||||
"maxInflowRate": 200,
|
|
||||||
"temperatureReferenceDegC": 15,
|
|
||||||
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
|
||||||
"enableDryRunProtection": true,
|
|
||||||
"enableOverfillProtection": true,
|
|
||||||
"dryRunThresholdPercent": 2,
|
|
||||||
"overfillThresholdPercent": 98,
|
|
||||||
"minHeightBasedOn": "outlet",
|
|
||||||
"processOutputFormat": "process",
|
|
||||||
"dbaseOutputFormat": "influxdb",
|
|
||||||
"refHeight": "NAP",
|
|
||||||
"basinBottomRef": 1,
|
|
||||||
"uuid": "example-ps-001",
|
|
||||||
"supplier": "WBD-RD",
|
|
||||||
"category": "station",
|
|
||||||
"assetType": "pumpingstation",
|
|
||||||
"model": "demo-50m3",
|
|
||||||
"unit": "m3/h",
|
|
||||||
"enableLog": true,
|
|
||||||
"logLevel": "info",
|
|
||||||
"positionVsParent": "atEquipment",
|
|
||||||
"positionIcon": "",
|
|
||||||
"hasDistance": false,
|
|
||||||
"distance": "",
|
|
||||||
"distanceUnit": "m",
|
|
||||||
"distanceDescription": "",
|
|
||||||
"controlMode": "levelbased",
|
|
||||||
"startLevel": 1.2,
|
|
||||||
"minLevel": 0.4,
|
|
||||||
"maxLevel": 2.8,
|
|
||||||
"flowSetpoint": null,
|
|
||||||
"flowDeadband": null,
|
|
||||||
"x": 1320,
|
|
||||||
"y": 300,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_basic_format"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_basic_dbg_influx"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_basic_dbg_parent"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_format",
|
|
||||||
"type": "function",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "Merge deltas + format",
|
|
||||||
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction pick(prefix) {\n for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n } return null;\n}\nconst vol = pick('volume.predicted.atequipment');\nconst lvl = pick('level.predicted.atequipment');\nconst flIn = pick('flow.predicted.in');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n};\nreturn msg;",
|
|
||||||
"outputs": 1,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 1560,
|
|
||||||
"y": 280,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_basic_dbg_process"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_dbg_process",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "Port 0: Process",
|
|
||||||
"active": true,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "payload",
|
|
||||||
"targetType": "msg",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 240,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_dbg_influx",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "Port 1: InfluxDB",
|
|
||||||
"active": false,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "true",
|
|
||||||
"targetType": "full",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 320,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_basic_dbg_parent",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "Port 2: Parent reg",
|
|
||||||
"active": true,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "true",
|
|
||||||
"targetType": "full",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 380,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grp_ps_basic",
|
|
||||||
"type": "group",
|
|
||||||
"z": "ps_basic_tab",
|
|
||||||
"name": "Pumping Station (PC)",
|
|
||||||
"style": {
|
|
||||||
"label": true,
|
|
||||||
"stroke": "#000000",
|
|
||||||
"fill": "#0c99d9",
|
|
||||||
"fill-opacity": "0.10"
|
|
||||||
},
|
},
|
||||||
"nodes": [
|
{
|
||||||
"ps_basic_node",
|
"id": "aa3381b896eb2cfb",
|
||||||
"ps_basic_format"
|
"type": "group",
|
||||||
],
|
"z": "77f00aef1c966167",
|
||||||
"x": 1290,
|
"name": "Pumping Station (Process Cell)",
|
||||||
"y": 230,
|
"style": {
|
||||||
"w": 500,
|
"label": true,
|
||||||
"h": 140
|
"stroke": "#000000",
|
||||||
}
|
"fill": "#0c99d9",
|
||||||
]
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"8e78b6607deb33a7"
|
||||||
|
],
|
||||||
|
"x": 534,
|
||||||
|
"y": 351.5,
|
||||||
|
"w": 232,
|
||||||
|
"h": 97
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4996420d47442fad",
|
||||||
|
"type": "group",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"name": "1. Control mode",
|
||||||
|
"style": {
|
||||||
|
"stroke": "#666666",
|
||||||
|
"fill": "#ffdf7f",
|
||||||
|
"fill-opacity": "0.15",
|
||||||
|
"label": true,
|
||||||
|
"color": "#333333"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"1155bbbde7c65363",
|
||||||
|
"e9bea0f95b557f5d"
|
||||||
|
],
|
||||||
|
"x": 94,
|
||||||
|
"y": 119,
|
||||||
|
"w": 272,
|
||||||
|
"h": 122
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a9f9b38b0e00c1d7",
|
||||||
|
"type": "group",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"name": "2. Flow signals (inflow / outflow)",
|
||||||
|
"style": {
|
||||||
|
"stroke": "#666666",
|
||||||
|
"fill": "#ffdf7f",
|
||||||
|
"fill-opacity": "0.15",
|
||||||
|
"label": true,
|
||||||
|
"color": "#333333"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"7b2b5eb919b1ab15",
|
||||||
|
"3350187815774b95"
|
||||||
|
],
|
||||||
|
"x": 94,
|
||||||
|
"y": 279,
|
||||||
|
"w": 262,
|
||||||
|
"h": 122
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "42bf82c87d05f498",
|
||||||
|
"type": "group",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"name": "3. Operator demand (manual mode only)",
|
||||||
|
"style": {
|
||||||
|
"stroke": "#666666",
|
||||||
|
"fill": "#ffdf7f",
|
||||||
|
"fill-opacity": "0.15",
|
||||||
|
"label": true,
|
||||||
|
"color": "#333333"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"48c2262c345c46b9"
|
||||||
|
],
|
||||||
|
"x": 94,
|
||||||
|
"y": 479,
|
||||||
|
"w": 261,
|
||||||
|
"h": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "234bdce20170061a",
|
||||||
|
"type": "group",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"name": "4. Calibration",
|
||||||
|
"style": {
|
||||||
|
"stroke": "#666666",
|
||||||
|
"fill": "#ffdf7f",
|
||||||
|
"fill-opacity": "0.15",
|
||||||
|
"label": true,
|
||||||
|
"color": "#333333"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"463eefdd54df89a5",
|
||||||
|
"2e0642275899fc79"
|
||||||
|
],
|
||||||
|
"x": 94,
|
||||||
|
"y": 599,
|
||||||
|
"w": 272,
|
||||||
|
"h": 122
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f4ba4542514ed853",
|
||||||
|
"type": "group",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"name": "Expected outputs",
|
||||||
|
"style": {
|
||||||
|
"stroke": "#666666",
|
||||||
|
"fill": "#d1d1d1",
|
||||||
|
"fill-opacity": "0.2",
|
||||||
|
"label": true,
|
||||||
|
"color": "#333333"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"b2450e5ee2eebfaa",
|
||||||
|
"386af1ad8aa8ed12",
|
||||||
|
"c27c2655f199b530"
|
||||||
|
],
|
||||||
|
"x": 874,
|
||||||
|
"y": 299,
|
||||||
|
"w": 252,
|
||||||
|
"h": 202
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b30af582f935bcb7",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"name": "PumpingStation — Basic (Tier 1)",
|
||||||
|
"info": "Single pumpingStation node driven by inject buttons. Shows the canonical msg.topic command surface.\n\nDefault controlMode = levelbased. Switch to manual to honour set.demand.\n\nHOW TO USE\n1. Deploy the flow.\n2. (optional) Click \"set.mode = manual\" if you want set.demand to forward; otherwise leave it on levelbased and the ramp drives demand from level.\n3. Click \"set.inflow = 60 m³/h\" to push wastewater into the basin.\n4. Watch the basin fill on Port 0 (level, volume rise) and Port 1 (InfluxDB-shaped payload).\n5. In manual mode: click \"set.demand = 40\" — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.\n6. Click \"calibrate volume 25 m³\" or \"calibrate level 1.5 m\" to snap the predicted-volume integrator.\n\nPORTS\n- Port 0: process output (changed fields only)\n- Port 1: InfluxDB-shaped {measurement, fields, tags, timestamp}\n- Port 2: parent registration (child handshake)",
|
||||||
|
"x": 650,
|
||||||
|
"y": 300,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1155bbbde7c65363",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "4996420d47442fad",
|
||||||
|
"name": "set.mode = manual",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "manual",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"topic": "set.mode",
|
||||||
|
"x": 230,
|
||||||
|
"y": 160,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"8e78b6607deb33a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e9bea0f95b557f5d",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "4996420d47442fad",
|
||||||
|
"name": "set.mode = levelbased",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "levelbased",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"topic": "set.mode",
|
||||||
|
"x": 240,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"8e78b6607deb33a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7b2b5eb919b1ab15",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "a9f9b38b0e00c1d7",
|
||||||
|
"name": "set.inflow = 60 m3/h",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "60",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"topic": "set.inflow",
|
||||||
|
"x": 240,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"8e78b6607deb33a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "48c2262c345c46b9",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "42bf82c87d05f498",
|
||||||
|
"name": "set.demand = 40 %",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "40",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"topic": "set.demand",
|
||||||
|
"x": 230,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"8e78b6607deb33a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "463eefdd54df89a5",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "234bdce20170061a",
|
||||||
|
"name": "calibrate volume 25 m3",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "25",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"topic": "cmd.calibrate.volume",
|
||||||
|
"x": 240,
|
||||||
|
"y": 640,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"8e78b6607deb33a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2e0642275899fc79",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "234bdce20170061a",
|
||||||
|
"name": "calibrate level 1.5 m",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "1.5",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"topic": "cmd.calibrate.level",
|
||||||
|
"x": 240,
|
||||||
|
"y": 680,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"8e78b6607deb33a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b2450e5ee2eebfaa",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "f4ba4542514ed853",
|
||||||
|
"name": "Port 0: Process",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 980,
|
||||||
|
"y": 340,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "386af1ad8aa8ed12",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "f4ba4542514ed853",
|
||||||
|
"name": "Port 1: InfluxDB",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 980,
|
||||||
|
"y": 400,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c27c2655f199b530",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "f4ba4542514ed853",
|
||||||
|
"name": "Port 2: Parent reg",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 990,
|
||||||
|
"y": 460,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8e78b6607deb33a7",
|
||||||
|
"type": "pumpingStation",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "aa3381b896eb2cfb",
|
||||||
|
"name": "",
|
||||||
|
"simulator": false,
|
||||||
|
"basinVolume": 50,
|
||||||
|
"basinHeight": 4,
|
||||||
|
"inflowLevel": 1.5,
|
||||||
|
"outflowLevel": 0.2,
|
||||||
|
"overflowLevel": 3.8,
|
||||||
|
"defaultFluid": "wastewater",
|
||||||
|
"inletPipeDiameter": 0.3,
|
||||||
|
"outletPipeDiameter": 0.3,
|
||||||
|
"pipelineLength": 80,
|
||||||
|
"maxDischargeHead": 24,
|
||||||
|
"staticHead": 12,
|
||||||
|
"maxInflowRate": 200,
|
||||||
|
"temperatureReferenceDegC": 15,
|
||||||
|
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
||||||
|
"enableDryRunProtection": true,
|
||||||
|
"enableHighVolumeSafety": true,
|
||||||
|
"enableOverfillProtection": true,
|
||||||
|
"dryRunThresholdPercent": 2,
|
||||||
|
"highVolumeSafetyThresholdPercent": 98,
|
||||||
|
"overfillThresholdPercent": 98,
|
||||||
|
"minHeightBasedOn": "outlet",
|
||||||
|
"processOutputFormat": "process",
|
||||||
|
"dbaseOutputFormat": "influxdb",
|
||||||
|
"refHeight": "NAP",
|
||||||
|
"basinBottomRef": 1,
|
||||||
|
"uuid": "",
|
||||||
|
"supplier": "",
|
||||||
|
"category": "",
|
||||||
|
"assetType": "",
|
||||||
|
"model": "",
|
||||||
|
"unit": "",
|
||||||
|
"enableLog": false,
|
||||||
|
"logLevel": "error",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "⊥",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"controlMode": "levelbased",
|
||||||
|
"levelCurveType": "linear",
|
||||||
|
"logCurveFactor": 9,
|
||||||
|
"enableShiftedRamp": false,
|
||||||
|
"shiftLevel": 0,
|
||||||
|
"shiftArmPercent": 95,
|
||||||
|
"startLevel": 1,
|
||||||
|
"stopLevel": 0.5,
|
||||||
|
"minLevel": 0.20400000000000001,
|
||||||
|
"maxLevel": 3.8,
|
||||||
|
"flowSetpoint": null,
|
||||||
|
"flowDeadband": null,
|
||||||
|
"x": 650,
|
||||||
|
"y": 400,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"b2450e5ee2eebfaa"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"386af1ad8aa8ed12"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"c27c2655f199b530"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3350187815774b95",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "77f00aef1c966167",
|
||||||
|
"g": "a9f9b38b0e00c1d7",
|
||||||
|
"name": "set.outflow= 80 m3/h",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"topic": "set.outflow",
|
||||||
|
"payload": "80",
|
||||||
|
"payloadType": "num",
|
||||||
|
"x": 230,
|
||||||
|
"y": 320,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"8e78b6607deb33a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ef77c1819422a098",
|
||||||
|
"type": "global-config",
|
||||||
|
"env": [],
|
||||||
|
"modules": {
|
||||||
|
"EVOLV": "1.0.29"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
1136
examples/02-Dashboard.json
Normal file
1136
examples/02-Dashboard.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,686 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "ps_int_proc",
|
|
||||||
"type": "tab",
|
|
||||||
"label": "Process Plant",
|
|
||||||
"disabled": false,
|
|
||||||
"info": "Tier 2: pumpingStation + measurement child + machineGroupControl parent with two rotatingMachine pumps. Demonstrates Phase-2 parent/child handshakes and the canonical set.mode/set.inflow/set.demand topics."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_setup",
|
|
||||||
"type": "tab",
|
|
||||||
"label": "Setup",
|
|
||||||
"disabled": false,
|
|
||||||
"info": "Deploy-time once-true injects that initialise control modes on the EVOLV nodes."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_title",
|
|
||||||
"type": "comment",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "PumpingStation - Integration\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nL0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\nPumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\nCross-tab channels: setup:* drive once-true initialisation from the Setup tab.",
|
|
||||||
"info": "",
|
|
||||||
"x": 600,
|
|
||||||
"y": 40,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lin_setup_mode",
|
|
||||||
"type": "link in",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "setup:to-ps-mode",
|
|
||||||
"links": [],
|
|
||||||
"x": 120,
|
|
||||||
"y": 500,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_station"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lin_setup_inflow",
|
|
||||||
"type": "link in",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "setup:to-ps-inflow",
|
|
||||||
"links": [],
|
|
||||||
"x": 120,
|
|
||||||
"y": 560,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_station"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lin_setup_mgcmode",
|
|
||||||
"type": "link in",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "setup:to-mgc-mode",
|
|
||||||
"links": [],
|
|
||||||
"x": 120,
|
|
||||||
"y": 360,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_mgc"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "meas_level",
|
|
||||||
"type": "measurement",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Basin level sensor",
|
|
||||||
"mode": "analog",
|
|
||||||
"channels": "[]",
|
|
||||||
"scaling": false,
|
|
||||||
"i_min": 0,
|
|
||||||
"i_max": 0,
|
|
||||||
"i_offset": 0,
|
|
||||||
"o_min": 0,
|
|
||||||
"o_max": 1,
|
|
||||||
"simulator": true,
|
|
||||||
"smooth_method": "mean",
|
|
||||||
"count": 5,
|
|
||||||
"processOutputFormat": "process",
|
|
||||||
"dbaseOutputFormat": "influxdb",
|
|
||||||
"uuid": "example-level-001",
|
|
||||||
"supplier": "vega",
|
|
||||||
"category": "sensor",
|
|
||||||
"assetType": "level",
|
|
||||||
"model": "VEGAPULS-31",
|
|
||||||
"unit": "m",
|
|
||||||
"assetTagNumber": "LT-001",
|
|
||||||
"enableLog": false,
|
|
||||||
"logLevel": "error",
|
|
||||||
"positionVsParent": "atEquipment",
|
|
||||||
"positionIcon": "",
|
|
||||||
"hasDistance": false,
|
|
||||||
"distance": 0,
|
|
||||||
"distanceUnit": "m",
|
|
||||||
"distanceDescription": "",
|
|
||||||
"x": 600,
|
|
||||||
"y": 700,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_dbg_level"
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
"ps_int_station"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_inj_level",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "sim level 1.6 m",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "1.6",
|
|
||||||
"vt": "num"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "measurement",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": false,
|
|
||||||
"onceDelay": "",
|
|
||||||
"x": 120,
|
|
||||||
"y": 700,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"meas_level"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pump_a",
|
|
||||||
"type": "rotatingMachine",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pump A",
|
|
||||||
"speed": "1",
|
|
||||||
"startup": "2",
|
|
||||||
"warmup": "1",
|
|
||||||
"shutdown": "2",
|
|
||||||
"cooldown": "1",
|
|
||||||
"movementMode": "staticspeed",
|
|
||||||
"machineCurve": "",
|
|
||||||
"uuid": "example-pump-a",
|
|
||||||
"supplier": "hidrostal",
|
|
||||||
"category": "pump",
|
|
||||||
"assetType": "pump-centrifugal",
|
|
||||||
"model": "hidrostal-H05K-S03R",
|
|
||||||
"unit": "m3/h",
|
|
||||||
"curvePressureUnit": "mbar",
|
|
||||||
"curveFlowUnit": "m3/h",
|
|
||||||
"curvePowerUnit": "kW",
|
|
||||||
"curveControlUnit": "%",
|
|
||||||
"enableLog": false,
|
|
||||||
"logLevel": "error",
|
|
||||||
"positionVsParent": "atEquipment",
|
|
||||||
"positionIcon": "",
|
|
||||||
"hasDistance": false,
|
|
||||||
"distance": "",
|
|
||||||
"distanceUnit": "m",
|
|
||||||
"distanceDescription": "",
|
|
||||||
"x": 840,
|
|
||||||
"y": 320,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_dbg_pa"
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
"ps_int_mgc"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pump_b",
|
|
||||||
"type": "rotatingMachine",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pump B",
|
|
||||||
"speed": "1",
|
|
||||||
"startup": "2",
|
|
||||||
"warmup": "1",
|
|
||||||
"shutdown": "2",
|
|
||||||
"cooldown": "1",
|
|
||||||
"movementMode": "staticspeed",
|
|
||||||
"machineCurve": "",
|
|
||||||
"uuid": "example-pump-b",
|
|
||||||
"supplier": "hidrostal",
|
|
||||||
"category": "pump",
|
|
||||||
"assetType": "pump-centrifugal",
|
|
||||||
"model": "hidrostal-H05K-S03R",
|
|
||||||
"unit": "m3/h",
|
|
||||||
"curvePressureUnit": "mbar",
|
|
||||||
"curveFlowUnit": "m3/h",
|
|
||||||
"curvePowerUnit": "kW",
|
|
||||||
"curveControlUnit": "%",
|
|
||||||
"enableLog": false,
|
|
||||||
"logLevel": "error",
|
|
||||||
"positionVsParent": "atEquipment",
|
|
||||||
"positionIcon": "",
|
|
||||||
"hasDistance": false,
|
|
||||||
"distance": "",
|
|
||||||
"distanceUnit": "m",
|
|
||||||
"distanceDescription": "",
|
|
||||||
"x": 840,
|
|
||||||
"y": 400,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_dbg_pb"
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
"ps_int_mgc"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_mgc",
|
|
||||||
"type": "machineGroupControl",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pump Group",
|
|
||||||
"enableLog": true,
|
|
||||||
"logLevel": "info",
|
|
||||||
"positionVsParent": "atEquipment",
|
|
||||||
"positionIcon": "",
|
|
||||||
"hasDistance": false,
|
|
||||||
"distance": "",
|
|
||||||
"distanceUnit": "m",
|
|
||||||
"x": 1080,
|
|
||||||
"y": 360,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_dbg_mgc"
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
"ps_int_station"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_station",
|
|
||||||
"type": "pumpingStation",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pumping Station",
|
|
||||||
"simulator": false,
|
|
||||||
"basinVolume": 50,
|
|
||||||
"basinHeight": 3.5,
|
|
||||||
"inflowLevel": 3,
|
|
||||||
"outflowLevel": 0.2,
|
|
||||||
"overflowLevel": 3.2,
|
|
||||||
"defaultFluid": "wastewater",
|
|
||||||
"inletPipeDiameter": 0.3,
|
|
||||||
"outletPipeDiameter": 0.3,
|
|
||||||
"pipelineLength": 80,
|
|
||||||
"maxDischargeHead": 24,
|
|
||||||
"staticHead": 12,
|
|
||||||
"maxInflowRate": 200,
|
|
||||||
"temperatureReferenceDegC": 15,
|
|
||||||
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
|
||||||
"enableDryRunProtection": true,
|
|
||||||
"enableOverfillProtection": true,
|
|
||||||
"dryRunThresholdPercent": 2,
|
|
||||||
"overfillThresholdPercent": 98,
|
|
||||||
"minHeightBasedOn": "outlet",
|
|
||||||
"processOutputFormat": "process",
|
|
||||||
"dbaseOutputFormat": "influxdb",
|
|
||||||
"refHeight": "NAP",
|
|
||||||
"basinBottomRef": 1,
|
|
||||||
"uuid": "example-ps-001",
|
|
||||||
"supplier": "WBD-RD",
|
|
||||||
"category": "station",
|
|
||||||
"assetType": "pumpingstation",
|
|
||||||
"model": "demo-50m3",
|
|
||||||
"unit": "m3/h",
|
|
||||||
"enableLog": true,
|
|
||||||
"logLevel": "info",
|
|
||||||
"positionVsParent": "atEquipment",
|
|
||||||
"positionIcon": "",
|
|
||||||
"hasDistance": false,
|
|
||||||
"distance": "",
|
|
||||||
"distanceUnit": "m",
|
|
||||||
"distanceDescription": "",
|
|
||||||
"controlMode": "levelbased",
|
|
||||||
"startLevel": 1.2,
|
|
||||||
"minLevel": 0.4,
|
|
||||||
"maxLevel": 2.8,
|
|
||||||
"flowSetpoint": null,
|
|
||||||
"flowDeadband": null,
|
|
||||||
"x": 1320,
|
|
||||||
"y": 520,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_format"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_int_dbg_influx"
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_format",
|
|
||||||
"type": "function",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Merge deltas + format",
|
|
||||||
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\nfunction pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\nconst vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n childCount: cache.childCount != null ? cache.childCount : 'n/a'\n};\nreturn msg;",
|
|
||||||
"outputs": 1,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 1560,
|
|
||||||
"y": 520,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_int_dbg_process"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_dbg_process",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "PS Port 0: Process",
|
|
||||||
"active": true,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "payload",
|
|
||||||
"targetType": "msg",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 480,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_dbg_influx",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "PS Port 1: InfluxDB",
|
|
||||||
"active": false,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "true",
|
|
||||||
"targetType": "full",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 540,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_dbg_mgc",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "MGC Port 0",
|
|
||||||
"active": true,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "payload",
|
|
||||||
"targetType": "msg",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 360,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_dbg_pa",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pump A Port 0",
|
|
||||||
"active": false,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "payload",
|
|
||||||
"targetType": "msg",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 320,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_dbg_pb",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pump B Port 0",
|
|
||||||
"active": false,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "payload",
|
|
||||||
"targetType": "msg",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 400,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_int_dbg_level",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Level Port 0",
|
|
||||||
"active": false,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "payload",
|
|
||||||
"targetType": "msg",
|
|
||||||
"x": 1800,
|
|
||||||
"y": 700,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grp_pumpa",
|
|
||||||
"type": "group",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pump A (EM)",
|
|
||||||
"style": {
|
|
||||||
"label": true,
|
|
||||||
"stroke": "#000000",
|
|
||||||
"fill": "#86bbdd",
|
|
||||||
"fill-opacity": "0.10"
|
|
||||||
},
|
|
||||||
"nodes": [
|
|
||||||
"pump_a",
|
|
||||||
"ps_int_dbg_pa"
|
|
||||||
],
|
|
||||||
"x": 815,
|
|
||||||
"y": 275,
|
|
||||||
"w": 1210,
|
|
||||||
"h": 110
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grp_pumpb",
|
|
||||||
"type": "group",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pump B (EM)",
|
|
||||||
"style": {
|
|
||||||
"label": true,
|
|
||||||
"stroke": "#000000",
|
|
||||||
"fill": "#86bbdd",
|
|
||||||
"fill-opacity": "0.10"
|
|
||||||
},
|
|
||||||
"nodes": [
|
|
||||||
"pump_b",
|
|
||||||
"ps_int_dbg_pb"
|
|
||||||
],
|
|
||||||
"x": 815,
|
|
||||||
"y": 355,
|
|
||||||
"w": 1210,
|
|
||||||
"h": 110
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grp_mgc",
|
|
||||||
"type": "group",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pump Group MGC (UN)",
|
|
||||||
"style": {
|
|
||||||
"label": true,
|
|
||||||
"stroke": "#000000",
|
|
||||||
"fill": "#50a8d9",
|
|
||||||
"fill-opacity": "0.10"
|
|
||||||
},
|
|
||||||
"nodes": [
|
|
||||||
"ps_int_mgc",
|
|
||||||
"ps_int_dbg_mgc",
|
|
||||||
"lin_setup_mgcmode"
|
|
||||||
],
|
|
||||||
"x": 95,
|
|
||||||
"y": 315,
|
|
||||||
"w": 1930,
|
|
||||||
"h": 110
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grp_station",
|
|
||||||
"type": "group",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Pumping Station (PC)",
|
|
||||||
"style": {
|
|
||||||
"label": true,
|
|
||||||
"stroke": "#000000",
|
|
||||||
"fill": "#0c99d9",
|
|
||||||
"fill-opacity": "0.10"
|
|
||||||
},
|
|
||||||
"nodes": [
|
|
||||||
"ps_int_station",
|
|
||||||
"ps_int_format",
|
|
||||||
"ps_int_dbg_process",
|
|
||||||
"ps_int_dbg_influx",
|
|
||||||
"lin_setup_mode",
|
|
||||||
"lin_setup_inflow"
|
|
||||||
],
|
|
||||||
"x": 95,
|
|
||||||
"y": 435,
|
|
||||||
"w": 1930,
|
|
||||||
"h": 190
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grp_level",
|
|
||||||
"type": "group",
|
|
||||||
"z": "ps_int_proc",
|
|
||||||
"name": "Level Sensor (CM)",
|
|
||||||
"style": {
|
|
||||||
"label": true,
|
|
||||||
"stroke": "#000000",
|
|
||||||
"fill": "#a9daee",
|
|
||||||
"fill-opacity": "0.10"
|
|
||||||
},
|
|
||||||
"nodes": [
|
|
||||||
"meas_level",
|
|
||||||
"ps_int_inj_level",
|
|
||||||
"ps_int_dbg_level"
|
|
||||||
],
|
|
||||||
"x": 95,
|
|
||||||
"y": 655,
|
|
||||||
"w": 1930,
|
|
||||||
"h": 110
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "setup_title",
|
|
||||||
"type": "comment",
|
|
||||||
"z": "ps_int_setup",
|
|
||||||
"name": "Deploy-time setup\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nFires once after each deploy: pushes the canonical set.mode / set.inflow /\nset.demand topics across cross-tab channels into the Process Plant tab.",
|
|
||||||
"info": "",
|
|
||||||
"x": 600,
|
|
||||||
"y": 40,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "setup_inj_mode",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_int_setup",
|
|
||||||
"name": "set.mode = levelbased",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "levelbased",
|
|
||||||
"vt": "str"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "set.mode",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": true,
|
|
||||||
"onceDelay": "0.5",
|
|
||||||
"x": 120,
|
|
||||||
"y": 160,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"lout_setup_mode"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "setup_inj_mgcmode",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_int_setup",
|
|
||||||
"name": "MGC set.mode = auto",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "auto",
|
|
||||||
"vt": "str"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "set.mode",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": true,
|
|
||||||
"onceDelay": "0.5",
|
|
||||||
"x": 120,
|
|
||||||
"y": 220,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"lout_setup_mgcmode"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "setup_inj_inflow",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_int_setup",
|
|
||||||
"name": "seed inflow 60 m3/h",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload",
|
|
||||||
"v": "60",
|
|
||||||
"vt": "num"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"topic": "set.inflow",
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": true,
|
|
||||||
"onceDelay": "1.0",
|
|
||||||
"x": 120,
|
|
||||||
"y": 280,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"lout_setup_inflow"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lout_setup_mode",
|
|
||||||
"type": "link out",
|
|
||||||
"z": "ps_int_setup",
|
|
||||||
"name": "setup:to-ps-mode",
|
|
||||||
"mode": "link",
|
|
||||||
"links": [
|
|
||||||
"lin_setup_mode"
|
|
||||||
],
|
|
||||||
"x": 1800,
|
|
||||||
"y": 160,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lout_setup_mgcmode",
|
|
||||||
"type": "link out",
|
|
||||||
"z": "ps_int_setup",
|
|
||||||
"name": "setup:to-mgc-mode",
|
|
||||||
"mode": "link",
|
|
||||||
"links": [
|
|
||||||
"lin_setup_mgcmode"
|
|
||||||
],
|
|
||||||
"x": 1800,
|
|
||||||
"y": 220,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lout_setup_inflow",
|
|
||||||
"type": "link out",
|
|
||||||
"z": "ps_int_setup",
|
|
||||||
"name": "setup:to-ps-inflow",
|
|
||||||
"mode": "link",
|
|
||||||
"links": [
|
|
||||||
"lin_setup_inflow"
|
|
||||||
],
|
|
||||||
"x": 1800,
|
|
||||||
"y": 280,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grp_setup",
|
|
||||||
"type": "group",
|
|
||||||
"z": "ps_int_setup",
|
|
||||||
"name": "Deploy-time setup",
|
|
||||||
"style": {
|
|
||||||
"label": true,
|
|
||||||
"stroke": "#000000",
|
|
||||||
"fill": "#dddddd",
|
|
||||||
"fill-opacity": "0.10"
|
|
||||||
},
|
|
||||||
"nodes": [
|
|
||||||
"setup_inj_mode",
|
|
||||||
"setup_inj_mgcmode",
|
|
||||||
"setup_inj_inflow",
|
|
||||||
"lout_setup_mode",
|
|
||||||
"lout_setup_mgcmode",
|
|
||||||
"lout_setup_inflow"
|
|
||||||
],
|
|
||||||
"x": 95,
|
|
||||||
"y": 115,
|
|
||||||
"w": 1930,
|
|
||||||
"h": 230
|
|
||||||
}
|
|
||||||
]
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
# pumpingStation - Example Flows
|
# pumpingStation - Example Flows
|
||||||
|
|
||||||
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
|
Node-RED flows demonstrating the Phase-2 pumpingStation node on the
|
||||||
canonical topic API (`set.mode`, `set.inflow`, `set.demand`,
|
canonical topic API (`set.mode`, `set.inflow`, `set.outflow`, `set.demand`,
|
||||||
`cmd.calibrate.volume`, `cmd.calibrate.level`). Legacy aliases
|
`cmd.calibrate.volume`, `cmd.calibrate.level`). Legacy aliases
|
||||||
(`changemode`, `q_in`, `Qd`, `calibratePredictedVolume`,
|
(`changemode`, `q_in`, `q_out`, `Qd`, `calibratePredictedVolume`,
|
||||||
`calibratePredictedLevel`, `registerChild`) still work but log a
|
`calibratePredictedLevel`, `registerChild`) still work but log a
|
||||||
one-time deprecation warning; these fresh flows use the canonical names only.
|
one-time deprecation warning; these fresh flows use the canonical names only.
|
||||||
|
|
||||||
@@ -12,15 +12,14 @@ one-time deprecation warning; these fresh flows use the canonical names only.
|
|||||||
| File | Tier | Tabs | Purpose |
|
| File | Tier | Tabs | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `01-Basic.json` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
|
| `01-Basic.json` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
|
||||||
| `02-Integration.json` | 2 | Process Plant + Setup | Adds a `measurement` level child and a `machineGroupControl` parent with two `rotatingMachine` pumps. Demonstrates the Phase-2 parent/child handshake. |
|
| `02-Dashboard.json` | 2 | Process Plant + Dashboard UI | Same command surface as Basic, but driven by FlowFuse Dashboard 2.0 widgets — `ui-button` controls + `ui-text` live status panel. |
|
||||||
| `03-Dashboard.json` | 3 | Process Plant + Dashboard UI + Setup | Tier 2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). |
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node-RED with the EVOLV package installed (so the `pumpingStation`,
|
- Node-RED with the EVOLV package installed (so the `pumpingStation`,
|
||||||
`measurement`, `machineGroupControl`, and `rotatingMachine` node
|
`measurement`, `machineGroupControl`, and `rotatingMachine` node
|
||||||
types are registered).
|
types are registered).
|
||||||
- For `03-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
|
- For `02-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
|
||||||
|
|
||||||
## How to load
|
## How to load
|
||||||
|
|
||||||
@@ -46,28 +45,22 @@ import into their own tabs and can be deployed immediately.
|
|||||||
5. Inject `cmd.calibrate.volume = 25 m3` to jump the predicted-volume
|
5. Inject `cmd.calibrate.volume = 25 m3` to jump the predicted-volume
|
||||||
integrator to half-full.
|
integrator to half-full.
|
||||||
|
|
||||||
## 02-Integration - what to try
|
## 02-Dashboard - what to try
|
||||||
|
|
||||||
1. Deploy. The Setup tab fires `set.mode = levelbased` to the station
|
|
||||||
and `set.mode = auto` to the MGC.
|
|
||||||
2. The two pumps register with the MGC via Port 2; the MGC and the level
|
|
||||||
sensor register with the station via Port 2. Watch the registration
|
|
||||||
debug taps to confirm.
|
|
||||||
3. The level inject pushes a 1.6 m measurement so the station sees a
|
|
||||||
non-zero starting level. Setup also seeds `set.inflow = 60 m3/h`.
|
|
||||||
4. The station's `controlMode = levelbased` then drives the MGC, which
|
|
||||||
dispatches to Pump A / Pump B.
|
|
||||||
|
|
||||||
## 03-Dashboard - what to try
|
|
||||||
|
|
||||||
1. Deploy.
|
1. Deploy.
|
||||||
2. Open the dashboard at `http://localhost:1880/dashboard/page/pumping-station`.
|
2. Open the dashboard at `http://localhost:1880/dashboard/pumpingstation-basic`.
|
||||||
3. Use the **Control mode** dropdown to switch between `manual`,
|
3. Click **Mode: Manual** or **Mode: Levelbased** in the Controls panel.
|
||||||
`levelbased`, `flowbased`, `none`.
|
4. Click **Inflow 60 m³/h** to push wastewater into the basin — the Status
|
||||||
4. In manual mode, drag the **Manual demand** slider - the demand cascades
|
panel on the right shows level / volume / volume % rising.
|
||||||
to the MGC and on to the pumps.
|
5. In manual mode, click **Demand 40 m³/h** — the value surfaces as
|
||||||
5. The three charts (flow, level, volume %) plot live data; the four text
|
`Manual demand` in the Status panel and in the node's status badge.
|
||||||
widgets show state, percControl, direction, and time-to-empty.
|
6. Use **Calibrate V = 25 m³** or **Calibrate L = 1.5 m** to snap the
|
||||||
|
predicted-volume integrator.
|
||||||
|
|
||||||
|
All buttons fire the same canonical `msg.topic` as the Basic flow's inject
|
||||||
|
nodes; the only difference is the trigger. The Live status panel is fed by
|
||||||
|
Port 0 via a small fan-out function that caches last-known values so
|
||||||
|
delta-only updates never blank a row.
|
||||||
|
|
||||||
## Layout conventions
|
## Layout conventions
|
||||||
|
|
||||||
@@ -88,12 +81,6 @@ These flows follow the EVOLV layout rule set in
|
|||||||
|
|
||||||
## Regenerating
|
## Regenerating
|
||||||
|
|
||||||
These flows are generated from `tools/build-examples.js`. Edit the
|
The current example JSON files are hand-maintained. If you re-introduce a
|
||||||
generator, never the JSON, then:
|
generator, regenerate `01-Basic.json` and `02-Dashboard.json` from it
|
||||||
|
rather than editing the JSON directly.
|
||||||
```bash
|
|
||||||
node nodes/pumpingStation/tools/build-examples.js
|
|
||||||
```
|
|
||||||
|
|
||||||
The script writes `01-Basic.json`, `02-Integration.json`, and
|
|
||||||
`03-Dashboard.json` into this directory.
|
|
||||||
|
|||||||
@@ -1,589 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "ps_tab_basic_dashboard",
|
|
||||||
"type": "tab",
|
|
||||||
"label": "PumpingStation Dashboard",
|
|
||||||
"disabled": false,
|
|
||||||
"info": "Basic level-based pumpingStation dashboard with basin trends and safety state."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ui_base_ps_basic",
|
|
||||||
"type": "ui-base",
|
|
||||||
"name": "EVOLV Demo",
|
|
||||||
"path": "/dashboard",
|
|
||||||
"appIcon": "",
|
|
||||||
"includeClientData": true,
|
|
||||||
"acceptsClientConfig": [
|
|
||||||
"ui-notification",
|
|
||||||
"ui-control"
|
|
||||||
],
|
|
||||||
"showPathInSidebar": false,
|
|
||||||
"headerContent": "page",
|
|
||||||
"navigationStyle": "default",
|
|
||||||
"titleBarStyle": "default"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ui_theme_ps_basic",
|
|
||||||
"type": "ui-theme",
|
|
||||||
"name": "EVOLV Pumping Theme",
|
|
||||||
"colors": {
|
|
||||||
"surface": "#ffffff",
|
|
||||||
"primary": "#0c99d9",
|
|
||||||
"bgPage": "#f1f3f5",
|
|
||||||
"groupBg": "#ffffff",
|
|
||||||
"groupOutline": "#cfd7de"
|
|
||||||
},
|
|
||||||
"sizes": {
|
|
||||||
"density": "default",
|
|
||||||
"pagePadding": "14px",
|
|
||||||
"groupGap": "14px",
|
|
||||||
"groupBorderRadius": "6px",
|
|
||||||
"widgetGap": "12px"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ui_page_ps_basic",
|
|
||||||
"type": "ui-page",
|
|
||||||
"name": "PumpingStation",
|
|
||||||
"ui": "ui_base_ps_basic",
|
|
||||||
"path": "/pumping-station",
|
|
||||||
"icon": "water_drop",
|
|
||||||
"layout": "grid",
|
|
||||||
"theme": "ui_theme_ps_basic",
|
|
||||||
"breakpoints": [
|
|
||||||
{
|
|
||||||
"name": "Default",
|
|
||||||
"px": "0",
|
|
||||||
"cols": "12"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"order": 1,
|
|
||||||
"className": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ui_group_ps_inputs",
|
|
||||||
"type": "ui-group",
|
|
||||||
"name": "Simulation Inputs",
|
|
||||||
"page": "ui_page_ps_basic",
|
|
||||||
"width": "4",
|
|
||||||
"height": "1",
|
|
||||||
"order": 1,
|
|
||||||
"showTitle": true,
|
|
||||||
"className": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ui_group_ps_trends",
|
|
||||||
"type": "ui-group",
|
|
||||||
"name": "Basin Trends",
|
|
||||||
"page": "ui_page_ps_basic",
|
|
||||||
"width": "8",
|
|
||||||
"height": "1",
|
|
||||||
"order": 2,
|
|
||||||
"showTitle": true,
|
|
||||||
"className": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ui_group_ps_state",
|
|
||||||
"type": "ui-group",
|
|
||||||
"name": "State",
|
|
||||||
"page": "ui_page_ps_basic",
|
|
||||||
"width": "12",
|
|
||||||
"height": "1",
|
|
||||||
"order": 3,
|
|
||||||
"showTitle": true,
|
|
||||||
"className": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_node_basic",
|
|
||||||
"type": "pumpingStation",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"name": "PS Dashboard Demo",
|
|
||||||
"basinVolume": 50,
|
|
||||||
"basinHeight": 5,
|
|
||||||
"inflowLevel": 3,
|
|
||||||
"outflowLevel": 0.2,
|
|
||||||
"overflowLevel": 4.5,
|
|
||||||
"defaultFluid": "wastewater",
|
|
||||||
"inletPipeDiameter": 0.4,
|
|
||||||
"outletPipeDiameter": 0.3,
|
|
||||||
"pipelineLength": 80,
|
|
||||||
"maxDischargeHead": 24,
|
|
||||||
"staticHead": 12,
|
|
||||||
"maxInflowRate": 200,
|
|
||||||
"temperatureReferenceDegC": 15,
|
|
||||||
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
|
||||||
"enableDryRunProtection": true,
|
|
||||||
"enableHighVolumeSafety": true,
|
|
||||||
"enableOverfillProtection": true,
|
|
||||||
"dryRunThresholdPercent": 2,
|
|
||||||
"highVolumeSafetyThresholdPercent": 98,
|
|
||||||
"overfillThresholdPercent": 98,
|
|
||||||
"minHeightBasedOn": "outlet",
|
|
||||||
"processOutputFormat": "process",
|
|
||||||
"dbaseOutputFormat": "influxdb",
|
|
||||||
"refHeight": "NAP",
|
|
||||||
"basinBottomRef": 0,
|
|
||||||
"unit": "m3/h",
|
|
||||||
"enableLog": false,
|
|
||||||
"logLevel": "error",
|
|
||||||
"positionVsParent": "atEquipment",
|
|
||||||
"positionIcon": "",
|
|
||||||
"hasDistance": false,
|
|
||||||
"distance": 0,
|
|
||||||
"distanceUnit": "m",
|
|
||||||
"distanceDescription": "",
|
|
||||||
"controlMode": "levelbased",
|
|
||||||
"levelCurveType": "linear",
|
|
||||||
"logCurveFactor": 9,
|
|
||||||
"minLevel": 1,
|
|
||||||
"startLevel": 2,
|
|
||||||
"maxLevel": 4,
|
|
||||||
"x": 720,
|
|
||||||
"y": 260,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_parse_output"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_debug_influx"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_debug_parent"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_calibrate_initial",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"name": "Set start level 2 m",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "topic",
|
|
||||||
"vt": "str"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"p": "payload"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"repeat": "",
|
|
||||||
"crontab": "",
|
|
||||||
"once": true,
|
|
||||||
"onceDelay": "0.5",
|
|
||||||
"topic": "calibratePredictedLevel",
|
|
||||||
"payload": "2",
|
|
||||||
"payloadType": "num",
|
|
||||||
"x": 180,
|
|
||||||
"y": 180,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_node_basic"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_auto_inflow",
|
|
||||||
"type": "inject",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"name": "Auto inflow 0.008 m3/s",
|
|
||||||
"props": [
|
|
||||||
{
|
|
||||||
"p": "payload"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"repeat": "1",
|
|
||||||
"crontab": "",
|
|
||||||
"once": true,
|
|
||||||
"onceDelay": "1",
|
|
||||||
"topic": "",
|
|
||||||
"payload": "0.008",
|
|
||||||
"payloadType": "num",
|
|
||||||
"x": 180,
|
|
||||||
"y": 240,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_build_qin"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_inflow_input",
|
|
||||||
"type": "ui-number-input",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"group": "ui_group_ps_inputs",
|
|
||||||
"name": "Inflow",
|
|
||||||
"label": "Inflow (m3/s)",
|
|
||||||
"order": 1,
|
|
||||||
"width": "4",
|
|
||||||
"height": "1",
|
|
||||||
"passthru": true,
|
|
||||||
"topic": "",
|
|
||||||
"min": 0,
|
|
||||||
"max": 0.05,
|
|
||||||
"step": 0.001,
|
|
||||||
"x": 190,
|
|
||||||
"y": 300,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_build_qin"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_build_qin",
|
|
||||||
"type": "function",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"name": "Build q_in",
|
|
||||||
"func": "msg.topic = 'q_in';\nmsg.unit = 'm3/s';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
|
||||||
"outputs": 1,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 440,
|
|
||||||
"y": 260,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_node_basic"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_outflow_input",
|
|
||||||
"type": "ui-number-input",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"group": "ui_group_ps_inputs",
|
|
||||||
"name": "Outflow",
|
|
||||||
"label": "Outflow (m3/s)",
|
|
||||||
"order": 2,
|
|
||||||
"width": "4",
|
|
||||||
"height": "1",
|
|
||||||
"passthru": true,
|
|
||||||
"topic": "",
|
|
||||||
"min": 0,
|
|
||||||
"max": 0.05,
|
|
||||||
"step": 0.001,
|
|
||||||
"x": 190,
|
|
||||||
"y": 360,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_build_qout"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_build_qout",
|
|
||||||
"type": "function",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"name": "Build q_out",
|
|
||||||
"func": "msg.topic = 'q_out';\nmsg.unit = 'm3/s';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
|
||||||
"outputs": 1,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 440,
|
|
||||||
"y": 360,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_node_basic"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_parse_output",
|
|
||||||
"type": "function",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"name": "Parse PS output",
|
|
||||||
"func": "// MeasurementContainer flat keys are `${type}.${variant}.${position}.${childId}`.\n// When PS writes without an explicit .child(), the childId is the literal\n// string 'default' — DON'T strip it. See generalFunctions/src/measurements/\n// MeasurementContainer.js getFlattenedOutput for details.\nconst fields = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst snapshot = Object.assign({}, context.get('snapshot') || {}, fields);\ncontext.set('snapshot', snapshot);\nconst firstFinite = (...keys) => {\n for (const key of keys) {\n const value = Number(snapshot[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\nconst level = firstFinite('level.predicted.atequipment.default', 'level.measured.atequipment.default');\nconst volume = firstFinite('volume.predicted.atequipment.default', 'volume.measured.atequipment.default');\nconst netFlow = firstFinite('netFlowRate.predicted.atequipment.default', 'netFlowRate.measured.atequipment.default');\nconst demand = firstFinite('percControl');\nconst safety = snapshot.safetyState || 'normal';\nconst direction = snapshot.direction || 'unknown';\nconst overflow = snapshot.isOverflowing === true || snapshot.isOverflowing === 'true';\nconst timeleft = Number(snapshot.timeleft);\nconst fmt = (value, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : '-';\nreturn [\n level == null ? null : { topic: 'level', payload: level },\n volume == null ? null : { topic: 'volume', payload: volume },\n demand == null ? null : { topic: 'demand', payload: demand },\n netFlow == null ? null : { topic: 'net_flow', payload: netFlow },\n { topic: 'safety', payload: `${safety} | overflowing=${overflow}` },\n { topic: 'snapshot', payload: `level=${fmt(level)} m | volume=${fmt(volume)} m3 | demand=${fmt(demand, 0)}% | direction=${direction} | t=${Number.isFinite(timeleft) ? Math.round(timeleft) + ' s' : '-'}` }\n];",
|
|
||||||
"outputs": 6,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 980,
|
|
||||||
"y": 220,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"ps_chart_level"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_chart_volume"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_chart_demand"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_chart_netflow"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_text_safety"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ps_text_snapshot"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_chart_level",
|
|
||||||
"type": "ui-chart",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"group": "ui_group_ps_trends",
|
|
||||||
"name": "Level",
|
|
||||||
"label": "Level (m)",
|
|
||||||
"order": 1,
|
|
||||||
"width": 4,
|
|
||||||
"height": 4,
|
|
||||||
"chartType": "line",
|
|
||||||
"category": "topic",
|
|
||||||
"xAxisType": "time",
|
|
||||||
"yAxisLabel": "m",
|
|
||||||
"removeOlder": "15",
|
|
||||||
"removeOlderUnit": "60",
|
|
||||||
"x": 1230,
|
|
||||||
"y": 140,
|
|
||||||
"wires": [],
|
|
||||||
"showLegend": false,
|
|
||||||
"categoryType": "msg",
|
|
||||||
"xAxisProperty": "",
|
|
||||||
"xAxisPropertyType": "timestamp",
|
|
||||||
"xAxisFormat": "",
|
|
||||||
"xAxisFormatType": "auto",
|
|
||||||
"yAxisProperty": "payload",
|
|
||||||
"yAxisPropertyType": "msg",
|
|
||||||
"xmin": "",
|
|
||||||
"xmax": "",
|
|
||||||
"ymin": "0",
|
|
||||||
"ymax": "5",
|
|
||||||
"bins": 10,
|
|
||||||
"action": "append",
|
|
||||||
"stackSeries": false,
|
|
||||||
"pointShape": "circle",
|
|
||||||
"pointRadius": 4,
|
|
||||||
"interpolation": "linear",
|
|
||||||
"className": "",
|
|
||||||
"colors": [
|
|
||||||
"#0c99d9"
|
|
||||||
],
|
|
||||||
"textColor": [
|
|
||||||
"#666666"
|
|
||||||
],
|
|
||||||
"textColorDefault": true,
|
|
||||||
"gridColor": [
|
|
||||||
"#e5e5e5"
|
|
||||||
],
|
|
||||||
"gridColorDefault": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_chart_volume",
|
|
||||||
"type": "ui-chart",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"group": "ui_group_ps_trends",
|
|
||||||
"name": "Volume",
|
|
||||||
"label": "Volume (m3)",
|
|
||||||
"order": 2,
|
|
||||||
"width": 4,
|
|
||||||
"height": 4,
|
|
||||||
"chartType": "line",
|
|
||||||
"category": "topic",
|
|
||||||
"xAxisType": "time",
|
|
||||||
"yAxisLabel": "m3",
|
|
||||||
"removeOlder": "15",
|
|
||||||
"removeOlderUnit": "60",
|
|
||||||
"x": 1230,
|
|
||||||
"y": 200,
|
|
||||||
"wires": [],
|
|
||||||
"showLegend": false,
|
|
||||||
"categoryType": "msg",
|
|
||||||
"xAxisProperty": "",
|
|
||||||
"xAxisPropertyType": "timestamp",
|
|
||||||
"xAxisFormat": "",
|
|
||||||
"xAxisFormatType": "auto",
|
|
||||||
"yAxisProperty": "payload",
|
|
||||||
"yAxisPropertyType": "msg",
|
|
||||||
"xmin": "",
|
|
||||||
"xmax": "",
|
|
||||||
"ymin": "0",
|
|
||||||
"ymax": "50",
|
|
||||||
"bins": 10,
|
|
||||||
"action": "append",
|
|
||||||
"stackSeries": false,
|
|
||||||
"pointShape": "circle",
|
|
||||||
"pointRadius": 4,
|
|
||||||
"interpolation": "linear",
|
|
||||||
"className": "",
|
|
||||||
"colors": [
|
|
||||||
"#2ca02c"
|
|
||||||
],
|
|
||||||
"textColor": [
|
|
||||||
"#666666"
|
|
||||||
],
|
|
||||||
"textColorDefault": true,
|
|
||||||
"gridColor": [
|
|
||||||
"#e5e5e5"
|
|
||||||
],
|
|
||||||
"gridColorDefault": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_chart_demand",
|
|
||||||
"type": "ui-chart",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"group": "ui_group_ps_trends",
|
|
||||||
"name": "Demand",
|
|
||||||
"label": "Demand (%)",
|
|
||||||
"order": 3,
|
|
||||||
"width": 4,
|
|
||||||
"height": 4,
|
|
||||||
"chartType": "line",
|
|
||||||
"category": "topic",
|
|
||||||
"xAxisType": "time",
|
|
||||||
"yAxisLabel": "%",
|
|
||||||
"removeOlder": "15",
|
|
||||||
"removeOlderUnit": "60",
|
|
||||||
"x": 1230,
|
|
||||||
"y": 260,
|
|
||||||
"wires": [],
|
|
||||||
"showLegend": false,
|
|
||||||
"categoryType": "msg",
|
|
||||||
"xAxisProperty": "",
|
|
||||||
"xAxisPropertyType": "timestamp",
|
|
||||||
"xAxisFormat": "",
|
|
||||||
"xAxisFormatType": "auto",
|
|
||||||
"yAxisProperty": "payload",
|
|
||||||
"yAxisPropertyType": "msg",
|
|
||||||
"xmin": "",
|
|
||||||
"xmax": "",
|
|
||||||
"ymin": "0",
|
|
||||||
"ymax": "120",
|
|
||||||
"bins": 10,
|
|
||||||
"action": "append",
|
|
||||||
"stackSeries": false,
|
|
||||||
"pointShape": "circle",
|
|
||||||
"pointRadius": 4,
|
|
||||||
"interpolation": "linear",
|
|
||||||
"className": "",
|
|
||||||
"colors": [
|
|
||||||
"#d68910"
|
|
||||||
],
|
|
||||||
"textColor": [
|
|
||||||
"#666666"
|
|
||||||
],
|
|
||||||
"textColorDefault": true,
|
|
||||||
"gridColor": [
|
|
||||||
"#e5e5e5"
|
|
||||||
],
|
|
||||||
"gridColorDefault": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_chart_netflow",
|
|
||||||
"type": "ui-chart",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"group": "ui_group_ps_trends",
|
|
||||||
"name": "Net Flow",
|
|
||||||
"label": "Net flow (m3/s)",
|
|
||||||
"order": 4,
|
|
||||||
"width": 4,
|
|
||||||
"height": 4,
|
|
||||||
"chartType": "line",
|
|
||||||
"category": "topic",
|
|
||||||
"xAxisType": "time",
|
|
||||||
"yAxisLabel": "m3/s",
|
|
||||||
"removeOlder": "15",
|
|
||||||
"removeOlderUnit": "60",
|
|
||||||
"x": 1240,
|
|
||||||
"y": 320,
|
|
||||||
"wires": [],
|
|
||||||
"showLegend": false,
|
|
||||||
"categoryType": "msg",
|
|
||||||
"xAxisProperty": "",
|
|
||||||
"xAxisPropertyType": "timestamp",
|
|
||||||
"xAxisFormat": "",
|
|
||||||
"xAxisFormatType": "auto",
|
|
||||||
"yAxisProperty": "payload",
|
|
||||||
"yAxisPropertyType": "msg",
|
|
||||||
"xmin": "",
|
|
||||||
"xmax": "",
|
|
||||||
"ymin": "",
|
|
||||||
"ymax": "",
|
|
||||||
"bins": 10,
|
|
||||||
"action": "append",
|
|
||||||
"stackSeries": false,
|
|
||||||
"pointShape": "circle",
|
|
||||||
"pointRadius": 4,
|
|
||||||
"interpolation": "linear",
|
|
||||||
"className": "",
|
|
||||||
"colors": [
|
|
||||||
"#9467bd"
|
|
||||||
],
|
|
||||||
"textColor": [
|
|
||||||
"#666666"
|
|
||||||
],
|
|
||||||
"textColorDefault": true,
|
|
||||||
"gridColor": [
|
|
||||||
"#e5e5e5"
|
|
||||||
],
|
|
||||||
"gridColorDefault": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_text_safety",
|
|
||||||
"type": "ui-text",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"group": "ui_group_ps_state",
|
|
||||||
"name": "Safety",
|
|
||||||
"label": "Safety",
|
|
||||||
"order": 1,
|
|
||||||
"width": 4,
|
|
||||||
"height": 1,
|
|
||||||
"format": "{{msg.payload}}",
|
|
||||||
"layout": "row-spread",
|
|
||||||
"x": 1230,
|
|
||||||
"y": 380,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_text_snapshot",
|
|
||||||
"type": "ui-text",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"group": "ui_group_ps_state",
|
|
||||||
"name": "Snapshot",
|
|
||||||
"label": "Snapshot",
|
|
||||||
"order": 2,
|
|
||||||
"width": 8,
|
|
||||||
"height": 1,
|
|
||||||
"format": "{{msg.payload}}",
|
|
||||||
"layout": "row-spread",
|
|
||||||
"x": 1240,
|
|
||||||
"y": 440,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_debug_influx",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"name": "Influx output",
|
|
||||||
"active": false,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "true",
|
|
||||||
"targetType": "full",
|
|
||||||
"x": 980,
|
|
||||||
"y": 320,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ps_debug_parent",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "ps_tab_basic_dashboard",
|
|
||||||
"name": "Parent output",
|
|
||||||
"active": false,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "true",
|
|
||||||
"targetType": "full",
|
|
||||||
"x": 980,
|
|
||||||
"y": 380,
|
|
||||||
"wires": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* Standalone PumpingStation demo — run with `node examples/standalone-demo.js`.
|
|
||||||
* Builds a station + one pump, calibrates predicted volume, ticks once.
|
|
||||||
* Useful for sanity-checking the orchestrator without Node-RED.
|
|
||||||
*/
|
|
||||||
const PumpingStation = require('../src/specificClass');
|
|
||||||
const RotatingMachine = require('../../rotatingMachine/src/specificClass');
|
|
||||||
|
|
||||||
function createPumpingStationConfig(name) {
|
|
||||||
return {
|
|
||||||
general: {
|
|
||||||
logging: { enabled: true, logLevel: 'debug' },
|
|
||||||
name,
|
|
||||||
id: `${name}-${Date.now()}`,
|
|
||||||
flowThreshold: 1e-4,
|
|
||||||
},
|
|
||||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller' },
|
|
||||||
basin: { volume: 43.75, height: 10, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 3.2 },
|
|
||||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0 },
|
|
||||||
safety: { enableDryRunProtection: false, enableOverfillProtection: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMachineConfig(name, position) {
|
|
||||||
return {
|
|
||||||
general: { name, logging: { enabled: false, logLevel: 'debug' } },
|
|
||||||
functionality: { softwareType: 'machine', positionVsParent: position },
|
|
||||||
asset: { supplier: 'Hydrostal', type: 'pump', category: 'centrifugal', model: 'hidrostal-H05K-S03R' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMachineStateConfig() {
|
|
||||||
return {
|
|
||||||
general: { logging: { enabled: true, logLevel: 'debug' } },
|
|
||||||
movement: { speed: 1 },
|
|
||||||
time: { starting: 2, warmingup: 3, stopping: 2, coolingdown: 3 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
(async function demo() {
|
|
||||||
const station = new PumpingStation(createPumpingStationConfig('PumpingStationDemo'));
|
|
||||||
const pump1 = new RotatingMachine(createMachineConfig('Pump1', 'downstream'), createMachineStateConfig());
|
|
||||||
|
|
||||||
station.childRegistrationUtils.registerChild(pump1, 'machine');
|
|
||||||
|
|
||||||
setInterval(() => station.tick(), 1000);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
console.log('Initial state:', station.state);
|
|
||||||
station.setManualInflow(300, Date.now(), 'l/s');
|
|
||||||
station.calibratePredictedVolume(3.4);
|
|
||||||
|
|
||||||
console.log('Station state:', station.state);
|
|
||||||
console.log('Station output:', station.getOutput());
|
|
||||||
})().catch((err) => {
|
|
||||||
console.error('Demo failed:', err);
|
|
||||||
});
|
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
"main": "pumpingStation.js",
|
"main": "pumpingStation.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/",
|
"test": "node --test test/",
|
||||||
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
|
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Reference-Contracts.md",
|
||||||
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
|
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Reference-Contracts.md",
|
||||||
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -23,17 +23,17 @@
|
|||||||
<script>//test
|
<script>//test
|
||||||
RED.nodes.registerType("pumpingStation", {
|
RED.nodes.registerType("pumpingStation", {
|
||||||
category: "EVOLV",
|
category: "EVOLV",
|
||||||
color: "#0c99d9", // color for the node based on the S88 schema
|
color: "#8B4513",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
|
|
||||||
// Define station-specific properties
|
// Define station-specific properties
|
||||||
simulator: { value: false },
|
simulator: { value: false },
|
||||||
basinVolume: { value: 1 }, // m³, total empty basin
|
basinVolume: { value: 50 }, // m³, total empty basin
|
||||||
basinHeight: { value: 1 }, // m, floor to top
|
basinHeight: { value: 4 }, // m, floor to top
|
||||||
inflowLevel: { value: 0.8 }, // m, bottom/invert of inlet pipe above floor
|
inflowLevel: { value: 1.5 }, // m, bottom/invert of inlet pipe above floor
|
||||||
outflowLevel: { value: 0.2 }, // m, top of outlet/suction pipe above floor
|
outflowLevel: { value: 0.2 }, // m, top of outlet/suction pipe above floor
|
||||||
overflowLevel: { value: 0.9 }, // m, overflow elevation
|
overflowLevel: { value: 3.8 }, // m, overflow elevation
|
||||||
defaultFluid: { value: "wastewater" },
|
defaultFluid: { value: "wastewater" },
|
||||||
inletPipeDiameter: { value: 0.3 }, // m
|
inletPipeDiameter: { value: 0.3 }, // m
|
||||||
outletPipeDiameter: { value: 0.3 }, // m
|
outletPipeDiameter: { value: 0.3 }, // m
|
||||||
@@ -84,10 +84,12 @@
|
|||||||
enableShiftedRamp: { value: false },
|
enableShiftedRamp: { value: false },
|
||||||
shiftLevel: { value: 0 },
|
shiftLevel: { value: 0 },
|
||||||
shiftArmPercent: { value: 95 },
|
shiftArmPercent: { value: 95 },
|
||||||
startLevel: { value: null },
|
startLevel: { value: 1 }, // m, pump-on threshold (engagement edge)
|
||||||
stopLevel: { value: null },
|
stopLevel: { value: 0.5 }, // m, pump-off threshold (hysteresis fall-back)
|
||||||
minLevel: { value: null },
|
holdLevel: { value: 1 }, // m, ramp 0%-foot; defaults to startLevel (= no hold zone)
|
||||||
maxLevel: { value: null },
|
deadZoneKeepAlivePercent: { value: 1 }, // % emitted across [stopLevel, startLevel] keep-alive band
|
||||||
|
minLevel: { value: 0.3 }, // m, hard-stop (just above outflow pipe top)
|
||||||
|
maxLevel: { value: 3.8 }, // m, 100% demand saturation
|
||||||
flowSetpoint: { value: null },
|
flowSetpoint: { value: null },
|
||||||
flowDeadband: { value: null }
|
flowDeadband: { value: null }
|
||||||
|
|
||||||
@@ -418,6 +420,11 @@
|
|||||||
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
|
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
|
||||||
<span class="ps-unit">m</span>
|
<span class="ps-unit">m</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#27AE60" data-couples-line="ps-mode-line-holdLevel">
|
||||||
|
<div><label>holdLevel</label><div class="ps-sub">0 % ramp foot — leave at startLevel for no hold band</div></div>
|
||||||
|
<input type="number" id="node-input-holdLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
|
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
|
||||||
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
|
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
|
||||||
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
|
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
|
||||||
@@ -475,6 +482,7 @@
|
|||||||
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-holdLevel" y1="24" y2="140" stroke="#27AE60" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
||||||
@@ -565,6 +573,7 @@
|
|||||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||||
<option value="influxdb">influxdb</option>
|
<option value="influxdb">influxdb</option>
|
||||||
|
<option value="frost">frost</option>
|
||||||
<option value="json">json</option>
|
<option value="json">json</option>
|
||||||
<option value="csv">csv</option>
|
<option value="csv">csv</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -4,7 +4,14 @@
|
|||||||
//
|
//
|
||||||
// Invariants enforced (level-space, bottom → top):
|
// Invariants enforced (level-space, bottom → top):
|
||||||
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||||
// dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
// dryRunLevel ≤ minLevel ≤ startLevel ≤ holdLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
||||||
|
//
|
||||||
|
// startLevel is INTENTIONALLY not constrained against inflowLevel: setting
|
||||||
|
// startLevel above the gravity-feed inlet is the "buffer in the sewer"
|
||||||
|
// configuration where the upstream pipe network is used as overflow storage
|
||||||
|
// before pumping engages. holdLevel (optional, defaults to startLevel when
|
||||||
|
// omitted) is the 0 % ramp foot — pumps engage at startLevel but hold at
|
||||||
|
// min flow until level rises through holdLevel.
|
||||||
//
|
//
|
||||||
// dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
|
// dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
|
||||||
// The validator recomputes them so a config that places minLevel below the
|
// The validator recomputes them so a config that places minLevel below the
|
||||||
@@ -56,14 +63,26 @@ function validateThresholdOrdering(basin, levelbased, safety) {
|
|||||||
const points = computeSafetyPoints(basin, safety);
|
const points = computeSafetyPoints(basin, safety);
|
||||||
const { dryRunLevel, highVolumeSafetyLevel } = points;
|
const { dryRunLevel, highVolumeSafetyLevel } = points;
|
||||||
|
|
||||||
|
// holdLevel is optional — when omitted (null/undefined/NaN) it equals
|
||||||
|
// startLevel at runtime, so skip both holdLevel-related checks in that
|
||||||
|
// case (the canonical engine semantics still hold). Explicit null/undefined
|
||||||
|
// check first so `Number(null) === 0` doesn't accidentally flag a default
|
||||||
|
// schema value as a real operator-provided one.
|
||||||
|
const rawHold = lvl.holdLevel;
|
||||||
|
const holdLevelProvided = rawHold != null && Number.isFinite(Number(rawHold));
|
||||||
|
const holdLevel = holdLevelProvided ? Number(rawHold) : null;
|
||||||
|
|
||||||
const checks = [
|
const checks = [
|
||||||
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||||
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||||
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||||
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||||
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||||
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
|
|
||||||
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
...(holdLevelProvided ? [
|
||||||
|
['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel],
|
||||||
|
['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
] : []),
|
||||||
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
|
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,14 @@ exports.setOutflow = (source, msg) => {
|
|||||||
|
|
||||||
exports.setDemand = (source, msg, ctx) => {
|
exports.setDemand = (source, msg, ctx) => {
|
||||||
const log = _logger(source, ctx);
|
const log = _logger(source, ctx);
|
||||||
const demand = Number(msg.payload);
|
// generalFunctions/commandRegistry's _normaliseUnits has already converted
|
||||||
|
// msg.payload to m3/h (the descriptor's units.default — see
|
||||||
|
// commands/index.js). Accepts {value, unit} objects upstream; we just read
|
||||||
|
// the normalized number here. _manualDemand is stored in m3/h, no further
|
||||||
|
// conversion needed.
|
||||||
|
const demand = Number(msg?.payload);
|
||||||
if (!Number.isFinite(demand)) {
|
if (!Number.isFinite(demand)) {
|
||||||
log?.warn?.(`set.demand: invalid Qd value '${msg.payload}'`);
|
log?.warn?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source.mode !== 'manual') {
|
if (source.mode !== 'manual') {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
// through the dead band [stopLevel, startLevel] emitting a small
|
// through the dead band [stopLevel, startLevel] emitting a small
|
||||||
// keep-alive demand so MGC keeps a single pump draining the basin.
|
// keep-alive demand so MGC keeps a single pump draining the basin.
|
||||||
// 3. Up-curve mapping — level mapped to demand 0..100 % across
|
// 3. Up-curve mapping — level mapped to demand 0..100 % across
|
||||||
// [inflowLevel, maxLevel] using linear or log shape.
|
// [max(startLevel, inflowLevel), maxLevel] using linear or log shape.
|
||||||
|
// Foot at startLevel when startLevel > inflowLevel allows buffering
|
||||||
|
// in the upstream sewer above the gravity-feed point.
|
||||||
// 4. Shifted-ramp hysteresis — when the up-curve crosses
|
// 4. Shifted-ramp hysteresis — when the up-curve crosses
|
||||||
// shiftArmPercent the strategy ARMS; on the next filling→draining
|
// shiftArmPercent the strategy ARMS; on the next filling→draining
|
||||||
// flip it captures the up-curve value as `hold`; while draining
|
// flip it captures the up-curve value as `hold`; while draining
|
||||||
@@ -45,13 +47,21 @@ function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
|
|||||||
|
|
||||||
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
||||||
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
||||||
await Promise.all(
|
// The caller (run() below) already gated turn-off via the minLevel
|
||||||
Object.values(machineGroups).map((group) =>
|
// hard-stop, stopLevel falling-edge, and the rising-edge engagement gate.
|
||||||
group.handleInput('parent', percentControl).catch((err) => {
|
// By the time we get here, pumps should be running — `0 %` is the engaged
|
||||||
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
|
// "min flow" floor (MGC.setDemand interpolates 0 → dt.flow.min), NOT a
|
||||||
})
|
// soft turn-off. Forward unconditionally.
|
||||||
)
|
const forward = (group) => {
|
||||||
);
|
if (typeof group.setDemand !== 'function') {
|
||||||
|
logger?.error?.(`Group "${group.config?.general?.name}" missing setDemand — refusing to call handleInput with a percent value`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.resolve(group.setDemand(percentControl, '%')).catch((err) => {
|
||||||
|
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err && err.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await Promise.all(Object.values(machineGroups).map(forward));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
||||||
@@ -118,6 +128,8 @@ async function run(ctx, controlState, direction) {
|
|||||||
controlState.percControl = 0;
|
controlState.percControl = 0;
|
||||||
if (host) {
|
if (host) {
|
||||||
host._stopHystRunning = false;
|
host._stopHystRunning = false;
|
||||||
|
host._shiftArmed = false;
|
||||||
|
host._shiftHoldValue = null;
|
||||||
host._lastDirection = direction;
|
host._lastDirection = direction;
|
||||||
}
|
}
|
||||||
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||||
@@ -131,13 +143,38 @@ async function run(ctx, controlState, direction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Up-curve mapping. Foot stays at inflowLevel (the basin's
|
// 3. Engagement gate. Pumps stay OFF until level rises through startLevel
|
||||||
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
|
// for the first time (rising-edge); once engaged they stay on until
|
||||||
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
|
// level drops through stopLevel (falling-edge — handled by case 2).
|
||||||
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
|
// Without an explicit stopLevel the gate collapses to `level >= startLevel`.
|
||||||
|
// Moved out of the percentControl path so 0 % can mean "engaged at
|
||||||
|
// min flow" instead of "stopped". Disengagement also clears the
|
||||||
|
// shifted-ramp hysteresis so it doesn't survive a stop/start cycle.
|
||||||
|
const isEngaged = host ? host._stopHystRunning : (level >= startLevel);
|
||||||
|
if (!isEngaged) {
|
||||||
|
controlState.percControl = 0;
|
||||||
|
if (host) {
|
||||||
|
host._shiftArmed = false;
|
||||||
|
host._shiftHoldValue = null;
|
||||||
|
host._lastDirection = direction;
|
||||||
|
}
|
||||||
|
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Up-curve mapping. Foot = holdLevel (defaults to startLevel; operators
|
||||||
|
// can raise it to introduce a hold band [startLevel, holdLevel] where
|
||||||
|
// pumps run at min flow before the ramp begins). `inflowLevel` does NOT
|
||||||
|
// shape the curve — it's basin geometry, not a control setpoint.
|
||||||
|
// Explicit null/undefined check first so `Number(null) === 0` doesn't
|
||||||
|
// silently put the ramp foot at the basin floor.
|
||||||
|
const rawHold = cfg.holdLevel;
|
||||||
|
const holdLevel = (rawHold != null && Number.isFinite(Number(rawHold)))
|
||||||
|
? Number(rawHold) : startLevel;
|
||||||
|
const rampFoot = Math.max(startLevel, holdLevel);
|
||||||
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
|
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
|
||||||
|
|
||||||
// 4. Shifted-ramp arming.
|
// 5. Shifted-ramp arming.
|
||||||
if (host) {
|
if (host) {
|
||||||
if (cfg.enableShiftedRamp) {
|
if (cfg.enableShiftedRamp) {
|
||||||
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
||||||
@@ -177,10 +214,14 @@ async function run(ctx, controlState, direction) {
|
|||||||
let percControl;
|
let percControl;
|
||||||
if (!inDrainingHold) {
|
if (!inDrainingHold) {
|
||||||
if (level < rampFoot) {
|
if (level < rampFoot) {
|
||||||
// While engaged via stopLevel hysteresis AND inside the dead band
|
// Engaged (we passed the gate above) but below the ramp foot. Two
|
||||||
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
|
// sub-cases:
|
||||||
// single pump running.
|
// (a) Inside the configurable hold band [startLevel, holdLevel] —
|
||||||
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
|
// emit 0 %, which MGC's setDemand interpolates to flow.min.
|
||||||
|
// (b) Inside the falling-edge keep-alive band [stopLevel, startLevel]
|
||||||
|
// — emit deadZoneKeepAlivePercent (default 1 %) so MGC keeps
|
||||||
|
// at least one pump turning rather than dispatching a clean min.
|
||||||
|
if (stopThresholdActive && level < startLevel) {
|
||||||
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
|
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
|
||||||
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
||||||
percControl = Math.max(0, keepAlive);
|
percControl = Math.max(0, keepAlive);
|
||||||
@@ -212,6 +253,26 @@ async function run(ctx, controlState, direction) {
|
|||||||
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
|
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// We are past every off-gate, so the station is engaged and the computed
|
||||||
|
// demand is meant to drive pumps. If no machine group is registered the
|
||||||
|
// demand has nowhere to go and the pumps stay silent — the signature of a
|
||||||
|
// dropped Port 2 parent↔group registration (e.g. after a partial redeploy
|
||||||
|
// that recreated this node). Warn once until a group reappears so the
|
||||||
|
// failure isn't invisible.
|
||||||
|
const groupCount = machineGroups ? Object.keys(machineGroups).length : 0;
|
||||||
|
if (groupCount === 0) {
|
||||||
|
if (host && !host._warnedNoMachineGroup) {
|
||||||
|
logger?.warn?.(
|
||||||
|
`Level-based control engaged (demand ${percControl.toFixed(1)} %) but no machine group is registered — `
|
||||||
|
+ `pumps cannot be driven. The parent↔group registration was likely lost on a partial redeploy; `
|
||||||
|
+ `redeploy/restart fully to re-run the Port 2 registration handshake.`
|
||||||
|
);
|
||||||
|
host._warnedNoMachineGroup = true;
|
||||||
|
}
|
||||||
|
} else if (host) {
|
||||||
|
host._warnedNoMachineGroup = false;
|
||||||
|
}
|
||||||
|
|
||||||
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function forwardDemand(ctx, demand) {
|
async function forwardDemand(ctx, demand) {
|
||||||
const { machineGroups, machines, logger } = ctx;
|
const { machineGroups, machines, unitPolicy, logger } = ctx;
|
||||||
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
||||||
|
|
||||||
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
||||||
|
const groupDemand = unitPolicy.convert(demand, 'm3/h', 'm3/s', 'manual demand to machineGroups');
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.values(machineGroups).map((group) =>
|
Object.values(machineGroups).map((group) =>
|
||||||
group.handleInput('parent', demand).catch((err) => {
|
group.handleInput('parent', groupDemand).catch((err) => {
|
||||||
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
|
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,18 @@ async function forwardDemand(ctx, demand) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neither a group nor a direct machine is registered, so the operator's
|
||||||
|
// demand silently goes nowhere. Surface it — the usual cause is a dropped
|
||||||
|
// Port 2 parent↔child registration after a partial redeploy.
|
||||||
|
const noGroups = !machineGroups || Object.keys(machineGroups).length === 0;
|
||||||
|
const noMachines = !machines || Object.keys(machines).length === 0;
|
||||||
|
if (noGroups && noMachines) {
|
||||||
|
logger?.warn?.(
|
||||||
|
`Manual demand ${demand} not forwarded — no machine group or machine is registered to this pumping station. `
|
||||||
|
+ `Check the parent↔child Port 2 registration (redeploy/restart fully to restore it).`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
// ≤-checks below are skipped rather than false-flagged).
|
// ≤-checks below are skipped rather than false-flagged).
|
||||||
const basinHraw = fNum('basinHeight');
|
const basinHraw = fNum('basinHeight');
|
||||||
const start = fNum('startLevel');
|
const start = fNum('startLevel');
|
||||||
|
const hold = fNum('holdLevel');
|
||||||
const inlet = fNum('inflowLevel');
|
const inlet = fNum('inflowLevel');
|
||||||
const max = fNum('maxLevel');
|
const max = fNum('maxLevel');
|
||||||
const ovfl = fNum('overflowLevel');
|
const ovfl = fNum('overflowLevel');
|
||||||
@@ -154,8 +155,12 @@
|
|||||||
issues.push('outflowLevel must be > 0');
|
issues.push('outflowLevel must be > 0');
|
||||||
if (!ok(dryLvl, start, '<'))
|
if (!ok(dryLvl, start, '<'))
|
||||||
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
|
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
|
||||||
if (!ok(start, inlet, '<='))
|
if (!ok(start, max, '<'))
|
||||||
issues.push('startLevel must be ≤ inflowLevel');
|
issues.push('startLevel must be < maxLevel');
|
||||||
|
if (!ok(start, hold, '<='))
|
||||||
|
issues.push('holdLevel must be ≥ startLevel (use startLevel for no hold band)');
|
||||||
|
if (!ok(hold, max, '<'))
|
||||||
|
issues.push('holdLevel must be < maxLevel');
|
||||||
if (!ok(inlet, max, '<='))
|
if (!ok(inlet, max, '<='))
|
||||||
issues.push('inflowLevel must be ≤ maxLevel');
|
issues.push('inflowLevel must be ≤ maxLevel');
|
||||||
if (!ok(max, ovfl, '<='))
|
if (!ok(max, ovfl, '<='))
|
||||||
|
|||||||
@@ -3,8 +3,14 @@
|
|||||||
// the current values of related inputs, so the up/down arrows stop at
|
// the current values of related inputs, so the up/down arrows stop at
|
||||||
// values that respect the basin hierarchy:
|
// values that respect the basin hierarchy:
|
||||||
//
|
//
|
||||||
// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
|
// 0 < outflowLevel < dryRunLevel < startLevel < maxLevel ≤ overflowLevel ≤ basinHeight
|
||||||
// ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
|
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||||
|
//
|
||||||
|
// startLevel is intentionally NOT clamped against inflowLevel: pushing
|
||||||
|
// startLevel above the gravity-feed inlet is the "buffer in the sewer"
|
||||||
|
// configuration where upstream pipe storage absorbs flow before pumping
|
||||||
|
// engages. The level-based ramp foot is max(startLevel, inflowLevel) so
|
||||||
|
// either ordering is valid.
|
||||||
//
|
//
|
||||||
// The user can still type out-of-range values via the keyboard (HTML5
|
// The user can still type out-of-range values via the keyboard (HTML5
|
||||||
// min/max only constrain the spinner). The validation ribbons in
|
// min/max only constrain the spinner). The validation ribbons in
|
||||||
@@ -52,10 +58,10 @@
|
|||||||
|
|
||||||
setBounds('startLevel',
|
setBounds('startLevel',
|
||||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||||
inlet ?? max ?? overflow ?? basinHeight);
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
setBounds('inflowLevel',
|
setBounds('inflowLevel',
|
||||||
start ?? EPS,
|
EPS,
|
||||||
max ?? overflow ?? basinHeight);
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
setBounds('maxLevel',
|
setBounds('maxLevel',
|
||||||
@@ -73,6 +79,14 @@
|
|||||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||||
start ?? inlet ?? max ?? overflow ?? basinHeight);
|
start ?? inlet ?? max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
|
// holdLevel — 0 % ramp foot. Defaults to startLevel (no hold band);
|
||||||
|
// when raised above startLevel, pumps engage at startLevel but emit
|
||||||
|
// 0 % across [startLevel, holdLevel] before the ramp begins. Bounds:
|
||||||
|
// startLevel ≤ holdLevel < maxLevel.
|
||||||
|
setBounds('holdLevel',
|
||||||
|
Number.isFinite(start) ? start : EPS,
|
||||||
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
// Shift inputs (only relevant when shifted ramp enabled).
|
// Shift inputs (only relevant when shifted ramp enabled).
|
||||||
if (shiftEnabled) {
|
if (shiftEnabled) {
|
||||||
setBounds('shiftLevel',
|
setBounds('shiftLevel',
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
return Number.isFinite(v) ? v : null;
|
return Number.isFinite(v) ? v : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set a numeric input's value, or blank if not finite.
|
// Set a numeric input's value, or blank if not finite. Accepts numeric
|
||||||
|
// strings (Node-RED's auto-form-binding stores form values as strings).
|
||||||
ns.setNumberField = (id, val) => {
|
ns.setNumberField = (id, val) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
if (!el) return;
|
||||||
|
const num = typeof val === 'number' ? val : parseFloat(val);
|
||||||
|
el.value = Number.isFinite(num) ? num : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add input + change listeners to a list of node-input-* ids.
|
// Add input + change listeners to a list of node-input-* ids.
|
||||||
|
|||||||
@@ -23,13 +23,16 @@
|
|||||||
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
const start = fNum('startLevel');
|
const start = fNum('startLevel');
|
||||||
|
const hold = fNum('holdLevel');
|
||||||
const inlet = fNum('inflowLevel');
|
const inlet = fNum('inflowLevel');
|
||||||
const max = fNum('maxLevel');
|
const max = fNum('maxLevel');
|
||||||
// Optional stopLevel — explicit pump-off threshold. Drawn as its
|
// Optional stopLevel — explicit pump-off threshold. Drawn as its
|
||||||
// own marker line; does NOT shift the ramp foot. Must be < startLevel
|
// own marker line; does NOT shift the ramp foot. Renders as long as
|
||||||
// for the marker to render.
|
// the typed value is a non-negative number — the start-vs-stop
|
||||||
|
// ordering check belongs to the validation ribbon, not the visual
|
||||||
|
// marker (otherwise the line vanishes while the user is mid-edit).
|
||||||
const stopRaw = fNum('stopLevel');
|
const stopRaw = fNum('stopLevel');
|
||||||
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null;
|
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 ? stopRaw : null;
|
||||||
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
|
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
|
||||||
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
||||||
// we draw it as the leftmost vertical marker so the user sees
|
// we draw it as the leftmost vertical marker so the user sees
|
||||||
@@ -90,14 +93,19 @@
|
|||||||
return pts.join(' ');
|
return pts.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Up curve. Foot is startLevel (the configured pump-on threshold and
|
// Up curve. Engagement edge is startLevel (pump-on threshold); the
|
||||||
// ramp foot per the runtime in _controlLevelBased). The OFF baseline
|
// ramp foot is holdLevel, with a Math.max(startLevel, …) safety
|
||||||
// is drawn for level < startLevel; at startLevel demand jumps from
|
// floor — matching the runtime in levelBased.run.
|
||||||
// OFF to 0 % and ramps up to 100 % at maxLevel.
|
// - holdLevel == startLevel (default): no hold band, 0..100 % across
|
||||||
|
// [startLevel, maxLevel].
|
||||||
|
// - holdLevel > startLevel: pumps engaged across [startLevel,
|
||||||
|
// holdLevel] at 0 % (= MGC flow.min), then 0..100 % across
|
||||||
|
// [holdLevel, maxLevel].
|
||||||
const up = document.getElementById('ps-mode-curve-up');
|
const up = document.getElementById('ps-mode-curve-up');
|
||||||
const down = document.getElementById('ps-mode-curve-down');
|
const down = document.getElementById('ps-mode-curve-down');
|
||||||
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
||||||
if (up) up.setAttribute('points', buildPath(start, start, max));
|
const upFoot = Number.isFinite(hold) && hold > start ? hold : start;
|
||||||
|
if (up) up.setAttribute('points', buildPath(start, upFoot, max));
|
||||||
|
|
||||||
// Shifted-DOWN curve (only when shift enabled): represents the
|
// Shifted-DOWN curve (only when shift enabled): represents the
|
||||||
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
|
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
|
||||||
@@ -161,6 +169,7 @@
|
|||||||
['dryRunLevel', dryRun],
|
['dryRunLevel', dryRun],
|
||||||
['startLevel', start],
|
['startLevel', start],
|
||||||
['stopLevel', stop],
|
['stopLevel', stop],
|
||||||
|
['holdLevel', hold],
|
||||||
['inflowLevel', inlet],
|
['inflowLevel', inlet],
|
||||||
['maxLevel', max],
|
['maxLevel', max],
|
||||||
['overflowLevel', overflow],
|
['overflowLevel', overflow],
|
||||||
|
|||||||
@@ -65,6 +65,17 @@
|
|||||||
|
|
||||||
// Numeric field defaults.
|
// Numeric field defaults.
|
||||||
ns.setNumberField('node-input-startLevel', node.startLevel);
|
ns.setNumberField('node-input-startLevel', node.startLevel);
|
||||||
|
ns.setNumberField('node-input-stopLevel', node.stopLevel);
|
||||||
|
// holdLevel defaults to startLevel when omitted (no hold band). Show
|
||||||
|
// the saved value if there is one; otherwise mirror startLevel so the
|
||||||
|
// user immediately sees the "no hold band" baseline. Coerce to Number
|
||||||
|
// because Node-RED form-bind stores numeric inputs as strings.
|
||||||
|
const holdNum = parseFloat(node.holdLevel);
|
||||||
|
ns.setNumberField('node-input-holdLevel',
|
||||||
|
Number.isFinite(holdNum) ? holdNum : node.startLevel);
|
||||||
|
const deadZoneNum = parseFloat(node.deadZoneKeepAlivePercent);
|
||||||
|
ns.setNumberField('node-input-deadZoneKeepAlivePercent',
|
||||||
|
Number.isFinite(deadZoneNum) ? deadZoneNum : 1);
|
||||||
ns.setNumberField('node-input-maxLevel', node.maxLevel);
|
ns.setNumberField('node-input-maxLevel', node.maxLevel);
|
||||||
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
|
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
|
||||||
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
||||||
@@ -77,16 +88,22 @@
|
|||||||
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
||||||
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
||||||
|
|
||||||
// Bind redraws to the inputs each diagram cares about.
|
// Bind redraws to the inputs each diagram cares about. The basin
|
||||||
|
// diagram itself only paints inflow/outflow/overflow lines, but its
|
||||||
|
// validation ribbon also enforces startLevel/holdLevel/maxLevel
|
||||||
|
// ordering — so it has to refire when any of those change too, or
|
||||||
|
// the "Fix before deploy" ribbon goes stale mid-edit.
|
||||||
ns.bindRedraw(
|
ns.bindRedraw(
|
||||||
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
|
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
|
||||||
|
'startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
|
||||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
|
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
|
||||||
ns.basinDiagram.redraw
|
ns.basinDiagram.redraw
|
||||||
);
|
);
|
||||||
ns.bindRedraw(
|
ns.bindRedraw(
|
||||||
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
|
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
|
||||||
// so the mode preview must redraw when either of those change.
|
// so the mode preview must redraw when either of those change.
|
||||||
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
['startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
|
||||||
|
'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||||
'dryRunThresholdPercent',
|
'dryRunThresholdPercent',
|
||||||
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
||||||
'shiftArmPercent'],
|
'shiftArmPercent'],
|
||||||
@@ -97,7 +114,7 @@
|
|||||||
// so the next redraw + validation sees the correct min/max attrs.
|
// so the next redraw + validation sees the correct min/max attrs.
|
||||||
ns.bindRedraw(
|
ns.bindRedraw(
|
||||||
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
||||||
'inflowLevel', 'startLevel', 'outflowLevel',
|
'inflowLevel', 'startLevel', 'stopLevel', 'holdLevel', 'outflowLevel',
|
||||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||||
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
||||||
() => ns.bounds?.apply()
|
() => ns.bounds?.apply()
|
||||||
|
|||||||
@@ -50,6 +50,15 @@
|
|||||||
node.logCurveFactor = parseNum('node-input-logCurveFactor');
|
node.logCurveFactor = parseNum('node-input-logCurveFactor');
|
||||||
node.startLevel = parseNum('node-input-startLevel');
|
node.startLevel = parseNum('node-input-startLevel');
|
||||||
node.maxLevel = parseNum('node-input-maxLevel');
|
node.maxLevel = parseNum('node-input-maxLevel');
|
||||||
|
// Persist as numbers — Node-RED's auto-form-binding would store these as
|
||||||
|
// strings, and oneditprepare's setNumberField rejects non-Number values,
|
||||||
|
// so the input would blank out on reopen.
|
||||||
|
const stopLevelVal = parseNum('node-input-stopLevel');
|
||||||
|
node.stopLevel = Number.isFinite(stopLevelVal) ? stopLevelVal : null;
|
||||||
|
const holdLevelVal = parseNum('node-input-holdLevel');
|
||||||
|
if (Number.isFinite(holdLevelVal)) node.holdLevel = holdLevelVal;
|
||||||
|
const deadZoneVal = parseNum('node-input-deadZoneKeepAlivePercent');
|
||||||
|
if (Number.isFinite(deadZoneVal)) node.deadZoneKeepAlivePercent = deadZoneVal;
|
||||||
// minLevel is no longer a user input — it's the derived dryRunLevel
|
// minLevel is no longer a user input — it's the derived dryRunLevel
|
||||||
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
|
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
|
||||||
// uses node.minLevel as the unconditional STOP threshold; we set it
|
// uses node.minLevel as the unconditional STOP threshold; we set it
|
||||||
|
|||||||
@@ -57,6 +57,32 @@ class FlowAggregator {
|
|||||||
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pick the best-available variant for one side of the basin balance.
|
||||||
|
// Mirrors selectBestNetFlow's variant precedence (measured first, then
|
||||||
|
// predicted) but resolves each side independently — so a real measured
|
||||||
|
// upstream sensor + a predicted pump outflow both feed the integrator.
|
||||||
|
// Returns the summed flow at the requested positions. The first variant
|
||||||
|
// that has any registered measurement at one of those positions wins,
|
||||||
|
// even if its sum is 0 (a sensor that reads 0 is still data).
|
||||||
|
_pickFlowSum(positions, flowUnit = 'm3/s') {
|
||||||
|
const buckets = this.measurements.measurements?.flow;
|
||||||
|
if (!buckets) return { sum: 0, variant: null };
|
||||||
|
for (const variant of this.flowVariants) {
|
||||||
|
const variantBucket = buckets[variant];
|
||||||
|
if (!variantBucket) continue;
|
||||||
|
const hasAny = positions.some((pos) => {
|
||||||
|
const posBucket = variantBucket[pos];
|
||||||
|
return posBucket && Object.keys(posBucket).length > 0;
|
||||||
|
});
|
||||||
|
if (!hasAny) continue;
|
||||||
|
return {
|
||||||
|
sum: this.measurements.sum('flow', variant, positions, flowUnit) || 0,
|
||||||
|
variant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { sum: 0, variant: null };
|
||||||
|
}
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
const flowUnit = 'm3/s';
|
const flowUnit = 'm3/s';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -64,8 +90,13 @@ class FlowAggregator {
|
|||||||
// Synthetic spill flow lives at its OWN position ('overflow') —
|
// Synthetic spill flow lives at its OWN position ('overflow') —
|
||||||
// not as a child of 'out'. That keeps it out of the operational
|
// not as a child of 'out'. That keeps it out of the operational
|
||||||
// outflow sum here so no self-subtraction is needed.
|
// outflow sum here so no self-subtraction is needed.
|
||||||
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
// Inflow + outflow are resolved per-side: a real measured upstream
|
||||||
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
// sensor (variant=measured) + a predicted pump-curve outflow
|
||||||
|
// (variant=predicted) is the common realistic mix.
|
||||||
|
const inflowPick = this._pickFlowSum(this.flowPositions.inflow, flowUnit);
|
||||||
|
const outflowPick = this._pickFlowSum(this.flowPositions.outflow, flowUnit);
|
||||||
|
const inflow = inflowPick.sum;
|
||||||
|
const outflowReal = outflowPick.sum;
|
||||||
|
|
||||||
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class MeasurementRouter {
|
|||||||
|
|
||||||
onLevelMeasurement(position, value, context = {}) {
|
onLevelMeasurement(position, value, context = {}) {
|
||||||
this.measurements.type('level').variant('measured').position(position)
|
this.measurements.type('level').variant('measured').position(position)
|
||||||
.value(value).unit(context.unit);
|
.value(value, context.timestamp, context.unit);
|
||||||
|
|
||||||
const series = this.measurements.type('level').variant('measured').position(position);
|
const series = this.measurements.type('level').variant('measured').position(position);
|
||||||
const levelMeters = series.getCurrentValue('m');
|
const levelMeters = series.getCurrentValue('m');
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class nodeClass extends BaseNodeAdapter {
|
|||||||
minLevel: uiConfig.minLevel,
|
minLevel: uiConfig.minLevel,
|
||||||
startLevel: uiConfig.startLevel,
|
startLevel: uiConfig.startLevel,
|
||||||
stopLevel: uiConfig.stopLevel,
|
stopLevel: uiConfig.stopLevel,
|
||||||
|
holdLevel: uiConfig.holdLevel,
|
||||||
maxLevel: uiConfig.maxLevel,
|
maxLevel: uiConfig.maxLevel,
|
||||||
// Editor names the field levelCurveType; runtime uses curveType.
|
// Editor names the field levelCurveType; runtime uses curveType.
|
||||||
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
||||||
@@ -44,6 +45,7 @@ class nodeClass extends BaseNodeAdapter {
|
|||||||
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
||||||
shiftLevel: uiConfig.shiftLevel,
|
shiftLevel: uiConfig.shiftLevel,
|
||||||
shiftArmPercent: uiConfig.shiftArmPercent,
|
shiftArmPercent: uiConfig.shiftArmPercent,
|
||||||
|
deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
safety: {
|
safety: {
|
||||||
|
|||||||
@@ -18,15 +18,21 @@ class PumpingStation extends BaseDomain {
|
|||||||
static name = 'pumpingStation';
|
static name = 'pumpingStation';
|
||||||
|
|
||||||
// Internal math runs in m3/s for flow and m for level so the volume
|
// Internal math runs in m3/s for flow and m for level so the volume
|
||||||
// integrator (flow × dt) is unit-consistent. Strict canonicals make
|
// integrator (flow × dt) is unit-consistent — canonical stays m3/s, the
|
||||||
// unit drift in child-fed measurements an explicit error.
|
// platform-wide convention every cross-node consumer (MGC demand math,
|
||||||
|
// physics-sanity) assumes. Strict canonicals make unit drift in child-fed
|
||||||
|
// measurements an explicit error.
|
||||||
|
// Output flow / netFlowRate are emitted in m3/h so telemetry/dashboard
|
||||||
|
// series land on the same axis as the rest of the pump group (verified
|
||||||
|
// slice #47); the m3/s→m3/h presentation conversion happens at the output
|
||||||
|
// boundary only — it never touches the canonical integrator basis.
|
||||||
// overflowVolume / underflowVolume are listed in output so the
|
// overflowVolume / underflowVolume are listed in output so the
|
||||||
// MeasurementContainer keeps the integrator's m³ unit on those streams
|
// MeasurementContainer keeps the integrator's m³ unit on those streams
|
||||||
// (FlowAggregator writes spill / underflow per tick).
|
// (FlowAggregator writes spill / underflow per tick).
|
||||||
static unitPolicy = UnitPolicy.declare({
|
static unitPolicy = UnitPolicy.declare({
|
||||||
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||||
output: {
|
output: {
|
||||||
flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3',
|
flow: 'm3/h', netFlowRate: 'm3/h', level: 'm', volume: 'm3',
|
||||||
overflowVolume: 'm3', underflowVolume: 'm3',
|
overflowVolume: 'm3', underflowVolume: 'm3',
|
||||||
},
|
},
|
||||||
requireUnitForTypes: [],
|
requireUnitForTypes: [],
|
||||||
@@ -44,6 +50,12 @@ class PumpingStation extends BaseDomain {
|
|||||||
this.controlState = { percControl: 0 };
|
this.controlState = { percControl: 0 };
|
||||||
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
|
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
|
||||||
|
|
||||||
|
// Last operator demand from set.demand in manual mode. Stored on the
|
||||||
|
// host so getOutput()/status reflect it even when no children are
|
||||||
|
// registered yet (otherwise forwardDemand is invisible on Port 0/1).
|
||||||
|
// Cleared on mode change away from manual.
|
||||||
|
this._manualDemand = null;
|
||||||
|
|
||||||
// Level-armed hysteresis state — ported from basin-docs `_controlLevelBased`.
|
// Level-armed hysteresis state — ported from basin-docs `_controlLevelBased`.
|
||||||
// Exposed as instance fields because the e2e/basic tests assert on them
|
// Exposed as instance fields because the e2e/basic tests assert on them
|
||||||
// directly. levelBased strategy reads/writes via the same names.
|
// directly. levelBased strategy reads/writes via the same names.
|
||||||
@@ -140,6 +152,7 @@ class PumpingStation extends BaseDomain {
|
|||||||
levelVariants: this.levelVariants,
|
levelVariants: this.levelVariants,
|
||||||
volVariants: this.volVariants,
|
volVariants: this.volVariants,
|
||||||
flowThreshold: this.flowThreshold,
|
flowThreshold: this.flowThreshold,
|
||||||
|
unitPolicy: this.unitPolicy,
|
||||||
host: this,
|
host: this,
|
||||||
};
|
};
|
||||||
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
|
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
|
||||||
@@ -172,6 +185,8 @@ class PumpingStation extends BaseDomain {
|
|||||||
if (this.config.control.allowedModes?.has?.(newMode)) {
|
if (this.config.control.allowedModes?.has?.(newMode)) {
|
||||||
this.logger.info(`Control mode changing from ${this.mode} to ${newMode}`);
|
this.logger.info(`Control mode changing from ${this.mode} to ${newMode}`);
|
||||||
this.mode = newMode;
|
this.mode = newMode;
|
||||||
|
if (newMode !== 'manual') this._manualDemand = null;
|
||||||
|
this.notifyOutputChanged();
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`);
|
this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`);
|
||||||
}
|
}
|
||||||
@@ -183,7 +198,11 @@ class PumpingStation extends BaseDomain {
|
|||||||
setManualInflow(value, ts = Date.now(), unit) { calibration.setManualInflow(this, value, ts, unit); }
|
setManualInflow(value, ts = Date.now(), unit) { calibration.setManualInflow(this, value, ts, unit); }
|
||||||
setManualOutflow(value, ts = Date.now(), unit) { calibration.setManualOutflow(this, value, ts, unit); }
|
setManualOutflow(value, ts = Date.now(), unit) { calibration.setManualOutflow(this, value, ts, unit); }
|
||||||
|
|
||||||
forwardDemandToChildren(demand) { return control.manual.forwardDemand(this.context(), demand); }
|
forwardDemandToChildren(demand) {
|
||||||
|
this._manualDemand = Number.isFinite(demand) ? demand : null;
|
||||||
|
this.notifyOutputChanged();
|
||||||
|
return control.manual.forwardDemand(this.context(), demand);
|
||||||
|
}
|
||||||
|
|
||||||
// Direct delegations preserved so existing tests can drive the strategy
|
// Direct delegations preserved so existing tests can drive the strategy
|
||||||
// without re-mocking the dispatch layer.
|
// without re-mocking the dispatch layer.
|
||||||
@@ -220,6 +239,8 @@ class PumpingStation extends BaseDomain {
|
|||||||
out.flowSource = this.state.flowSource;
|
out.flowSource = this.state.flowSource;
|
||||||
out.timeleft = this.state.seconds;
|
out.timeleft = this.state.seconds;
|
||||||
out.percControl = this.controlState.percControl;
|
out.percControl = this.controlState.percControl;
|
||||||
|
out.mode = this.mode;
|
||||||
|
out.manualDemand = this._manualDemand;
|
||||||
|
|
||||||
// Derived safety thresholds — exposed so editor + dashboards can show
|
// Derived safety thresholds — exposed so editor + dashboards can show
|
||||||
// the dryRunLevel and highVolumeSafetyLevel without recomputing.
|
// the dryRunLevel and highVolumeSafetyLevel without recomputing.
|
||||||
@@ -247,15 +268,14 @@ class PumpingStation extends BaseDomain {
|
|||||||
steady: { arrow: '⏸️', fill: 'green' },
|
steady: { arrow: '⏸️', fill: 'green' },
|
||||||
};
|
};
|
||||||
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
|
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
|
||||||
const vol = this.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
|
||||||
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
|
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
|
||||||
const maxVol = this.basin?.maxVolAtOverflow ?? 0;
|
const netFlowM3h = this.unitPolicy.convert(this.state?.netFlow ?? 0, 'm3/s', 'm3/h', 'status badge netFlow');
|
||||||
const netFlowM3h = (this.state?.netFlow ?? 0) * 3600;
|
const mode = this.mode || '?';
|
||||||
const seconds = this.state?.seconds;
|
const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
|
||||||
const tStr = seconds != null ? `t≈${Math.round(seconds / 60)} min` : null;
|
? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
|
||||||
|
|
||||||
return statusBadge.compose(
|
return statusBadge.compose(
|
||||||
[`${arrow} ${pct.toFixed(1)}%`, `V=${vol.toFixed(2)} / ${maxVol.toFixed(2)} m³`, `net: ${netFlowM3h.toFixed(0)} m³/h`, tStr],
|
[mode, `${arrow} ${pct.toFixed(1)}%`, `net: ${netFlowM3h.toFixed(0)} m³/h`, manualPart],
|
||||||
{ fill, shape: 'dot' }
|
{ fill, shape: 'dot' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,14 +292,32 @@ class PumpingStation extends BaseDomain {
|
|||||||
const measurementType = child.config.asset.type;
|
const measurementType = child.config.asset.type;
|
||||||
const eventName = `${measurementType}.measured.${position}`;
|
const eventName = `${measurementType}.measured.${position}`;
|
||||||
|
|
||||||
child.measurements.emitter.on(eventName, (eventData = {}) => {
|
const handle = (eventData = {}) => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
|
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
|
||||||
);
|
);
|
||||||
|
if (measurementType === 'level') {
|
||||||
|
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.measurements.type(measurementType).variant('measured').position(position)
|
this.measurements.type(measurementType).variant('measured').position(position)
|
||||||
.value(eventData.value, eventData.timestamp, eventData.unit);
|
.value(eventData.value, eventData.timestamp, eventData.unit);
|
||||||
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
|
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
child.measurements.emitter.on(eventName, handle);
|
||||||
|
|
||||||
|
// Seed from the child's current value. The emitter only delivers FUTURE
|
||||||
|
// updates, so a parent that registers after the child already emitted
|
||||||
|
// (e.g. a once-only inject that fired during startup before this
|
||||||
|
// subscription existed) would otherwise never see that value. Replaying
|
||||||
|
// the last sample makes a late subscriber pick up the present state.
|
||||||
|
const series = child.measurements
|
||||||
|
.type(measurementType).variant('measured').position(position).get?.();
|
||||||
|
const sample = series?.getLaggedSample?.(0);
|
||||||
|
if (sample && sample.value != null) {
|
||||||
|
handle({ ...sample, childName: child.config.general.name });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_subscribePredictedFlow(child) {
|
_subscribePredictedFlow(child) {
|
||||||
|
|||||||
85
test/basic/_probe_upstream_emit.test.js
Normal file
85
test/basic/_probe_upstream_emit.test.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Throwaway probe — exercises the exact path:
|
||||||
|
// measurement child writes flow.measured.upstream → pumpingStation parent
|
||||||
|
// subscribes → getOutput() (≡ what Port 0 emits).
|
||||||
|
// Run with: node --test test/basic/_probe_upstream_emit.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
const { MeasurementContainer, configManager } = require('generalFunctions');
|
||||||
|
const EventEmitter = require('node:events');
|
||||||
|
|
||||||
|
// Minimal PumpingStation config — matches the editor defaults shape.
|
||||||
|
function makePsConfig() {
|
||||||
|
const ui = {
|
||||||
|
name: 'PS', basinVolume: 50, basinHeight: 5,
|
||||||
|
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||||
|
minHeightBasedOn: 'outlet',
|
||||||
|
controlMode: 'levelbased',
|
||||||
|
minLevel: 1, startLevel: 2, maxLevel: 4,
|
||||||
|
levelCurveType: 'linear',
|
||||||
|
processOutputFormat: 'process', dbaseOutputFormat: 'influxdb',
|
||||||
|
};
|
||||||
|
const cm = new configManager();
|
||||||
|
// Use the same buildConfig pipeline the runtime uses.
|
||||||
|
return cm.buildConfig('pumpingStation', ui, 'ps-probe', {
|
||||||
|
basin: {
|
||||||
|
volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||||
|
},
|
||||||
|
hydraulics: { minHeightBasedOn: 'outlet' },
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
|
||||||
|
},
|
||||||
|
safety: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake measurement child that looks exactly like the real one to the router:
|
||||||
|
// - softwareType 'measurement'
|
||||||
|
// - config.asset.type = 'flow'
|
||||||
|
// - config.functionality.positionVsParent = 'upstream'
|
||||||
|
// - .measurements is a real MeasurementContainer with a real emitter
|
||||||
|
function makeMeasurementChild(id = 'meas-probe') {
|
||||||
|
const measurements = new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { flow: 'm3/s' },
|
||||||
|
});
|
||||||
|
// Real container ships an emitter; sanity check.
|
||||||
|
assert.ok(measurements.emitter instanceof EventEmitter || typeof measurements.emitter?.on === 'function');
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source: {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
|
||||||
|
asset: { type: 'flow' },
|
||||||
|
},
|
||||||
|
measurements,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('PROBE: measurement child writes flow.measured.upstream — parent surfaces it on getOutput()', () => {
|
||||||
|
const ps = new PumpingStation(makePsConfig());
|
||||||
|
const child = makeMeasurementChild();
|
||||||
|
|
||||||
|
// Register the child the same way the runtime does.
|
||||||
|
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
|
||||||
|
|
||||||
|
// Drive a value through the child's MeasurementContainer the way Channel
|
||||||
|
// does — type/variant/position chain then .value().
|
||||||
|
child.source.measurements
|
||||||
|
.type('flow').variant('measured').position('upstream')
|
||||||
|
.value(12, Date.now(), 'm3/h'); // 12 m³/h ≈ 0.00333 m³/s
|
||||||
|
|
||||||
|
const out = ps.getOutput();
|
||||||
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||||
|
console.log('flow.measured.upstream.* keys in Port 0 payload:', upstreamKeys);
|
||||||
|
for (const k of upstreamKeys) console.log(` ${k} = ${out[k]}`);
|
||||||
|
|
||||||
|
// The contract: the parent should surface the upstream measurement.
|
||||||
|
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* on Port 0');
|
||||||
|
});
|
||||||
@@ -24,9 +24,10 @@ function makeMeasurements(levelMeters) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeGroup(name) {
|
function makeGroup(name) {
|
||||||
const calls = { handleInput: [], turnOff: 0 };
|
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||||
return {
|
return {
|
||||||
config: { general: { name } },
|
config: { general: { name } },
|
||||||
|
setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); },
|
||||||
handleInput: async (...args) => { calls.handleInput.push(args); },
|
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||||
turnOffAllMachines: () => { calls.turnOff += 1; },
|
turnOffAllMachines: () => { calls.turnOff += 1; },
|
||||||
_calls: calls,
|
_calls: calls,
|
||||||
@@ -59,31 +60,38 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
|
|||||||
assert.equal(state.percControl, 0);
|
assert.equal(state.percControl, 0);
|
||||||
for (const g of Object.values(ctx.machineGroups)) {
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
|
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
|
||||||
assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone');
|
assert.equal(g._calls.setDemand.length, 0, 'no demand sent in stop zone');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// basin-docs behavior: between minLevel and the active ramp foot, demand
|
// Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge
|
||||||
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
|
// hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so
|
||||||
// only the explicit minLevel hard-stop path skips handleInput.
|
// MGC doesn't kick a pump on at flow.min before the gate is ever passed.
|
||||||
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
|
test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => {
|
||||||
const ctx = makeCtx(1.5);
|
const ctx = makeCtx(1.5);
|
||||||
const state = { percControl: 17 };
|
const state = { percControl: 17 };
|
||||||
await levelBased.run(ctx, state);
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone');
|
assert.equal(state.percControl, 0, 'percControl held at 0 before engagement');
|
||||||
for (const g of Object.values(ctx.machineGroups)) {
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
assert.equal(g._calls.turnOff, 0);
|
assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff');
|
||||||
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
|
assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement');
|
||||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
|
test('level == startLevel → percControl == 0 dispatched as setDemand (0 % = min flow, NOT off)', async () => {
|
||||||
const ctx = makeCtx(2);
|
const ctx = makeCtx(2);
|
||||||
const state = { percControl: null };
|
const state = { percControl: null };
|
||||||
await levelBased.run(ctx, state);
|
await levelBased.run(ctx, state);
|
||||||
assert.equal(state.percControl, 0);
|
assert.equal(state.percControl, 0);
|
||||||
|
// Critical: at startLevel pumps are engaged at min flow, NOT turned off.
|
||||||
|
// The bug we're fixing: the previous soft-turnOff at pct≤0 stopped pumps
|
||||||
|
// at this boundary even though the hysteresis was armed.
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0, 'do not turnOff at startLevel');
|
||||||
|
assert.equal(g._calls.setDemand.length, 1, 'forward 0 % to MGC');
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
|
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
|
||||||
@@ -101,19 +109,65 @@ test('level above maxLevel → percControl clamped at 100 (interpolation limit_i
|
|||||||
assert.equal(state.percControl, 100);
|
assert.equal(state.percControl, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
|
test('percControl forwarded to every group via setDemand(pct, "%")', async () => {
|
||||||
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
|
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
|
||||||
const state = { percControl: null };
|
const state = { percControl: null };
|
||||||
await levelBased.run(ctx, state);
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
assert.equal(state.percControl, 50);
|
assert.equal(state.percControl, 50);
|
||||||
for (const g of Object.values(ctx.machineGroups)) {
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
|
assert.equal(g._calls.setDemand.length, 1, 'one forward per group');
|
||||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
assert.deepEqual(g._calls.setDemand[0], [50, '%']);
|
||||||
|
assert.equal(g._calls.handleInput.length, 0, 'no raw handleInput — % goes through setDemand');
|
||||||
assert.equal(g._calls.turnOff, 0);
|
assert.equal(g._calls.turnOff, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('inflowLevel does NOT shape the curve — ramp foot = startLevel regardless', async () => {
|
||||||
|
// startLevel=2, inflowLevel=3, maxLevel=4. Level=2.5 sits between
|
||||||
|
// startLevel and inflowLevel. Pre-fix this was a 0 % "hold zone"; now
|
||||||
|
// the ramp is anchored at startLevel so level=2.5 → 25 %.
|
||||||
|
const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 } });
|
||||||
|
ctx.basin = { inflowLevel: 3 };
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.ok(Math.abs(state.percControl - 25) < 1e-9,
|
||||||
|
`expected ~25 % (ramp foot at startLevel, NOT inflowLevel); got ${state.percControl}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holdLevel > startLevel opts into a hold band [startLevel, holdLevel] at 0 %', async () => {
|
||||||
|
// Same geometry but operator raises holdLevel to 3 so the ramp's 0 %
|
||||||
|
// foot moves up. Level=2.5 should now sit in the hold band: pumps are
|
||||||
|
// engaged but emit 0 % (= MGC's flow.min, NOT turn-off).
|
||||||
|
const ctx = makeCtx(2.5, {
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4 },
|
||||||
|
});
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 0, '0 % in the configurable hold band');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0, 'engaged — must not turnOff in hold band');
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falling-edge keep-alive [stopLevel, startLevel] keeps pumps spinning', async () => {
|
||||||
|
// stopLevel = 0.5, startLevel = 2. Once armed (level ≥ startLevel), the
|
||||||
|
// band [0.5, 2) stays engaged at deadZoneKeepAlivePercent (default 1 %).
|
||||||
|
const ctx = makeCtx(1.5, {
|
||||||
|
levelbased: { minLevel: 0.1, startLevel: 2, stopLevel: 0.5, maxLevel: 4 },
|
||||||
|
});
|
||||||
|
// Pre-arm: simulate that level previously crossed startLevel.
|
||||||
|
ctx.host = { _stopHystRunning: true };
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 1, 'keep-alive emits 1 % in the [stop, start) band');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [1, '%']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
|
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
|
||||||
const ctx = makeCtx(NaN);
|
const ctx = makeCtx(NaN);
|
||||||
let warned = false;
|
let warned = false;
|
||||||
@@ -128,3 +182,51 @@ test('no valid level → warns and returns without mutating percControl or calli
|
|||||||
assert.equal(g._calls.handleInput.length, 0);
|
assert.equal(g._calls.handleInput.length, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: a station engaged above startLevel but with no machine group
|
||||||
|
// registered (e.g. the Port 2 parent↔group registration was dropped by a
|
||||||
|
// partial redeploy) computes a real demand that goes nowhere. The strategy
|
||||||
|
// must surface this once, not fail silently. See the 2026-05-27 "PS not
|
||||||
|
// reacting to level" trace.
|
||||||
|
test('engaged with NO machine group registered → warns once (throttled via host)', async () => {
|
||||||
|
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); // level 3 > startLevel 2 → engaged
|
||||||
|
ctx.machineGroups = {}; // registration lost
|
||||||
|
ctx.host = {};
|
||||||
|
const warns = [];
|
||||||
|
ctx.logger.warn = (m) => warns.push(m);
|
||||||
|
|
||||||
|
const state = { percControl: 0 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.ok(state.percControl > 0, 'demand is computed even though there is no group');
|
||||||
|
assert.equal(warns.length, 1, 'warns exactly once');
|
||||||
|
assert.match(warns[0], /no machine group is registered/i);
|
||||||
|
assert.equal(ctx.host._warnedNoMachineGroup, true);
|
||||||
|
|
||||||
|
// Subsequent ticks while still group-less stay quiet (no log spam).
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1, 'throttled: no repeat warning on the next tick');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warning re-arms after a group reappears then disappears again', async () => {
|
||||||
|
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } });
|
||||||
|
ctx.host = {};
|
||||||
|
const warns = [];
|
||||||
|
ctx.logger.warn = (m) => warns.push(m);
|
||||||
|
const state = { percControl: 0 };
|
||||||
|
|
||||||
|
ctx.machineGroups = {};
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1);
|
||||||
|
|
||||||
|
// Group registers again → flag clears, no new warning.
|
||||||
|
ctx.machineGroups = { a: makeGroup('A') };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1);
|
||||||
|
assert.equal(ctx.host._warnedNoMachineGroup, false);
|
||||||
|
|
||||||
|
// Group lost again → warns once more.
|
||||||
|
ctx.machineGroups = {};
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 2, 're-armed after recovery');
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { UnitPolicy } = require('generalFunctions');
|
||||||
const manual = require('../../src/control/manual');
|
const manual = require('../../src/control/manual');
|
||||||
|
|
||||||
|
const unitPolicy = UnitPolicy.declare({
|
||||||
|
canonical: { flow: 'm3/s' },
|
||||||
|
output: { flow: 'm3/s' },
|
||||||
|
requireUnitForTypes: [],
|
||||||
|
});
|
||||||
|
|
||||||
function makeGroup(name) {
|
function makeGroup(name) {
|
||||||
const calls = { handleInput: [] };
|
const calls = { handleInput: [] };
|
||||||
return {
|
return {
|
||||||
@@ -28,15 +35,15 @@ function makeLogger() {
|
|||||||
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('forwardDemand calls handleInput("parent", demand) on every machine group', async () => {
|
test('forwardDemand calls handleInput("parent", canonical m3/s demand) on every machine group', async () => {
|
||||||
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
|
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
|
||||||
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
|
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
|
||||||
|
|
||||||
await manual.forwardDemand(ctx, 50);
|
await manual.forwardDemand(ctx, 360);
|
||||||
|
|
||||||
for (const g of Object.values(groups)) {
|
for (const g of Object.values(groups)) {
|
||||||
assert.equal(g._calls.handleInput.length, 1);
|
assert.equal(g._calls.handleInput.length, 1);
|
||||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
assert.deepEqual(g._calls.handleInput[0], ['parent', 0.1]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ test('forwardDemand with no machineGroups but direct machines splits demand even
|
|||||||
|
|
||||||
test('run() is a no-op (manual mode is event-driven)', async () => {
|
test('run() is a no-op (manual mode is event-driven)', async () => {
|
||||||
const groups = { a: makeGroup('A') };
|
const groups = { a: makeGroup('A') };
|
||||||
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
|
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
|
||||||
await manual.run(ctx, { percControl: 0 });
|
await manual.run(ctx, { percControl: 0 });
|
||||||
assert.equal(groups.a._calls.handleInput.length, 0);
|
assert.equal(groups.a._calls.handleInput.length, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,48 @@ test('FlowAggregator.update integrates inflow-outflow over delta-t', async () =>
|
|||||||
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
|
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.update integrates measured inflow when predicted side is empty', async () => {
|
||||||
|
// Regression: a real upstream sensor writes `flow.measured.upstream.<id>`
|
||||||
|
// (the measurement node hard-codes variant='measured'), but the integrator
|
||||||
|
// used to read variant='predicted' only — so level stayed flat while the
|
||||||
|
// status row reported +N m³/h. The fix mirrors selectBestNetFlow's
|
||||||
|
// variant precedence per side.
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
const t0 = Date.now() - 10_000;
|
||||||
|
// Measured inflow at 'upstream' (one of the inflow position aliases),
|
||||||
|
// no outflow side at all.
|
||||||
|
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
|
||||||
|
.value(0.01, t0, 'm3/s');
|
||||||
|
|
||||||
|
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
fa.update();
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
// Expect minVol(2) + 0.01 × ~10 ≈ 2.10 m3.
|
||||||
|
assert.ok(vol > 2.09 && vol < 2.11, `measured inflow did not integrate: vol=${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.update mixes measured inflow with predicted outflow', async () => {
|
||||||
|
// Realistic mix: real upstream sensor (measured) + pump-curve outflow
|
||||||
|
// (predicted). The picker resolves each side independently, so the net
|
||||||
|
// balance uses both.
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
const t0 = Date.now() - 10_000;
|
||||||
|
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
|
||||||
|
.value(0.01, t0, 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('downstream').child('pump-A')
|
||||||
|
.value(0.004, t0, 'm3/s');
|
||||||
|
|
||||||
|
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
fa.update();
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
// minVol(2) + (0.01 - 0.004) × ~10 ≈ 2.06 m3.
|
||||||
|
assert.ok(vol > 2.05 && vol < 2.07, `mixed-variant integration produced vol=${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
|
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
|
||||||
const { fa, measurements } = makeAggregator();
|
const { fa, measurements } = makeAggregator();
|
||||||
measurements.type('flow').variant('measured').position('in').child('m')
|
measurements.type('flow').variant('measured').position('in').child('m')
|
||||||
|
|||||||
81
test/basic/replay-on-subscribe.basic.test.js
Normal file
81
test/basic/replay-on-subscribe.basic.test.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Late-subscriber replay: a measurement child that already holds a value when
|
||||||
|
// the pumpingStation registers it (e.g. a once-only inject that fired during
|
||||||
|
// startup before the parent subscribed) must still surface on Port 0. The
|
||||||
|
// emitter only delivers future updates, so _subscribeMeasurement seeds from the
|
||||||
|
// child's current sample.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const EventEmitter = require('node:events');
|
||||||
|
|
||||||
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
const { MeasurementContainer, configManager } = require('generalFunctions');
|
||||||
|
|
||||||
|
function makePsConfig() {
|
||||||
|
const cm = new configManager();
|
||||||
|
return cm.buildConfig('pumpingStation', { name: 'PS' }, 'ps-replay', {
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||||
|
hydraulics: { minHeightBasedOn: 'outlet' },
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
|
||||||
|
},
|
||||||
|
safety: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFlowMeasurementChild(id = 'meas-replay') {
|
||||||
|
const measurements = new MeasurementContainer({ autoConvert: true, preferredUnits: { flow: 'm3/s' } });
|
||||||
|
assert.ok(typeof measurements.emitter?.on === 'function');
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source: {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
|
||||||
|
asset: { type: 'flow' },
|
||||||
|
},
|
||||||
|
measurements,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('value written BEFORE registration is replayed on subscribe (once-inject timing)', () => {
|
||||||
|
const ps = new PumpingStation(makePsConfig());
|
||||||
|
const child = makeFlowMeasurementChild();
|
||||||
|
|
||||||
|
// Child already holds a value — emitted into the void before the parent existed.
|
||||||
|
child.source.measurements
|
||||||
|
.type('flow').variant('measured').position('upstream')
|
||||||
|
.value(50, Date.now(), 'm3/h');
|
||||||
|
|
||||||
|
// Parent registers AFTER the value is present. Without replay it would only
|
||||||
|
// catch future emits and surface nothing.
|
||||||
|
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
|
||||||
|
|
||||||
|
const out = ps.getOutput();
|
||||||
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||||
|
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* after late subscribe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no stored value → nothing replayed, no crash', () => {
|
||||||
|
const ps = new PumpingStation(makePsConfig());
|
||||||
|
const child = makeFlowMeasurementChild('empty-child');
|
||||||
|
// Register with an empty child container; replay must be a safe no-op.
|
||||||
|
assert.doesNotThrow(() => ps.childRegistrationUtils.registerChild(child.source, 'upstream'));
|
||||||
|
const out = ps.getOutput();
|
||||||
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||||
|
assert.equal(upstreamKeys.length, 0, 'no upstream key when child has no value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('future emits still delivered after subscribe (listener intact)', () => {
|
||||||
|
const ps = new PumpingStation(makePsConfig());
|
||||||
|
const child = makeFlowMeasurementChild('streaming-child');
|
||||||
|
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
|
||||||
|
// Emit AFTER registration — the normal streaming-sensor path.
|
||||||
|
child.source.measurements.type('flow').variant('measured').position('upstream').value(30, Date.now(), 'm3/h');
|
||||||
|
const out = ps.getOutput();
|
||||||
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||||
|
assert.ok(upstreamKeys.length > 0, 'normal post-subscribe emit still surfaces');
|
||||||
|
});
|
||||||
@@ -4,13 +4,14 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
const PumpingStation = require('../../src/specificClass');
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
|
||||||
// machineGroups is a registry-backed getter (declareChildGetter) — direct
|
// machineGroups is a registry-backed getter (declareChildGetter) — direct
|
||||||
// assignment is no longer possible. Tests inject mock groups through the
|
// assignment is no longer possible. Tests inject mock groups through the
|
||||||
// real registration handshake so the registry remains the source of truth.
|
// real registration handshake so the registry remains the source of truth.
|
||||||
function registerMockGroup(ps, id, behavior = {}) {
|
function registerMockGroup(ps, id, behavior = {}) {
|
||||||
const calls = { handleInput: [], turnOff: 0 };
|
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||||
const mock = {
|
const mock = {
|
||||||
config: {
|
config: {
|
||||||
general: { id, name: id },
|
general: { id, name: id },
|
||||||
@@ -21,6 +22,8 @@ function registerMockGroup(ps, id, behavior = {}) {
|
|||||||
emitter: { on: () => {} },
|
emitter: { on: () => {} },
|
||||||
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
||||||
},
|
},
|
||||||
|
setDemand: behavior.setDemand
|
||||||
|
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
|
||||||
handleInput: behavior.handleInput
|
handleInput: behavior.handleInput
|
||||||
|| (async (...args) => { calls.handleInput.push(args); }),
|
|| (async (...args) => { calls.handleInput.push(args); }),
|
||||||
turnOffAllMachines: behavior.turnOffAllMachines
|
turnOffAllMachines: behavior.turnOffAllMachines
|
||||||
@@ -82,6 +85,39 @@ function makeConfig(overrides = {}) {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeMeasurementChild({ type = 'level', position = 'atequipment', name = 'child-level' } = {}) {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id: name, name },
|
||||||
|
functionality: { positionVsParent: position },
|
||||||
|
asset: { type },
|
||||||
|
},
|
||||||
|
measurements: new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { level: 'm', flow: 'm3/s', pressure: 'Pa' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('level child subscription records one sample per event for level-rate fallback', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
const child = makeMeasurementChild();
|
||||||
|
|
||||||
|
ps._subscribeMeasurement(child);
|
||||||
|
child.measurements.type('level').variant('measured').position('atequipment')
|
||||||
|
.value(1.0, 1000, 'm');
|
||||||
|
child.measurements.type('level').variant('measured').position('atequipment')
|
||||||
|
.value(1.1, 3000, 'm');
|
||||||
|
|
||||||
|
const series = ps.measurements.type('level').variant('measured').position('atequipment').get();
|
||||||
|
assert.deepEqual(series.values, [1.0, 1.1]);
|
||||||
|
|
||||||
|
const net = ps.flowAggregator.selectBestNetFlow();
|
||||||
|
assert.equal(net.source, 'level:measured');
|
||||||
|
assert.equal(net.direction, 'filling');
|
||||||
|
assert.ok(Math.abs(net.value - 0.5) < 1e-9, `net flow was ${net.value}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('Basin geometry — derived values', async (t) => {
|
test('Basin geometry — derived values', async (t) => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
@@ -163,7 +199,10 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
|||||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
|
await t.test('startLevel > inflowLevel is allowed (sewer-buffer mode), no issue raised', () => {
|
||||||
|
// Inflow gravity point at 3, startLevel pushed to 3.5 → basin is allowed
|
||||||
|
// to fill past the inlet before pumps engage. levelBased shifts the ramp
|
||||||
|
// foot to startLevel; the validator no longer flags the ordering.
|
||||||
const ps = new PumpingStation(makeConfig({
|
const ps = new PumpingStation(makeConfig({
|
||||||
control: {
|
control: {
|
||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
@@ -171,7 +210,8 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
|||||||
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
|
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'));
|
assert.ok(!ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'),
|
||||||
|
'startLevel vs inflowLevel ordering must not raise an issue');
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
||||||
@@ -261,51 +301,77 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
assert.equal(mock._calls.turnOff, 1);
|
assert.equal(mock._calls.turnOff, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
|
await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
ps.percControl = 42; // simulated previous demand
|
ps.percControl = 42; // simulated previous demand
|
||||||
const mock = registerMockGroup(ps, 'mgc1');
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
assert.equal(ps.percControl, 0);
|
assert.equal(ps.percControl, 0);
|
||||||
assert.equal(mock._calls.handleInput[0][1], 0);
|
// pct=0 → turnOff, no setDemand call (avoids MGC interpolating 0 % to dt.flow.min).
|
||||||
|
assert.equal(mock._calls.turnOff, 1);
|
||||||
|
assert.equal(mock._calls.setDemand.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
|
await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
const mock = registerMockGroup(ps, 'mgc1');
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
|
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3, maxLevel=4
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
// Ramp foot = startLevel (NOT inflowLevel). lerp(2.5, [2, 4], [0, 100]) = 25.
|
||||||
|
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected ~25 %, got ${ps.percControl}`);
|
||||||
|
assert.equal(mock._calls.turnOff, 0, 'engaged — pumps must not be turned off in the ramp');
|
||||||
|
assert.equal(mock._calls.setDemand.length, 1);
|
||||||
|
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 25) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('filling: level ≥ maxLevel → percControl clamped at 100, routed via setDemand', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
|
ps.calibratePredictedLevel(3.5); // 3/4 of the [2,4] ramp → 75 %.
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
assert.ok(Math.abs(ps.percControl - 75) < 1e-9, `expected ~75 %, got ${ps.percControl}`);
|
||||||
|
assert.equal(mock._calls.setDemand.length, 1);
|
||||||
|
assert.equal(mock._calls.setDemand[0][1], '%');
|
||||||
|
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 75) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('filling: holdLevel raises the ramp foot — explicit hold band [startLevel, holdLevel] sits at 0 %', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
|
ps.calibratePredictedLevel(2.5); // inside [startLevel, holdLevel]
|
||||||
await ps._controlLevelBased('filling');
|
await ps._controlLevelBased('filling');
|
||||||
assert.equal(ps.percControl, 0);
|
assert.equal(ps.percControl, 0);
|
||||||
assert.equal(mock._calls.handleInput[0][1], 0);
|
assert.equal(mock._calls.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off');
|
||||||
|
assert.deepEqual(mock._calls.setDemand[0], [0, '%']);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
|
await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const mock = registerMockGroup(ps, 'mgc1');
|
|
||||||
ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4
|
|
||||||
await ps._controlLevelBased('filling');
|
|
||||||
// lerp(3.5, [3,4], [0,100]) = 50
|
|
||||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
|
||||||
assert.equal(mock._calls.handleInput.length, 1);
|
|
||||||
assert.ok(Math.abs(mock._calls.handleInput[0][1] - 50) < 1e-9);
|
|
||||||
});
|
|
||||||
|
|
||||||
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
registerMockGroup(ps, 'mgc1');
|
registerMockGroup(ps, 'mgc1');
|
||||||
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
|
// Climb above startLevel, then fall to a level inside [start, inflow]. With
|
||||||
|
// the new semantics (ramp foot = startLevel, NOT inflowLevel) the falling
|
||||||
|
// level still produces a positive demand on the way down.
|
||||||
ps.calibratePredictedLevel(3.8);
|
ps.calibratePredictedLevel(3.8);
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
assert.ok(ps.percControl > 0);
|
assert.ok(ps.percControl > 0);
|
||||||
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
|
ps.calibratePredictedLevel(2.5); // startLevel=2, maxLevel=4 → 25 %
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
// Without shift the foot is inflowLevel → 0% in the hold zone.
|
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
|
||||||
assert.equal(ps.percControl, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
|
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
|
||||||
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
|
// The original shifted-ramp test was authored against the legacy ramp
|
||||||
|
// foot = inflowLevel (=3). With the new defaults the foot moves to
|
||||||
|
// startLevel (=2), which changes every percentage in the trace. Pin
|
||||||
|
// the foot back to 3 by setting holdLevel = 3 — that keeps this test's
|
||||||
|
// arithmetic self-consistent: up curve goes 0 %@3 to 100 %@4.
|
||||||
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
|
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
|
||||||
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
|
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
|
||||||
const ps = new PumpingStation(makeConfig({
|
const ps = new PumpingStation(makeConfig({
|
||||||
@@ -313,7 +379,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased']),
|
allowedModes: new Set(['levelbased']),
|
||||||
levelbased: {
|
levelbased: {
|
||||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -355,7 +421,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased']),
|
allowedModes: new Set(['levelbased']),
|
||||||
levelbased: {
|
levelbased: {
|
||||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
// Pin the ramp foot at 3 via holdLevel — keeps legacy arithmetic
|
||||||
|
// self-consistent with the original test (up curve 0 %@3 → 100 %@4).
|
||||||
|
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -381,7 +449,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
control: {
|
control: {
|
||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased']),
|
allowedModes: new Set(['levelbased']),
|
||||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
// holdLevel=3 keeps ramp foot at 3 so x=0.5 means level=3.5, matching
|
||||||
|
// the legacy assertion bracket.
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
registerMockGroup(ps, 'mgc1');
|
registerMockGroup(ps, 'mgc1');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
function loadDashboardFlow() {
|
function loadDashboardFlow() {
|
||||||
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
|
const flowPath = path.join(__dirname, '../../examples/02-Dashboard.json');
|
||||||
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,27 +22,29 @@ function makeContextStub() {
|
|||||||
|
|
||||||
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
|
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
|
||||||
const flow = loadDashboardFlow();
|
const flow = loadDashboardFlow();
|
||||||
const ps = flow.find((n) => n.id === 'ps_node_basic');
|
const ps = flow.find((n) => n.type === 'pumpingStation');
|
||||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
const parser = flow.find((n) => n.id === 'fn_status_split');
|
||||||
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
|
const levelChart = flow.find((n) => n.id === 'ui_chart_level');
|
||||||
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
|
const volumeChart = flow.find((n) => n.id === 'ui_chart_volume');
|
||||||
|
const flowChart = flow.find((n) => n.id === 'ui_chart_flow');
|
||||||
|
|
||||||
assert.ok(ps, 'ps_node_basic should exist');
|
assert.ok(ps, 'pumpingStation node should exist');
|
||||||
assert.equal(ps.type, 'pumpingStation');
|
assert.equal(ps.type, 'pumpingStation');
|
||||||
assert.equal(ps.controlMode, 'levelbased');
|
assert.equal(ps.controlMode, 'levelbased');
|
||||||
assert.equal(ps.levelCurveType, 'linear');
|
assert.equal(ps.levelCurveType, 'linear');
|
||||||
assert.equal(ps.inletPipeDiameter, 0.4);
|
assert.equal(ps.inletPipeDiameter, 0.3);
|
||||||
assert.equal(ps.outletPipeDiameter, 0.3);
|
assert.equal(ps.outletPipeDiameter, 0.3);
|
||||||
assert.ok(parser, 'ps_parse_output should exist');
|
assert.ok(parser, 'fn_status_split should exist');
|
||||||
assert.equal(parser.outputs, 6);
|
assert.equal(parser.outputs, 14);
|
||||||
assert.equal(levelChart.type, 'ui-chart');
|
assert.equal(levelChart.type, 'ui-chart');
|
||||||
assert.equal(demandChart.type, 'ui-chart');
|
assert.equal(volumeChart.type, 'ui-chart');
|
||||||
|
assert.equal(flowChart.type, 'ui-chart');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('basic dashboard parser routes process fields to charts and state text', () => {
|
test('basic dashboard parser routes process fields to charts and state text', () => {
|
||||||
const flow = loadDashboardFlow();
|
const flow = loadDashboardFlow();
|
||||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
const parser = flow.find((n) => n.id === 'fn_status_split');
|
||||||
assert.ok(parser, 'ps_parse_output should exist');
|
assert.ok(parser, 'fn_status_split should exist');
|
||||||
|
|
||||||
const func = new Function('msg', 'context', 'node', parser.func);
|
const func = new Function('msg', 'context', 'node', parser.func);
|
||||||
const context = makeContextStub();
|
const context = makeContextStub();
|
||||||
@@ -56,8 +58,12 @@ test('basic dashboard parser routes process fields to charts and state text', ()
|
|||||||
payload: {
|
payload: {
|
||||||
'level.predicted.atequipment.default': 3.25,
|
'level.predicted.atequipment.default': 3.25,
|
||||||
'volume.predicted.atequipment.default': 32.5,
|
'volume.predicted.atequipment.default': 32.5,
|
||||||
|
'volumePercent.predicted.atequipment.default': 65,
|
||||||
|
'flow.predicted.in.default': 0.005,
|
||||||
|
'flow.predicted.out.default': 0.002,
|
||||||
'netFlowRate.predicted.atequipment.default': 0.003,
|
'netFlowRate.predicted.atequipment.default': 0.003,
|
||||||
percControl: 25,
|
percControl: 25,
|
||||||
|
mode: 'levelbased',
|
||||||
direction: 'filling',
|
direction: 'filling',
|
||||||
safetyState: 'normal',
|
safetyState: 'normal',
|
||||||
isOverflowing: false,
|
isOverflowing: false,
|
||||||
@@ -66,22 +72,25 @@ test('basic dashboard parser routes process fields to charts and state text', ()
|
|||||||
}, context, node);
|
}, context, node);
|
||||||
|
|
||||||
assert.ok(Array.isArray(out));
|
assert.ok(Array.isArray(out));
|
||||||
assert.equal(out.length, 6);
|
assert.equal(out.length, 14);
|
||||||
assert.equal(out[0].topic, 'level');
|
assert.equal(out[0].payload, 'levelbased');
|
||||||
assert.equal(out[0].payload, 3.25);
|
assert.equal(out[1].payload, 'filling');
|
||||||
assert.equal(out[1].topic, 'volume');
|
assert.equal(out[2].payload, '3.25 m');
|
||||||
assert.equal(out[1].payload, 32.5);
|
assert.equal(out[3].payload, '32.50 m³');
|
||||||
assert.equal(out[2].topic, 'demand');
|
assert.equal(out[4].payload, '65.00 %');
|
||||||
assert.equal(out[2].payload, 25);
|
assert.equal(out[5].payload, '25.0 %');
|
||||||
assert.equal(out[3].topic, 'net_flow');
|
assert.deepEqual(out[7], { topic: 'Level', payload: 3.25 });
|
||||||
assert.equal(out[3].payload, 0.003);
|
assert.deepEqual(out[8], { topic: 'Volume', payload: 32.5 });
|
||||||
assert.match(out[4].payload, /normal/);
|
assert.deepEqual(out[9], { topic: 'Volume %', payload: 65 });
|
||||||
assert.match(out[5].payload, /level=3.25 m/);
|
assert.deepEqual(out[10], { topic: 'Inflow', payload: 18 });
|
||||||
|
assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 });
|
||||||
|
assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 });
|
||||||
|
assert.ok(Array.isArray(out[13].payload));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
||||||
const flow = loadDashboardFlow();
|
const flow = loadDashboardFlow();
|
||||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
const parser = flow.find((n) => n.id === 'fn_status_split');
|
||||||
const func = new Function('msg', 'context', 'node', parser.func);
|
const func = new Function('msg', 'context', 'node', parser.func);
|
||||||
const context = makeContextStub();
|
const context = makeContextStub();
|
||||||
const node = { send() {} };
|
const node = { send() {} };
|
||||||
@@ -89,6 +98,6 @@ test('basic dashboard parser keeps previous values when process output sends onl
|
|||||||
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
|
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
|
||||||
const out = func({ payload: { percControl: 20 } }, context, node);
|
const out = func({ payload: { percControl: 20 } }, context, node);
|
||||||
|
|
||||||
assert.equal(out[0].payload, 3.1);
|
assert.equal(out[2].payload, '3.10 m');
|
||||||
assert.equal(out[2].payload, 20);
|
assert.equal(out[5].payload, '20.0 %');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ function makeConfig() {
|
|||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased', 'manual']),
|
allowedModes: new Set(['levelbased', 'manual']),
|
||||||
levelbased: {
|
levelbased: {
|
||||||
minLevel: 1, startLevel: 2, maxLevel: 4,
|
// holdLevel pins the ramp foot at 3 to preserve the original geometry
|
||||||
|
// (up curve 0 %@3 → 100 %@4). New default would put the foot at
|
||||||
|
// startLevel=2; this test specifically exercises shifted-ramp arming
|
||||||
|
// behaviour, not the ramp-foot semantic itself.
|
||||||
|
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4,
|
||||||
curveType: 'linear', logCurveFactor: 9,
|
curveType: 'linear', logCurveFactor: 9,
|
||||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||||
},
|
},
|
||||||
|
|||||||
388
wiki/Home.md
388
wiki/Home.md
@@ -1,295 +1,131 @@
|
|||||||
# pumpingStation
|
# pumpingStation
|
||||||
|
|
||||||
> **Reflects code as of `d2384b1` · regenerated `<YYYY-MM-DD>` via `npm run wiki:all`**
|
  
|
||||||
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
|
|
||||||
|
|
||||||
## 1. What this node is
|
A `pumpingStation` models a wet-well lift station: one basin with sensors, and one or more pumps that move water against an elevation difference. It integrates basin volume each tick, picks a control mode (level-based by default), and sends a demand setpoint to its pumps so the basin level stays inside its safe operating band.
|
||||||
|
|
||||||
**pumpingStation** is an S88 Process Cell that owns a wet-well basin and orchestrates the pumps that drain it. It tracks measured + predicted volume, evaluates safety interlocks (dry-run, overfill), and dispatches a control strategy that hands a demand setpoint to one or more downstream machine groups or individual pumps.
|
---
|
||||||
|
|
||||||
## 2. Position in the platform
|
## At a glance
|
||||||
|
|
||||||
```mermaid
|
| Thing | Value |
|
||||||
flowchart LR
|
|:---|:---|
|
||||||
ps[pumpingStation<br/>Process Cell]:::pc
|
| What it represents | A wet-well lift station: a basin + N pumps |
|
||||||
meas_lvl[measurement<br/>type=level<br/>position=atequipment]:::ctrl
|
| S88 level | Process Cell |
|
||||||
meas_in[measurement<br/>type=flow<br/>position=upstream]:::ctrl
|
| Use it when | You need to lift water from a low point to a higher one, with sensors driving demand |
|
||||||
mgc[machineGroupControl<br/>Unit]:::unit
|
| Don't use it for | Pressurised distribution networks (use a pumpingStation cascade or VGC instead), or a single pump with no basin (parent a `rotatingMachine` directly) |
|
||||||
|
| Children it accepts | `measurement`, `machine`, `machinegroup`, `pumpingstation` |
|
||||||
|
|
||||||
meas_lvl -.data.-> ps
|
---
|
||||||
meas_in -.data.-> ps
|
|
||||||
ps -->|set.demand| mgc
|
## How it looks in Node-RED
|
||||||
mgc -.evt.flow-predicted.-> ps
|
|
||||||
mgc -->|child.register| ps
|

|
||||||
classDef pc fill:#0c99d9,color:#fff
|
|
||||||
classDef unit fill:#50a8d9,color:#000
|
---
|
||||||
classDef ctrl fill:#a9daee,color:#000
|
|
||||||
|
## What it models
|
||||||
|
|
||||||
|
A rectangular basin with measured inflow, measured (or pump-summed) outflow, and a level sensor. The diagram below is the live source; open it in [draw.io](https://app.diagrams.net/) to edit.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The basin has five horizontal reference lines that matter to the controller:
|
||||||
|
|
||||||
|
| Line | Role |
|
||||||
|
|:---|:---|
|
||||||
|
| `overflowLevel` | Physical weir crest. Above this level the basin is spilling. |
|
||||||
|
| `maxLevel` | Demand saturates at 100 % at or above this level. |
|
||||||
|
| `startLevel` | Falling-ramp returns to 0 % demand here; deadband upper bound. |
|
||||||
|
| `minLevel` | Below this level the controller commands all pumps off. |
|
||||||
|
| `dryRunLevel` | Pump-protection cutoff (safety layer, mode-independent). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Try it — 3-minute demo
|
||||||
|
|
||||||
|
Import the basic example flow, deploy, and watch the basin react to inject buttons.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data @nodes/pumpingStation/examples/01-Basic.json \
|
||||||
|
http://localhost:1880/flow
|
||||||
```
|
```
|
||||||
|
|
||||||
S88 colours: Process Cell `#0c99d9`, Unit `#50a8d9`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
|

|
||||||
|
|
||||||
## 3. Capability matrix
|
What to click in the dashboard after deploy:
|
||||||
|
|
||||||
| Capability | Status | Notes |
|
1. `set.mode = levelbased` → the controller switches to level-based mode.
|
||||||
|---|---|---|
|
2. `set.inflow = 60 m³/h` → inflow is now feeding the basin.
|
||||||
| Predicts basin volume from net flow | ✅ | Integrator seeded from `basin.minVol`; recomputes level. |
|
3. `cmd.calibrate.level = 1.5 m` → the volume integrator syncs to a known level.
|
||||||
| Accepts measured level / volume / pressure | ✅ | Routed via `measurementRouter` on child registration. |
|
4. Watch Port 0 in the debug pane: level rises, predicted volume integrates, demand follows the curve.
|
||||||
| Level-based control strategy | ✅ | Linear or log ramp between `minLevel` and `maxLevel`. |
|
|
||||||
| Flow-based control strategy | ✅ | PID against `flowSetpoint`. |
|
|
||||||
| Manual demand passthrough | ✅ | `set.demand` only honoured in `manual` mode. |
|
|
||||||
| Dry-run safety interlock | ✅ | Stops downstream pumps when volume < `minVol` while draining. |
|
|
||||||
| Overfill safety interlock | ✅ | Stops upstream equipment when volume crosses overfill threshold. |
|
|
||||||
| Cascaded children (sub-stations) | ⚠️ | Accepted via `pumpingstation` softwareType but not exercised in production. |
|
|
||||||
|
|
||||||
## 4. Code map
|
> [!IMPORTANT]
|
||||||
|
> **GIF needed.** Demo recording of the basic flow reacting to mode + inflow clicks. Save as `wiki/_partial-gifs/pumpingStation/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||||
|
|
||||||
```mermaid
|
---
|
||||||
flowchart TB
|
|
||||||
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
|
## Typical wiring
|
||||||
nc["buildDomainConfig()<br/>static DomainClass, commands<br/>static tickInterval = 1000ms"]
|
|
||||||
end
|
The two patterns you'll see most.
|
||||||
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
|
|
||||||
sc["PumpingStation.configure()<br/>declares ChildRouter rules<br/>tick() → safety → control"]
|
### Standalone (`01-Basic.json`)
|
||||||
end
|
|
||||||
subgraph concerns["src/ concern modules"]
|

|
||||||
basin["basin/<br/>BasinGeometry + thresholdValidator"]
|
|
||||||
measurement["measurement/<br/>flowAggregator + router + calibration"]
|
### With a measurement child and an MGC parent
|
||||||
control["control/<br/>levelbased / flowbased / manual"]
|
|
||||||
safety["safety/<br/>SafetyController"]
|

|
||||||
commands["commands/<br/>topic registry + handlers"]
|
|
||||||
end
|
---
|
||||||
nc --> sc
|
|
||||||
sc --> basin
|
## The five things you'll send
|
||||||
sc --> measurement
|
|
||||||
sc --> control
|
| Topic | Payload | What it does |
|
||||||
sc --> safety
|
|:---|:---|:---|
|
||||||
nc --> commands
|
| `set.mode` | `"levelbased"` or `"manual"` | Switches control strategy. Manual exposes `set.demand` as the direct setpoint. |
|
||||||
|
| `set.demand` | number, m³/h | Operator outflow setpoint. Honoured in `manual` mode. |
|
||||||
|
| `set.inflow` | number, m³/h | Push a measured inflow into the basin balance (if you don't have a `measurement` child for inflow). |
|
||||||
|
| `cmd.calibrate.level` | number, m | Sync the volume integrator to a known level reading. Useful at startup. |
|
||||||
|
| `cmd.calibrate.volume` | number, m³ | Sync the volume integrator to a known volume reading. |
|
||||||
|
|
||||||
|
## What you'll see come out
|
||||||
|
|
||||||
|
Sample Port 0 message (delta-compressed — only changed fields each tick):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topic": "pumpingStation#PS1",
|
||||||
|
"payload": {
|
||||||
|
"level": 1.62,
|
||||||
|
"volume": 32.4,
|
||||||
|
"direction": "filling",
|
||||||
|
"demand": 38,
|
||||||
|
"safety": { "blocked": false },
|
||||||
|
"etaSeconds": 412
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Module | Owns | Read first if you're changing… |
|
| Field | Meaning |
|
||||||
|---|---|---|
|
|:---|:---|
|
||||||
| `basin/` | Geometry, volume↔level conversion, threshold ordering | Capacity, level↔volume math, fill %. |
|
| `level` | Current basin level (m). Measured if a level `measurement` is registered; predicted otherwise. |
|
||||||
| `measurement/` | Net-flow aggregation, predicted-volume integrator, calibration | Predicted volume / time-to-full. |
|
| `volume` | Integrated predicted volume (m³). |
|
||||||
| `control/` | Control strategy dispatch (`levelbased`, `flowbased`, `manual`) | Demand calculation, mode behaviour. |
|
| `direction` | `filling` / `draining` / `steady` based on the flow dead-band. |
|
||||||
| `safety/` | Dry-run + overfill rules, pump-shutdown side-effects | Safety envelope, alarm reactions. |
|
| `demand` | What the station is asking its pumps to do (0–100 %). |
|
||||||
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
|
| `safety.blocked` | True when the safety layer is overriding the control loop. |
|
||||||
|
| `etaSeconds` | Predicted time to full (if filling) or empty (if draining). |
|
||||||
|
|
||||||
## 5. Topic contract
|
---
|
||||||
|
|
||||||
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
## Need more?
|
||||||
|
|
||||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
| Page | What you'll find |
|
||||||
|
|:---|:---|
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, state chart, lifecycle sequence, output ports |
|
||||||
|
| [Reference — Examples](Reference-Examples) | All shipped example flows + Docker compose snippet + debug recipes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | When not to use this node, known limitations, open questions |
|
||||||
|
|
||||||
| Canonical topic | Aliases | Payload | Unit | Effect |
|
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) · [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) · [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||||
|---|---|---|---|---|
|
|
||||||
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
|
|
||||||
| `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. |
|
|
||||||
| `cmd.calibrate.volume` | `calibratePredictedVolume` | `any` | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
|
|
||||||
| `cmd.calibrate.level` | `calibratePredictedLevel` | `any` | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
|
|
||||||
| `set.inflow` | `q_in` | `any` | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
|
|
||||||
| `set.outflow` | `q_out` | `any` | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
|
|
||||||
| `set.demand` | `Qd` | `any` | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
|
|
||||||
|
|
||||||
<!-- END AUTOGEN: topic-contract -->
|
|
||||||
|
|
||||||
## 6. Child registration
|
|
||||||
|
|
||||||
Mirrors the `ChildRouter` declarations in `specificClass.js → configure()`.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph kids["accepted children (softwareType)"]
|
|
||||||
m["measurement"]:::ctrl
|
|
||||||
mach["machine<br/>(rotatingMachine)"]:::equip
|
|
||||||
mgc["machinegroup"]:::unit
|
|
||||||
sub["pumpingstation<br/>(sub-station)"]:::pc
|
|
||||||
end
|
|
||||||
m -->|"<type>.measured.<position>"| route1[_subscribeMeasurement<br/>routes to measurementRouter]
|
|
||||||
mach -->|flow.predicted.<in or out>| route2[_subscribePredictedFlow<br/>+ flowAggregator]
|
|
||||||
mgc -->|flow.predicted.<in or out>| route2
|
|
||||||
sub -->|flow.predicted.<in or out>| route2
|
|
||||||
route1 --> tick[tick]
|
|
||||||
route2 --> tick
|
|
||||||
classDef ctrl fill:#a9daee,color:#000
|
|
||||||
classDef equip fill:#86bbdd,color:#000
|
|
||||||
classDef unit fill:#50a8d9,color:#000
|
|
||||||
classDef pc fill:#0c99d9,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
| softwareType | onRegister side-effect | Subscribed events |
|
|
||||||
|---|---|---|
|
|
||||||
| `measurement` | `_subscribeMeasurement(child)` — registers in MeasurementContainer. | `<type>.measured.<position>` for any type (pressure, level, flow, …). |
|
|
||||||
| `machine` | Stored in `this.machines[id]`. **Skipped when a machineGroup parent is present** to avoid double-counting. | `flow.predicted.<in|out>` per the child's `positionVsParent`. |
|
|
||||||
| `machinegroup` | Stored in `this.machineGroups[id]`. | `flow.predicted.<in|out>`. |
|
|
||||||
| `pumpingstation` | Stored in `this.stations[id]`. | `flow.predicted.<in|out>`. |
|
|
||||||
|
|
||||||
## 7. Lifecycle — what one tick does
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant child as measurement / pump child
|
|
||||||
participant ps as pumpingStation
|
|
||||||
participant fa as flowAggregator
|
|
||||||
participant sf as safetyController
|
|
||||||
participant ctl as control strategy
|
|
||||||
participant out as Port-0 output
|
|
||||||
|
|
||||||
child->>ps: data event (measured.level / flow.predicted.out)
|
|
||||||
ps->>ps: ChildRouter dispatches to handler
|
|
||||||
Note over ps: every 1000 ms (static tickInterval)
|
|
||||||
ps->>fa: tick() — net flow, ETA, predicted volume
|
|
||||||
ps->>sf: evaluate({direction, secondsRemaining})
|
|
||||||
alt safety blocked
|
|
||||||
sf-->>ps: blocked=true, reason
|
|
||||||
Note over ctl: skipped this tick
|
|
||||||
else safety clear
|
|
||||||
ps->>ctl: dispatch(mode, ctx, controlState)
|
|
||||||
ctl-->>ps: percControl updated
|
|
||||||
end
|
|
||||||
ps->>ps: notifyOutputChanged()
|
|
||||||
ps->>out: msg{topic, payload (delta-compressed)}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Data model — `getOutput()`
|
|
||||||
|
|
||||||
What lands on Port 0. Built in `getOutput()`, then delta-compressed by `outputUtils.formatMsg`.
|
|
||||||
|
|
||||||
<!-- BEGIN AUTOGEN: data-model -->
|
|
||||||
|
|
||||||
| Key | Type | Unit | Sample |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `direction` | string | — | `"steady"` |
|
|
||||||
| `dryRunLevel` | number | — | `0.20400000000000001` |
|
|
||||||
| `dryRunSafetyVol` | number | — | `0.20400000000000001` |
|
|
||||||
| `flowSource` | null | — | `null` |
|
|
||||||
| `heightBasin` | number | m | `1` |
|
|
||||||
| `highVolumeSafetyLevel` | number | — | `2.45` |
|
|
||||||
| `highVolumeSafetyVol` | number | — | `2.45` |
|
|
||||||
| `inflowLevel` | number | m | `2` |
|
|
||||||
| `inletPipeDiameter` | number | — | `0.4` |
|
|
||||||
| `maxVol` | number | m3 | `1` |
|
|
||||||
| `maxVolAtOverflow` | number | m3 | `2.5` |
|
|
||||||
| `minHeightBasedOn` | string | — | `"outlet"` |
|
|
||||||
| `minVol` | number | m3 | `0.2` |
|
|
||||||
| `minVolAtInflow` | number | m3 | `2` |
|
|
||||||
| `minVolAtOutflow` | number | m3 | `0.2` |
|
|
||||||
| `outflowLevel` | number | m | `0.2` |
|
|
||||||
| `outletPipeDiameter` | number | — | `0.4` |
|
|
||||||
| `overflowLevel` | number | m | `2.5` |
|
|
||||||
| `percControl` | number | % | `0` |
|
|
||||||
| `predictedOverflowRate` | number | — | `0` |
|
|
||||||
| `predictedOverflowVolume` | number | — | `0` |
|
|
||||||
| `predictedUnderflowVolume` | number | — | `0` |
|
|
||||||
| `surfaceArea` | number | m2 | `1` |
|
|
||||||
| `timeleft` | null | s | `null` |
|
|
||||||
| `volEmptyBasin` | number | m3 | `1` |
|
|
||||||
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` |
|
|
||||||
|
|
||||||
<!-- END AUTOGEN: data-model -->
|
|
||||||
|
|
||||||
The `<nodeId>` segment of the MeasurementContainer key is the Node-RED node id assigned at deploy time; auto-gen substitutes a placeholder stub.
|
|
||||||
|
|
||||||
## 9. Configuration — editor form ↔ config keys
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph editor["Node-RED editor form"]
|
|
||||||
f1[Basin: volume / height]
|
|
||||||
f2[Levels: inflow / outflow / overflow]
|
|
||||||
f3[Control mode]
|
|
||||||
f4[Level setpoints: min / start / max]
|
|
||||||
f5[Safety: dry-run % / overfill %]
|
|
||||||
end
|
|
||||||
subgraph config["Domain config slice"]
|
|
||||||
c1[basin.volume<br/>basin.height]
|
|
||||||
c2[basin.inflowLevel<br/>basin.outflowLevel<br/>basin.overflowLevel]
|
|
||||||
c3[control.mode]
|
|
||||||
c4[control.levelbased.minLevel<br/>control.levelbased.startLevel<br/>control.levelbased.maxLevel]
|
|
||||||
c5[safety.dryRunThresholdPercent<br/>safety.overfillThresholdPercent]
|
|
||||||
end
|
|
||||||
f1 --> c1
|
|
||||||
f2 --> c2
|
|
||||||
f3 --> c3
|
|
||||||
f4 --> c4
|
|
||||||
f5 --> c5
|
|
||||||
```
|
|
||||||
|
|
||||||
| Form field | Config key | Default | Range | Where used |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `basinVolume` | `basin.volume` | `1` | > 0 (m³) | `BasinGeometry` |
|
|
||||||
| `basinHeight` | `basin.height` | `1` | > 0 (m) | `BasinGeometry` |
|
|
||||||
| `inflowLevel` | `basin.inflowLevel` | `2` | ≥ 0 (m) | threshold validator, control |
|
|
||||||
| `outflowLevel` | `basin.outflowLevel` | `0.2` | ≥ 0 (m) | dead-volume floor |
|
|
||||||
| `overflowLevel` | `basin.overflowLevel` | `2.5` | > 0 (m) | overfill safety |
|
|
||||||
| `controlMode` | `control.mode` | `levelbased` | enum | `control/dispatch` |
|
|
||||||
| `minLevel` | `control.levelbased.minLevel` | `1` | ≥ 0 (m) | `levelBased.run` |
|
|
||||||
| `startLevel` | `control.levelbased.startLevel` | `1` | ≥ minLevel | ramp foot |
|
|
||||||
| `maxLevel` | `control.levelbased.maxLevel` | `4` | ≤ overflowLevel | ramp top |
|
|
||||||
| `enableDryRunProtection` | `safety.enableDryRunProtection` | `true` | bool | `SafetyController` |
|
|
||||||
| `dryRunThresholdPercent` | `safety.dryRunThresholdPercent` | `2` | 0–100 % | dry-run trip |
|
|
||||||
| `enableOverfillProtection` | `safety.enableOverfillProtection` | `true` | bool | overfill safety |
|
|
||||||
| `overfillThresholdPercent` | `safety.overfillThresholdPercent` | `98` | 0–100 % | overfill trip |
|
|
||||||
|
|
||||||
## 10. State chart
|
|
||||||
|
|
||||||
Two orthogonal state vectors: **control mode** (operator-driven) and **safety state** (data-driven). The diagram shows them together — most transitions are independent.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
state ControlMode {
|
|
||||||
[*] --> none
|
|
||||||
none --> levelbased: set.mode
|
|
||||||
levelbased --> flowbased: set.mode
|
|
||||||
flowbased --> manual: set.mode
|
|
||||||
manual --> levelbased: set.mode
|
|
||||||
levelbased --> none: set.mode
|
|
||||||
}
|
|
||||||
state SafetyState {
|
|
||||||
[*] --> nominal
|
|
||||||
nominal --> dryRun: vol < minVol AND draining
|
|
||||||
nominal --> overfill: vol > overfillThreshold AND filling
|
|
||||||
dryRun --> nominal: vol ≥ minVol
|
|
||||||
overfill --> nominal: vol ≤ overfillThreshold
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
While the safety state is `dryRun`, control dispatch is **skipped** entirely. While `overfill`, control still runs (pumps must keep draining) but upstream equipment is shut down.
|
|
||||||
|
|
||||||
## 11. Examples
|
|
||||||
|
|
||||||
Example flows live under `examples/` in the repo. The structured tier-1/2/3 flows for this node are still in progress; until they land, the standalone simulator demo is the only runnable artefact.
|
|
||||||
|
|
||||||
| Tier | File | What it shows | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Basic | `examples/01-Basic.flow.json` | Inject + dashboard, single basin, no parent | ⏳ TBD |
|
|
||||||
| Integration | `examples/02-Integration.flow.json` | pumpingStation + MGC + 2 pumps + measurement children | ⏳ TBD |
|
|
||||||
| Dashboard | `examples/03-Dashboard.flow.json` | Live FlowFuse charts (level, net flow, ETA) | ⏳ TBD |
|
|
||||||
| Headless | `examples/standalone-demo.js` | Node.js-only simulator, no Node-RED | ✅ in repo |
|
|
||||||
|
|
||||||
## 12. Debug recipes
|
|
||||||
|
|
||||||
| Symptom | First thing to check | Where to look |
|
|
||||||
|---|---|---|
|
|
||||||
| Status badge stuck on `❔ 0.0%` | Did any volume / level measurement register? Watch Port 2 + first-child event. | Editor debug tap on Port 2 + `_subscribeMeasurement` log line. |
|
|
||||||
| `direction` always `steady` | Net flow inside `general.flowThreshold` dead-band (default 0.0001 m³/s). | `flowAggregator.deriveDirection`. |
|
|
||||||
| `set.demand` ignored | Mode isn't `manual`. Check `set.mode` history. | `handlers.setDemand` debug log. |
|
|
||||||
| Predicted volume drifts off measured | Calibration needed — fire `cmd.calibrate.volume` with a known reading. | `measurement/calibration.js`. |
|
|
||||||
| Pumps don't stop on dry-run | `safety.enableDryRunProtection` must be `true` AND the orchestrator must see `direction='draining'`. | `SafetyController.evaluate`. |
|
|
||||||
| Threshold-ordering warnings on startup | `validateThresholdOrdering` printed `inflowLevel < overflowLevel` style violations. | `basin/thresholdValidator.js`. |
|
|
||||||
|
|
||||||
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging.
|
|
||||||
|
|
||||||
## 13. When you would NOT use this node
|
|
||||||
|
|
||||||
- Use pumpingStation for a **wet-well basin** that needs orchestrated drainage. For a single pump with no basin model, use `rotatingMachine` directly.
|
|
||||||
- Don't use pumpingStation to schedule a fixed pump rota — its modes are reactive (level / flow / manual). Use an external scheduler if you need a calendar-driven schedule.
|
|
||||||
- Skip pumpingStation if you don't need predicted volume / time-to-full. A bare `machineGroupControl` is lighter when the upstream basin is modelled elsewhere.
|
|
||||||
|
|
||||||
## 14. Known limitations / current issues
|
|
||||||
|
|
||||||
| # | Issue | Tracked in |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | Cascaded `pumpingstation` children accepted but not exercised in production — semantics of nested stations are not test-covered. | TBD |
|
|
||||||
| 2 | `pressureBased`, `percentageBased`, `powerBased`, and `hybrid` are in the config enum but not implemented as control strategies. | `control/index.js` — only `levelbased` / `flowbased` / `manual` dispatched. |
|
|
||||||
| 3 | Predicted-volume integrator can drift over long horizons without a measured-level calibration source. | `cmd.calibrate.volume` is operator-triggered, not automatic. |
|
|
||||||
| 4 | Tier 1/2/3 example flows not yet written — current `examples/` only contains the standalone simulator. | P2.14 (Docker E2E) + P9 wiki cleanup. |
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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).
|
|
||||||
158
wiki/Reference-Architecture.md
Normal file
158
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Reference — Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Code structure for `pumpingStation`: the three-tier sandwich, the `src/` layout, the FSM, the lifecycle, and the output-port pipeline. Everything here is reproducible from `src/`. For an intuitive overview, return to [Home](Home).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three-tier code layout
|
||||||
|
|
||||||
|
```
|
||||||
|
nodes/pumpingStation/
|
||||||
|
|
|
||||||
|
+-- pumpingStation.js entry: RED.nodes.registerType('pumpingstation', NodeClass)
|
||||||
|
|
|
||||||
|
+-- src/
|
||||||
|
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
|
||||||
|
| specificClass.js extends BaseDomain (orchestration only)
|
||||||
|
| |
|
||||||
|
| +-- commands/
|
||||||
|
| | index.js topic descriptors
|
||||||
|
| | handlers.js pure handler functions
|
||||||
|
| |
|
||||||
|
| +-- basin/
|
||||||
|
| | BasinGeometry.js basin shape, level <-> volume conversion
|
||||||
|
| | thresholdValidator.js derives + validates safety / control thresholds
|
||||||
|
| |
|
||||||
|
| +-- measurement/
|
||||||
|
| | flowAggregator.js net-flow + predicted-volume integrator
|
||||||
|
| | measurementRouter.js routes measurement-child events
|
||||||
|
| | calibration.js calibrate-to-known-level / volume helpers
|
||||||
|
| |
|
||||||
|
| +-- control/
|
||||||
|
| | index.js mode dispatcher (levelbased, manual, ...)
|
||||||
|
| |
|
||||||
|
| +-- safety/
|
||||||
|
| safetyController.js dry-run + high-volume + panic guards
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier responsibilities
|
||||||
|
|
||||||
|
| Tier | File | What it owns | Touches `RED.*` |
|
||||||
|
|:---|:---|:---|:---:|
|
||||||
|
| entry | `pumpingStation.js` | Type registration | Yes |
|
||||||
|
| nodeClass | `src/nodeClass.js` | Input routing, tick loop, output ports, status badge | Yes |
|
||||||
|
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; run them in `tick()`; nothing more | No |
|
||||||
|
|
||||||
|
The specificClass is stitching, not implementation. All real work lives in `basin/`, `measurement/`, `control/`, `safety/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State chart — safety controller
|
||||||
|
|
||||||
|
The pumpingStation does not have a per-mode FSM (control modes are stateless transfer functions). The state machine that matters is the **safety controller**, which can block or pass control commands.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> running
|
||||||
|
running --> blocked_dryrun: level < dryRunLevel
|
||||||
|
running --> blocked_highvolume: level >= highVolumeSafetyLevel
|
||||||
|
running --> blocked_panic: no-data panic timer expires
|
||||||
|
blocked_dryrun --> running: level recovers above hysteresis
|
||||||
|
blocked_highvolume --> running: level falls below hysteresis
|
||||||
|
blocked_panic --> running: data resumes
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `blocked_*` state sets `safety.blocked = true` on Port 0 and prevents the control layer from emitting a non-zero demand. The hysteresis is mode-independent and lives in `src/safety/safetyController.js`.
|
||||||
|
|
||||||
|
### Safety-rules asymmetry
|
||||||
|
|
||||||
|
The `dryRunLevel` and `highVolumeSafetyLevel` rules differ in **which children they stop**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Rule | What stops | Why |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| Dry run | All children (pumps off) | Pumps cavitate without water; protect the equipment |
|
||||||
|
| High volume | Only outflow-side pumps | Spill is the lesser evil; some pumps may still serve safety functions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lifecycle — one tick
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant tick as 1s tick
|
||||||
|
participant sc as specificClass.tick()
|
||||||
|
participant fa as flowAggregator
|
||||||
|
participant safe as safetyController
|
||||||
|
participant ctrl as control[mode]
|
||||||
|
participant out as Port 0 / 1
|
||||||
|
|
||||||
|
tick->>sc: tick()
|
||||||
|
sc->>fa: update predicted volume
|
||||||
|
fa->>fa: pick best net-flow source (measured / aggregated)
|
||||||
|
sc->>safe: evaluate
|
||||||
|
alt safety blocked
|
||||||
|
safe-->>sc: { blocked: true }
|
||||||
|
Note over sc: skip control layer
|
||||||
|
else safe to run
|
||||||
|
sc->>ctrl: strategies[mode].run(context)
|
||||||
|
ctrl-->>sc: demand 0..100
|
||||||
|
end
|
||||||
|
sc->>out: getOutput() — emit Port 0 + Port 1 deltas
|
||||||
|
```
|
||||||
|
|
||||||
|
Each tick is 1 Hz. The output pipeline (Port 0 + Port 1) is driven by `outputUtils.formatMsg` — only changed fields are sent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output ports
|
||||||
|
|
||||||
|
| Port | Carries | Sample shape |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| 0 (process) | Delta-compressed state snapshot consumed by downstream Node-RED logic | `{topic, payload: {level, volume, demand, direction, safety, etaSeconds}}` |
|
||||||
|
| 1 (telemetry) | InfluxDB line-protocol string with the same fields as Port 0 | `pumpingStation,id=PS1 level=1.62,volume=32.4 ...` |
|
||||||
|
| 2 (register / control) | `child.register` upward at init; internal control plumbing later | `{topic: 'child.register', payload: {ref, softwareType, config}}` |
|
||||||
|
|
||||||
|
See [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tick timing and event sources
|
||||||
|
|
||||||
|
| Source | Where it fires | What it triggers |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `setInterval(1000)` | `BaseNodeAdapter` lifecycle | `specificClass.tick()` — the per-second integrator update |
|
||||||
|
| `measurement` emitter event | Child node's `emitter.emit(<type>.measured.<position>, ...)` | `measurementRouter` updates the basin balance |
|
||||||
|
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch to a handler |
|
||||||
|
| `child.register` from another node | Port 2 of a child | `_subscribeMeasurement` or `_subscribePredictedFlow` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to start reading
|
||||||
|
|
||||||
|
| If you're changing... | Read first |
|
||||||
|
|:---|:---|
|
||||||
|
| Basin geometry, level/volume conversion | `src/basin/BasinGeometry.js`, `src/basin/thresholdValidator.js` |
|
||||||
|
| Net-flow selection, predicted-volume integration | `src/measurement/flowAggregator.js` |
|
||||||
|
| Calibration commands | `src/measurement/calibration.js` |
|
||||||
|
| Control modes (level-based, manual, future modes) | `src/control/index.js` |
|
||||||
|
| Safety blocks | `src/safety/safetyController.js` |
|
||||||
|
| Topic dispatch | `src/commands/index.js` + `src/commands/handlers.js` |
|
||||||
|
| Adapter, ticking, output ports | `src/nodeClass.js` (and `BaseNodeAdapter` in `generalFunctions`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped example flows |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known limitations and open questions |
|
||||||
|
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||||
164
wiki/Reference-Contracts.md
Normal file
164
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Reference — Contracts
|
||||||
|
|
||||||
|
 
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Full topic contract, configuration schema, and child-registration filters for `pumpingStation`. The topic-contract and data-model sections are **regenerated by `npm run wiki:all`** — do not hand-edit between the `BEGIN AUTOGEN` / `END AUTOGEN` markers. Source of truth for everything on this page: the node's `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/pumpingStation.json`.
|
||||||
|
>
|
||||||
|
> For an intuitive overview, return to the [Home](Home).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Topic contract
|
||||||
|
|
||||||
|
The **Unit** column reflects each descriptor's `units: { measure, default }` declaration. The default unit is what the commandRegistry coerces incoming `msg.unit` values to before the handler runs.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
|
||||||
|
| `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. |
|
||||||
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` | any | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
|
||||||
|
| `cmd.calibrate.level` | `calibratePredictedLevel` | any | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
|
||||||
|
| `set.inflow` | `q_in` | any | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
|
||||||
|
| `set.outflow` | `q_out` | any | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
|
||||||
|
| `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model — `getOutput()` shape
|
||||||
|
|
||||||
|
Keys composed each tick by `specificClass.getOutput()` and emitted via `outputUtils.formatMsg` on Port 0. Delta-compressed: consumers see only the keys that changed.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
| Key | Type | Unit | Sample |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `direction` | string | — | `"steady"` |
|
||||||
|
| `dryRunLevel` | number | — | `0.20400000000000001` |
|
||||||
|
| `dryRunSafetyVol` | number | — | `0.20400000000000001` |
|
||||||
|
| `flowSource` | null | — | `null` |
|
||||||
|
| `heightBasin` | number | m | `1` |
|
||||||
|
| `highVolumeSafetyLevel` | number | — | `2.45` |
|
||||||
|
| `highVolumeSafetyVol` | number | — | `2.45` |
|
||||||
|
| `inflowLevel` | number | m | `2` |
|
||||||
|
| `inletPipeDiameter` | number | — | `0.4` |
|
||||||
|
| `maxVol` | number | m3 | `1` |
|
||||||
|
| `maxVolAtOverflow` | number | m3 | `2.5` |
|
||||||
|
| `minHeightBasedOn` | string | — | `"outlet"` |
|
||||||
|
| `minVol` | number | m3 | `0.2` |
|
||||||
|
| `minVolAtInflow` | number | m3 | `2` |
|
||||||
|
| `minVolAtOutflow` | number | m3 | `0.2` |
|
||||||
|
| `outflowLevel` | number | m | `0.2` |
|
||||||
|
| `outletPipeDiameter` | number | — | `0.4` |
|
||||||
|
| `overflowLevel` | number | m | `2.5` |
|
||||||
|
| `percControl` | number | % | `0` |
|
||||||
|
| `predictedOverflowRate` | number | — | `0` |
|
||||||
|
| `predictedOverflowVolume` | number | — | `0` |
|
||||||
|
| `predictedUnderflowVolume` | number | — | `0` |
|
||||||
|
| `surfaceArea` | number | m2 | `1` |
|
||||||
|
| `timeleft` | null | s | `null` |
|
||||||
|
| `volEmptyBasin` | number | m3 | `1` |
|
||||||
|
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
Sample values come from a stub instantiation in `wikiGen` — in a live deployment the volume key is shaped `volume.<variant>.<position>.<childId>` per the standard [Measurement Key Shape](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions#measurement-key-shape).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration schema — editor form to config keys
|
||||||
|
|
||||||
|
Source of truth: `generalFunctions/src/configs/pumpingStation.json`.
|
||||||
|
|
||||||
|
### Basin geometry (`config.basin`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Unit | Notes |
|
||||||
|
|:---|:---|:---|:---|:---|
|
||||||
|
| Basin Volume | `basin.volume` | `1` | m3 | Total geometric storage from floor to rim |
|
||||||
|
| Basin Height | `basin.height` | `1` | m | Floor-to-rim wall height |
|
||||||
|
| Inlet Elevation | `basin.inflowLevel` | `2` | m | Bottom of incoming pipe, from floor |
|
||||||
|
| Outlet Elevation | `basin.outflowLevel` | `0.2` | m | Top of pump-suction pipe, from floor |
|
||||||
|
| Inlet Pipe Diameter | `basin.inletPipeDiameter` | `0.4` | m | For future hydraulic upgrades |
|
||||||
|
| Outlet Pipe Diameter | `basin.outletPipeDiameter` | `0.4` | m | For future hydraulic upgrades |
|
||||||
|
| Overflow Level | `basin.overflowLevel` | `2.5` | m | Physical overflow weir crest |
|
||||||
|
|
||||||
|
### Safety thresholds (`config.safety`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| High-Volume Safety % | `safety.highVolumeSafetyThresholdPercent` | `98` | Trigger high-volume safety at this fill % |
|
||||||
|
| Dry-Run Safety Level | `safety.dryRunLevel` | `0.2` | Below this level all pumps stop |
|
||||||
|
| Enable High-Volume Safety | `safety.enableHighVolumeSafety` | `true` | Master switch |
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Earlier versions used `enableOverfillProtection` and `overfillThresholdPercent`. Those names are deprecated. The current canonical names are `enableHighVolumeSafety` and `highVolumeSafetyThresholdPercent`. See `.claude/refactor/OPEN_QUESTIONS.md` for the alias-removal timeline.
|
||||||
|
|
||||||
|
### Control mode (`config.control`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| Mode | `control.mode` | `"levelbased"` | One of `levelbased`, `manual`, `flowbased`*, `pressureBased`*, `percentageBased`*, `powerBased`*, `hybrid`*. Asterisked modes are placeholders in code. |
|
||||||
|
| Level Curve Type | `control.levelbased.curveType` | `"linear"` | `linear` or `log` |
|
||||||
|
| Log Curve Factor | `control.levelbased.logCurveFactor` | `0.5` | Slope tuning for log curve |
|
||||||
|
| Min Level | `control.levelbased.minLevel` | `0.3` | Demand hard-zero below this |
|
||||||
|
| Start Level | `control.levelbased.startLevel` | `0.5` | Falling-ramp returns to 0 % here |
|
||||||
|
| Stop Level | `control.levelbased.stopLevel` | `0.4` | Schmitt-trigger lower bound for pump-count keep-alive |
|
||||||
|
| Max Level | `control.levelbased.maxLevel` | `2.3` | Demand saturates at 100 % here |
|
||||||
|
| Enable Shifted Ramp | `control.levelbased.enableShiftedRamp` | `true` | Hysteresis-armed shift between rising / falling ramps |
|
||||||
|
| Manual Flow Setpoint | `control.manual.flowSetpoint` | `0` | Honoured in `manual` mode |
|
||||||
|
|
||||||
|
### General (`config.general`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| Time-left full / empty threshold | `general.timeleftToFullOrEmptyThresholdSeconds` | `120` | ETA below this triggers warning state |
|
||||||
|
| Flow dead-band | `general.flowThreshold` | `1e-4` m³/s | Net-flow below this is treated as steady |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Child registration
|
||||||
|
|
||||||
|
Source: `nodes/pumpingStation/src/specificClass.js` `configure()`, lines 107–116.
|
||||||
|
|
||||||
|
| Software type | Filter | Wired to | Side-effect |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `measurement` | any | `_subscribeMeasurement` | Subscribes to the measurement's emitter; updates basin balance |
|
||||||
|
| `machine` | only if no `machinegroup` parent is present | direct dispatch | Bypassed when an MGC is the predicted-flow source |
|
||||||
|
| `machinegroup` | any | `_subscribePredictedFlow` | Reads aggregated predicted flow from the MGC |
|
||||||
|
| `pumpingstation` | any | `_subscribePredictedFlow` | Cascaded PS — reads predicted outflow of upstream station |
|
||||||
|
|
||||||
|
The router only subscribes to the **highest-level aggregator** for predicted flow. If an MGC is present, direct `machine` children are not double-counted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit policy
|
||||||
|
|
||||||
|
Source: `nodes/pumpingStation/src/specificClass.js` lines 21–30.
|
||||||
|
|
||||||
|
| Quantity | Canonical (internal) | Output (rendered) |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| Flow | `m3/s` | `m3/s` (also `netFlowRate`) |
|
||||||
|
| Level | `m` | `m` |
|
||||||
|
| Volume | `m3` | `m3` |
|
||||||
|
| Pressure | `Pa` | (not surfaced) |
|
||||||
|
| Power | `W` | (not surfaced) |
|
||||||
|
| Temperature | `K` | (not surfaced) |
|
||||||
|
|
||||||
|
`overflowVolume` and `underflowVolume` are explicitly listed in the policy output so the `MeasurementContainer` keeps the integrator's `m3` unit on those streams (`FlowAggregator` writes spill / underflow per tick).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, state chart, lifecycle |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped example flows |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known limitations and open questions |
|
||||||
|
| [EVOLV — Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
|
||||||
|
| [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |
|
||||||
147
wiki/Reference-Examples.md
Normal file
147
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Reference — Examples
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Every example flow shipped under `nodes/pumpingStation/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/pumpingStation/examples/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped examples
|
||||||
|
|
||||||
|
| File | Tier | What it shows |
|
||||||
|
|:---|:---:|:---|
|
||||||
|
| `examples/01-Basic.json` | 1 | Single pumpingStation driven by inject nodes — no parent, no dashboard. Numbered driver groups for Mode / Flow signals / Operator demand / Calibration. |
|
||||||
|
| `examples/02-Dashboard.json` | 2 | Same command surface as Basic, driven by a FlowFuse Dashboard 2.0 page (Controls + live Status rows + 4 trend charts + raw-output table). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading a flow
|
||||||
|
|
||||||
|
### Via the editor
|
||||||
|
|
||||||
|
1. Open the Node-RED editor at `http://localhost:1880`.
|
||||||
|
2. Menu → Import.
|
||||||
|
3. Drag-and-drop the JSON file, or paste its contents.
|
||||||
|
4. Click Deploy.
|
||||||
|
|
||||||
|
### Via the Admin API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data @nodes/pumpingStation/examples/01-Basic.json \
|
||||||
|
http://localhost:1880/flow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example 01 — Basic standalone
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Nodes on the tab
|
||||||
|
|
||||||
|
| Type | Purpose |
|
||||||
|
|:---|:---|
|
||||||
|
| `comment` | Tab header / instructions |
|
||||||
|
| `inject` × 7 | Buttons to send `set.mode` (manual / levelbased), `set.inflow`, `set.outflow`, `set.demand`, `cmd.calibrate.volume`, `cmd.calibrate.level` |
|
||||||
|
| `pumpingStation` | The unit under test |
|
||||||
|
| `debug` × 3 | Port 0 (process), Port 1 (InfluxDB), Port 2 (parent reg) |
|
||||||
|
|
||||||
|
Driver injects are wrapped in four numbered groups: **1. Control mode**, **2. Flow signals (inflow / outflow)**, **3. Operator demand (manual mode only)**, **4. Calibration**. Debug nodes sit in a separate **Debug outputs (sidebar)** group on the right.
|
||||||
|
|
||||||
|
### What to do after deploy
|
||||||
|
|
||||||
|
1. (optional) Click `set.mode = manual` if you want `set.demand` to forward; otherwise leave it on the default `levelbased` and the ramp drives demand from level.
|
||||||
|
2. Click `set.inflow = 60 m³/h` — the basin starts filling. Watch Port 0 in the debug pane: `direction` flips to `filling`, `level` rises, predicted volume integrates.
|
||||||
|
3. In manual mode: click `set.demand = 40` — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.
|
||||||
|
4. Click `cmd.calibrate.volume = 25 m³` (or `cmd.calibrate.level = 1.5 m`) to snap the predicted-volume integrator.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **GIF needed.** Demo recording of steps 1–4. Save as `wiki/_partial-gifs/pumpingStation/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example 02 — Dashboard
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Screenshot needed.** Two captures from `02-Dashboard.json`:
|
||||||
|
> 1. The editor tab (left controls column + pumpingStation + Live-status group on the right).
|
||||||
|
> 2. The rendered dashboard at `http://localhost:1880/dashboard/pumpingstation-basic`.
|
||||||
|
>
|
||||||
|
> Save as `wiki/_partial-screenshots/pumpingStation/05-ex02-editor.png` and `06-ex02-dashboard.png`.
|
||||||
|
> Replace this callout with both image links.
|
||||||
|
|
||||||
|
### What it adds vs Example 01
|
||||||
|
|
||||||
|
| Addition | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| FlowFuse `ui-base` + `ui-theme` + `ui-page` setup | One dashboard page hosting four widget groups |
|
||||||
|
| `ui-button` × 7 (Controls group) | Replace the inject buttons one-for-one — each carries the canonical `msg.topic` directly |
|
||||||
|
| `ui-text` × 7 (Status group) | Live readouts: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand |
|
||||||
|
| `ui-chart` × 4 (Trends group) | Level (m), Volume (m³), Volume % (0–100), Flow (m³/h, multi-series Inflow / Outflow / Net) |
|
||||||
|
| `ui-template` (Raw output group) | Full key/value table of the latest Port 0 cache — every field the node emits, sorted |
|
||||||
|
| Fan-out function | Caches last-known values so delta-only Port 0 updates never blank a status row, and forwards numeric values to the charts |
|
||||||
|
|
||||||
|
The buttons fire the **same canonical `msg.topic`** as the inject nodes in Example 01 — there is no separate dashboard command surface to learn.
|
||||||
|
|
||||||
|
Required: `@flowfuse/node-red-dashboard` (Dashboard 2.0) installed in the Node-RED instance.
|
||||||
|
|
||||||
|
### What to do after deploy
|
||||||
|
|
||||||
|
1. Open `http://localhost:1880/dashboard/pumpingstation-basic`.
|
||||||
|
2. Click `Mode: Manual` or `Mode: Levelbased`.
|
||||||
|
3. Click `Inflow 60 m³/h` — Status panel level / volume / vol% rise; the Level / Volume / Flow charts plot the trends.
|
||||||
|
4. In manual mode click `Demand 40 m³/h` — `Manual demand` row updates, node badge appends `Qd=40 m³/h`.
|
||||||
|
5. Inspect the **Raw output** table at the bottom of the page for the full Port 0 surface (basin geometry, dryRunLevel, highVolumeSafetyLevel, predictedOverflowVolume, …).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **GIF needed.** Capture clicking through Mode → Inflow → Demand and the charts reacting. 20–30 s is enough.
|
||||||
|
>
|
||||||
|
> Save as `wiki/_partial-gifs/pumpingStation/02-ex02-dashboard.gif`.
|
||||||
|
> Replace this callout with the image link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker compose snippet
|
||||||
|
|
||||||
|
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml (extract)
|
||||||
|
services:
|
||||||
|
nodered:
|
||||||
|
build: ./docker/nodered
|
||||||
|
ports: ['1880:1880']
|
||||||
|
volumes:
|
||||||
|
- ./docker/nodered/data:/data/evolv
|
||||||
|
influxdb:
|
||||||
|
image: influxdb:2.7
|
||||||
|
ports: ['8086:8086']
|
||||||
|
```
|
||||||
|
|
||||||
|
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug recipes
|
||||||
|
|
||||||
|
| Symptom | First thing to check |
|
||||||
|
|:---|:---|
|
||||||
|
| Status badge stuck on `no data` | Did the level `measurement` child register? Tap Port 2 of the measurement with a `debug` node and confirm a `child.register` msg fires once at init. |
|
||||||
|
| Level rises but `volume` stays at `minVol` | Volume integrator hasn't been calibrated. Send `cmd.calibrate.level = <real level>` once. |
|
||||||
|
| Demand stays at 0 % even though level is high | Mode might be `manual` — check `set.mode`. Or the safety layer is blocking (look at `safety.blocked` on Port 0). |
|
||||||
|
| Predicted volume drifts | Net-flow source is wrong. Look at `flowSource` on Port 0; it should match the highest-level aggregator you have wired in. |
|
||||||
|
| `enableLog: 'debug'` floods the container log | Toggle it off in the node's config. Never ship a demo with debug logging enabled. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, state chart, lifecycle |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known limitations and open questions |
|
||||||
|
| [EVOLV — Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where this node fits in a larger plant |
|
||||||
104
wiki/Reference-Limitations.md
Normal file
104
wiki/Reference-Limitations.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Reference — Limitations
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> What `pumpingStation` does not do, current rough edges, and open questions tracked against the refactor. Live source for the open items: `.claude/refactor/OPEN_QUESTIONS.md` in the EVOLV superproject.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When you would not use this node
|
||||||
|
|
||||||
|
| Scenario | Use instead |
|
||||||
|
|:---|:---|
|
||||||
|
| Pressurised distribution network without a basin | Cascade pumpingStations, or a `valveGroupControl` parented to a flow source |
|
||||||
|
| Single pump, no basin, no level sensor | Parent a `rotatingMachine` directly under a UI driver |
|
||||||
|
| Air manifold (compressor + valves) | A future `compressorStation` — not implemented |
|
||||||
|
| Open-channel flow without a wet-well | Out of scope for the current basin model (rectangular prismatic only) |
|
||||||
|
| Sludge thickening basin | Use a `settler` — different settling-velocity model required |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
### Implemented modes vs schema modes
|
||||||
|
|
||||||
|
The schema's `control.mode` enum lists eight modes, but only two are implemented in code:
|
||||||
|
|
||||||
|
| Mode | Status | Notes |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `levelbased` | Implemented | Default; the most production-tested path |
|
||||||
|
| `manual` | Implemented | Operator's `set.demand` is forwarded unchanged |
|
||||||
|
| `flowbased` | Placeholder | Schema accepts it; runtime falls back to levelbased |
|
||||||
|
| `pressureBased` | Placeholder | Same as above |
|
||||||
|
| `percentageBased` | Placeholder | Same as above |
|
||||||
|
| `powerBased` | Placeholder | Same as above |
|
||||||
|
| `hybrid` | Placeholder | Same as above |
|
||||||
|
| `mpc` | Not in code | Reserved name |
|
||||||
|
|
||||||
|
If you select an unimplemented mode in the editor, the basin runs but the controller stays in level-based. Tracked.
|
||||||
|
|
||||||
|
### Basin shape
|
||||||
|
|
||||||
|
Only rectangular prismatic basins are supported. Cylindrical, frusto-conical, or stepped basins would need a new `BasinGeometry` implementation. The `volume = level * surfaceArea` relationship is hard-wired.
|
||||||
|
|
||||||
|
### Net-flow source selection
|
||||||
|
|
||||||
|
When both an MGC parent and direct rotatingMachine children are wired, the station subscribes only to the MGC's predicted flow. If you intentionally have MGC + extra individual pumps, the extras are invisible to the volume integrator. The router protects against double-counting but does not warn about this edge case.
|
||||||
|
|
||||||
|
### Aliases not yet removed
|
||||||
|
|
||||||
|
The following legacy aliases still work but log a deprecation warning on first use. They are scheduled for removal in Phase 7:
|
||||||
|
|
||||||
|
| Canonical | Legacy alias |
|
||||||
|
|:---|:---|
|
||||||
|
| `set.mode` | `changemode` |
|
||||||
|
| `set.inflow` | `q_in` |
|
||||||
|
| `set.outflow` | `q_out` |
|
||||||
|
| `set.demand` | `Qd` |
|
||||||
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` |
|
||||||
|
| `cmd.calibrate.level` | `calibratePredictedLevel` |
|
||||||
|
| `child.register` | `registerChild` |
|
||||||
|
|
||||||
|
Update integrations now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions (tracked)
|
||||||
|
|
||||||
|
Pulled from `.claude/refactor/OPEN_QUESTIONS.md`. Last reviewed on the date in the badge above.
|
||||||
|
|
||||||
|
| Question | Where it lives |
|
||||||
|
|:---|:---|
|
||||||
|
| `overfillVol` alias drop — same shape as the already-done `overfillLevel` drop | OPEN_QUESTIONS.md (pumpingStation entry) |
|
||||||
|
| Net-flow source warning when multiple aggregators are wired | Internal — not yet ticketed |
|
||||||
|
| Cylindrical basin geometry | Internal — not yet ticketed |
|
||||||
|
| Docker E2E sign-off (P2.14) | OPEN_QUESTIONS.md (Phase 6) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
### From pre-refactor
|
||||||
|
|
||||||
|
| Pre-refactor | Now |
|
||||||
|
|:---|:---|
|
||||||
|
| `enableOverfillProtection` | `enableHighVolumeSafety` |
|
||||||
|
| `overfillThresholdPercent` | `highVolumeSafetyThresholdPercent` |
|
||||||
|
| Legacy topics (`changemode`, `q_in`, ...) | Canonical topics (see [Reference — Contracts](Reference-Contracts) for the alias map) |
|
||||||
|
| `basic.flow.json` (legacy) | `01-Basic.json` (canonical-topic version) |
|
||||||
|
|
||||||
|
### Renamed safety thresholds
|
||||||
|
|
||||||
|
The safety layer used to expose threshold fields named `overfill*`. Those names suggested the layer prevents overflow specifically; in practice the rule handles high-volume conditions more broadly (high level + low inflow / outflow imbalance). The current names (`highVolumeSafety*`) reflect that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters (alias map at the end) |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, state chart |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped flows |
|
||||||
17
wiki/_Sidebar.md
Normal file
17
wiki/_Sidebar.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
### pumpingStation
|
||||||
|
|
||||||
|
- [Home](Home)
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
|
||||||
|
- [Contracts](Reference-Contracts)
|
||||||
|
- [Architecture](Reference-Architecture)
|
||||||
|
- [Examples](Reference-Examples)
|
||||||
|
- [Limitations](Reference-Limitations)
|
||||||
|
|
||||||
|
**Related**
|
||||||
|
|
||||||
|
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
|
||||||
|
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
|
||||||
|
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||||
|
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)
|
||||||
2
wiki/_partial-flows/pumpingStation/.gitkeep
Normal file
2
wiki/_partial-flows/pumpingStation/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Downloadable example flow JSONs.
|
||||||
|
# Canonical examples live under nodes/pumpingStation/examples/.
|
||||||
4
wiki/_partial-gifs/pumpingStation/.gitkeep
Normal file
4
wiki/_partial-gifs/pumpingStation/.gitkeep
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Dashboard interaction GIFs for pumpingStation.
|
||||||
|
# Naming: NN-short-description.gif
|
||||||
|
# Optimise with: gifsicle -O3 --lossy=80 in.gif -o out.gif
|
||||||
|
# Target <= 1 MB.
|
||||||
3
wiki/_partial-screenshots/pumpingStation/.gitkeep
Normal file
3
wiki/_partial-screenshots/pumpingStation/.gitkeep
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Node-RED editor screenshots for pumpingStation.
|
||||||
|
# Naming: NN-short-description.png
|
||||||
|
# See Home.md callouts.
|
||||||
BIN
wiki/_partial-screenshots/pumpingStation/01-node-and-editor.png
Normal file
BIN
wiki/_partial-screenshots/pumpingStation/01-node-and-editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
BIN
wiki/_partial-screenshots/pumpingStation/02-basic-flow.png
Normal file
BIN
wiki/_partial-screenshots/pumpingStation/02-basic-flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
@@ -1,377 +0,0 @@
|
|||||||
---
|
|
||||||
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 High-volume Safety** | `true` | If on, upstream inflows are shut down once volume climbs above the high-volume safety point while filling. |
|
|
||||||
| **High Volume Threshold (%)** | `98` | Safety margin below physical overflow: `highVolumeSafetyVol = maxVolAtOverflow × pct/100`. Actual overflowing is still the boolean condition `level >= overflowLevel`. |
|
|
||||||
|
|
||||||
### 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.predicted.overflow.default` | Synthetic spill rate over the weir while predicted volume is pinned at `maxVolAtOverflow` (m³/s). Zero when not spilling. Lives at its own position (not under `out`) so the operational outflow sum stays clean; `_selectBestNetFlow` folds it into the outflow side for net-flow balance, where it reads ~0 while pinned. |
|
|
||||||
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
|
|
||||||
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow − outflow). |
|
|
||||||
| `overflowVolume.predicted.atequipment.default` | Cumulative predicted spill volume (m³) — for compliance reporting via InfluxDB. Monotonically non-decreasing. |
|
|
||||||
| `underflowVolume.predicted.atequipment.default` | Cumulative volume the integrator tried to drive below 0 m³ (m³). Diagnostic only, NOT compliance — a non-zero value indicates a flow-balance error (over-reported outflow / missing inflow source / pump curve too optimistic). |
|
|
||||||
| `direction` | `filling` / `draining` / `steady` / `unknown`. |
|
|
||||||
| `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. |
|
|
||||||
| `predictedOverflowVolume` | Convenience top-level mirror of `overflowVolume.predicted.atequipment.default` (m³). |
|
|
||||||
| `predictedOverflowRate` | Convenience top-level mirror of `flow.predicted.overflow.default` (m³/s). |
|
|
||||||
| `predictedUnderflowVolume` | Convenience top-level mirror of `underflowVolume.predicted.atequipment.default` (m³). |
|
|
||||||
| `percControl` | Last demand (0–100+ %) forwarded to the machine group during level-based control. |
|
|
||||||
|
|
||||||
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 < inflowLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
|
|
||||||
|
|
||||||
`minLevel`, `startLevel`, and `maxLevel` are deliberately not part of this generic basin diagram. They belong to a control mode. For the current level-based mode variants, see [`diagrams/modes/level-based/`](diagrams/modes/level-based/).
|
|
||||||
|
|
||||||
The pipe labels are intentional:
|
|
||||||
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
### Predicted-volume bounds
|
|
||||||
|
|
||||||
The predicted-volume integrator is clamped between two physical limits. **Measured** values are never clamped — only a real sensor can show level outside this range (e.g. inflow exceeds pump+weir capacity and the basin pressurises against the ceiling).
|
|
||||||
|
|
||||||
**Upper bound — `maxVolAtOverflow`.** Once the integrator would push past the weir crest, the predicted level pins at `overflowLevel`. The excess is recorded two ways every tick it spills:
|
|
||||||
|
|
||||||
- **Cumulative `overflowVolume.predicted.atequipment.default`** — running total of spill in m³, for compliance reporting via InfluxDB.
|
|
||||||
- **Synthetic `flow.predicted.out.overflow`** — instantaneous spill rate (m³/s) equal to `inflow − real_outflow`. Registered as a predicted outflow contribution so `_selectBestNetFlow` sees a balanced ledger and reports `netFlowRate ≈ 0` while pinned. The integrator subtracts this synthetic flow before integrating so the spill never feeds back into the volume math.
|
|
||||||
|
|
||||||
The `isOverflowing` flag (true when `level >= overflowLevel`) is what tells operators why net flow reads zero even though water is still moving through the basin.
|
|
||||||
|
|
||||||
**Lower bound — `dryRunSafetyVol`.** The integrator can't drain below the dry-run threshold because pumps physically can't pump that low (the safety controller would shut them off, and even with safety disabled the suction loses prime). The clamp only fires on the transition — if the basin starts (or is calibrated) below `dryRunSafetyVol` it's left alone; inflow is what brings it back up.
|
|
||||||
|
|
||||||
### Level-rate fallback during overflow
|
|
||||||
|
|
||||||
When the chosen flow source is `level:measured` or `level:predicted` (priorities 3–4 in the ladder below), `dL/dt × surfaceArea` *is* the net flow. While level is pinned at `overflowLevel`, `dL/dt = 0` collapses the signal even though water is still moving. In that case `_selectBestNetFlow` holds the last known non-zero net flow until level starts dropping again — so dashboards keep a usable "this is roughly what's coming in" reading. The held value is refreshed any tick the level rate is meaningful, so it auto-updates once the basin un-pins.
|
|
||||||
|
|
||||||
## Net-flow selection
|
|
||||||
|
|
||||||
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`. `minLevel`, `startLevel`, and `maxLevel` are mode-specific and are documented with the mode diagrams, not the generic basin drawing.
|
|
||||||
|
|
||||||
Every mode gets its own page under [`modes/`](modes/README.md) with a consistent layout (inputs, threshold policy, demand formula, edge cases) so they can be compared side-by-side. Currently:
|
|
||||||
|
|
||||||
| 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 high-volume safety | Intended — high-volume safety only stops **upstream** equipment; downstream pumps must drain. | To override, switch to `manual` and set `Qd = 0`, or issue an emergency-stop at the MGC. |
|
|
||||||
| Predicted volume drifts away from measured | Flow integrator has no reference — flows might have the wrong sign, or `unit` is mis-declared. | Call `calibratePredictedVolume` periodically from a measured level. |
|
|
||||||
| Predicted level pinned at `overflowLevel` and `netFlowRate` reads ~0 | Intended while spilling — the synthetic `flow.predicted.out.overflow` balances the ledger so net is 0. Watch `isOverflowing`, `predictedOverflowRate`, and the cumulative `predictedOverflowVolume` instead. | Lower inflow (or raise pump capacity / `maxLevel`) to clear the overflow condition; level un-pins automatically. |
|
|
||||||
| Measured level above `overflowLevel` | Real-world ceiling-pressure case — inflow is exceeding pump *and* weir capacity. | This is the only path to "above overflow" in the model; predicted is clamped. Trust the sensor; treat as an alarmable event. |
|
|
||||||
|
|
||||||
## Running it locally
|
|
||||||
|
|
||||||
```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.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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) |
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
---
|
|
||||||
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 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 sources: [`../diagrams/modes/level-based/basin-mode-level-linear.drawio.svg`](../diagrams/modes/level-based/basin-mode-level-linear.drawio.svg) and [`../diagrams/modes/level-based/basin-mode-level-log.drawio.svg`](../diagrams/modes/level-based/basin-mode-level-log.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — they round-trip).*
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
|
|
||||||
| 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 | falling ramp reaches 0 % here; rising demand holds 0 % until the inlet level |
|
|
||||||
| `config.control.levelbased.maxLevel` | editor, static | where demand saturates at 100 % |
|
|
||||||
| `config.control.levelbased.curveType` | editor, static | `linear` or `log`; log is fast early response |
|
|
||||||
|
|
||||||
The three control thresholds plus curve type are the 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 |
|
|
||||||
| `curveType` | `config.control.levelbased.curveType` | No |
|
|
||||||
|
|
||||||
That this policy is trivial (all static) is **the defining simplicity of this mode**. Modes like `powerBased` or future `weather-aware` variants will recompute these thresholds on the fly.
|
|
||||||
|
|
||||||
## Demand formula
|
|
||||||
|
|
||||||
```text
|
|
||||||
if level < minLevel:
|
|
||||||
demand = 0
|
|
||||||
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
|
|
||||||
elif direction == filling:
|
|
||||||
demand = curve(level, [inflowLevel, maxLevel], [0 %, 100 %])
|
|
||||||
elif direction == draining:
|
|
||||||
demand = curve(level, [startLevel, maxLevel], [0 %, 100 %])
|
|
||||||
else:
|
|
||||||
demand = previous demand
|
|
||||||
```
|
|
||||||
|
|
||||||
Below the active lower ramp point, demand is 0 %. Above `maxLevel`, demand is 100 %. `curve` is either linear or logarithmic; the log variant rises faster early in the ramp. The MGC is free to distribute the demand across its pumps however its own policy dictates (equal split, lead-lag, staging — that's the MGC's business).
|
|
||||||
|
|
||||||
## Edge cases
|
|
||||||
|
|
||||||
- **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`)
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
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 high-volume safety layer still applies as the last line of defence before physical overflow.
|
|
||||||
|
|
||||||
## Edge cases
|
|
||||||
|
|
||||||
- **Peak hour with rising level.** demandCap drops faster than level rises → demand gets clipped; level approaches `overflowLevel`. If high-volume safety trips, it overrides the clip (safety wins).
|
|
||||||
- **Power signal dropout.** Fall back to static `maxPowerKW` from config; log warning.
|
|
||||||
- **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