Compare commits
19 Commits
03440e1e6c
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
177a37e15c | ||
|
|
089a7fa2c4 | ||
|
|
e47de87adb | ||
|
|
fc6491dc23 | ||
|
|
2fb083da63 | ||
|
|
4889fdaaf0 | ||
|
|
a83a85e958 | ||
|
|
e041877ae4 | ||
|
|
8216480950 | ||
|
|
dfaa0c3ae8 | ||
|
|
6e727d929b | ||
| ef07f2a5b2 | |||
|
|
2d68a4f504 | ||
|
|
a3536b7b7f | ||
|
|
f5c6282478 | ||
|
|
df18e97b8b | ||
|
|
2e4ad8d3f1 | ||
|
|
d4de3cf5c5 | ||
|
|
304df7f135 |
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
|
||||
@@ -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.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.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. |
|
||||
|
||||
Aliases log a one-time deprecation warning the first time they fire.
|
||||
@@ -24,8 +25,9 @@ Aliases log a one-time deprecation warning the first time they fire.
|
||||
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
|
||||
`'influxdb'` formatter.
|
||||
- **Port 2 (registration):** at startup the node sends one
|
||||
`{ topic: 'registerChild', payload: <node.id>, positionVsParent, distance }`
|
||||
to the upstream parent.
|
||||
`{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
|
||||
to the upstream parent (`child.register` is canonical; `registerChild` is the
|
||||
deprecated *input* alias, not what this node emits).
|
||||
|
||||
## Events emitted by `source.measurements.emitter`
|
||||
|
||||
|
||||
@@ -1,479 +1,360 @@
|
||||
[
|
||||
{
|
||||
"id": "77f00aef1c966167",
|
||||
"type": "tab",
|
||||
"label": "PumpingStation - Basic",
|
||||
"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."
|
||||
{
|
||||
"id": "ps_basic_tab",
|
||||
"type": "tab",
|
||||
"label": "PumpingStation - Basic",
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"p": "unit",
|
||||
"v": "m3/h",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"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 m3/h",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "40",
|
||||
"vt": "num"
|
||||
},
|
||||
{
|
||||
"p": "unit",
|
||||
"v": "m3/h",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"p": "unit",
|
||||
"v": "m3",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"p": "unit",
|
||||
"v": "m",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "aa3381b896eb2cfb",
|
||||
"type": "group",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "Pumping Station (Process Cell)",
|
||||
"style": {
|
||||
"label": true,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"nodes": [
|
||||
"ps_basic_node",
|
||||
"ps_basic_format"
|
||||
],
|
||||
"x": 1290,
|
||||
"y": 230,
|
||||
"w": 500,
|
||||
"h": 140
|
||||
}
|
||||
]
|
||||
@@ -166,8 +166,8 @@
|
||||
"id": "b30af582f935bcb7",
|
||||
"type": "comment",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "PumpingStation — Dashboard (Tier 2)",
|
||||
"info": "Same command surface as the Basic example, driven by a FlowFuse dashboard.\n\nOpen /dashboard/pumpingstation-basic after deploy.\n\nCONTROLS panel\n- Mode buttons → set.mode (manual / levelbased)\n- Inflow / Outflow buttons → set.inflow / set.outflow (60 / 80 m³/h)\n- Demand button → set.demand (40 m³/h, manual mode only)\n- Calibrate buttons → cmd.calibrate.volume / cmd.calibrate.level\n\nSTATUS panel\n- 7 text rows: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand\n\nTRENDS panel\n- 4 charts: Level (m), Volume (m³), Volume %, Flow (in/out/net m³/h)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest Port 0 cache (sorted). Shows every field the node emits including basin geometry, safety thresholds, predicted overflow/underflow.\n\nThe fan-out function caches last-known values so delta-only Port 0 updates never blank a row.",
|
||||
"name": "PumpingStation \u2014 Dashboard (Tier 2)",
|
||||
"info": "Same command surface as the Basic example, driven by a FlowFuse dashboard.\n\nOpen /dashboard/pumpingstation-basic after deploy.\n\nCONTROLS panel\n- Mode buttons \u2192 set.mode (manual / levelbased)\n- Inflow / Outflow buttons \u2192 set.inflow / set.outflow (60 / 80 m\u00b3/h)\n- Demand button \u2192 set.demand (40 m\u00b3/h, manual mode only)\n- Calibrate buttons \u2192 cmd.calibrate.volume / cmd.calibrate.level\n\nSTATUS panel\n- 7 text rows: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand\n\nTRENDS panel\n- 4 charts: Level (m), Volume (m\u00b3), Volume %, Flow (in/out/net m\u00b3/h)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest Port 0 cache (sorted). Shows every field the node emits including basin geometry, safety thresholds, predicted overflow/underflow.\n\nThe fan-out function caches last-known values so delta-only Port 0 updates never blank a row.",
|
||||
"x": 660,
|
||||
"y": 320,
|
||||
"wires": []
|
||||
@@ -332,13 +332,13 @@
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "a9f9b38b0e00c1d7",
|
||||
"group": "ui_group_ctrl",
|
||||
"name": "Inflow 60 m³/h",
|
||||
"label": "Inflow 60 m³/h",
|
||||
"name": "Inflow 60 m\u00b3/h",
|
||||
"label": "Inflow 60 m\u00b3/h",
|
||||
"order": 3,
|
||||
"width": "3",
|
||||
"height": "1",
|
||||
"emulateClick": false,
|
||||
"tooltip": "Push a measured inflow of 60 m³/h into the basin balance",
|
||||
"tooltip": "Push a measured inflow of 60 m\u00b3/h into the basin balance",
|
||||
"color": "",
|
||||
"bgcolor": "",
|
||||
"icon": "south",
|
||||
@@ -360,13 +360,13 @@
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "a9f9b38b0e00c1d7",
|
||||
"group": "ui_group_ctrl",
|
||||
"name": "Outflow 80 m³/h",
|
||||
"label": "Outflow 80 m³/h",
|
||||
"name": "Outflow 80 m\u00b3/h",
|
||||
"label": "Outflow 80 m\u00b3/h",
|
||||
"order": 4,
|
||||
"width": "3",
|
||||
"height": "1",
|
||||
"emulateClick": false,
|
||||
"tooltip": "Push a measured outflow of 80 m³/h into the basin balance",
|
||||
"tooltip": "Push a measured outflow of 80 m\u00b3/h into the basin balance",
|
||||
"color": "",
|
||||
"bgcolor": "",
|
||||
"icon": "north",
|
||||
@@ -388,13 +388,13 @@
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "42bf82c87d05f498",
|
||||
"group": "ui_group_ctrl",
|
||||
"name": "Demand 40 m³/h",
|
||||
"label": "Demand 40 m³/h (manual)",
|
||||
"name": "Demand 40 m\u00b3/h",
|
||||
"label": "Demand 40 m\u00b3/h (manual)",
|
||||
"order": 5,
|
||||
"width": "6",
|
||||
"height": "1",
|
||||
"emulateClick": false,
|
||||
"tooltip": "Operator outflow demand — only forwarded when mode = manual",
|
||||
"tooltip": "Operator outflow demand \u2014 only forwarded when mode = manual",
|
||||
"color": "",
|
||||
"bgcolor": "",
|
||||
"icon": "speed",
|
||||
@@ -416,13 +416,13 @@
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "234bdce20170061a",
|
||||
"group": "ui_group_ctrl",
|
||||
"name": "Calibrate V=25 m³",
|
||||
"label": "Calibrate V = 25 m³",
|
||||
"name": "Calibrate V=25 m\u00b3",
|
||||
"label": "Calibrate V = 25 m\u00b3",
|
||||
"order": 6,
|
||||
"width": "3",
|
||||
"height": "1",
|
||||
"emulateClick": false,
|
||||
"tooltip": "Snap the predicted-volume integrator to 25 m³",
|
||||
"tooltip": "Snap the predicted-volume integrator to 25 m\u00b3",
|
||||
"color": "",
|
||||
"bgcolor": "",
|
||||
"icon": "tune",
|
||||
@@ -472,8 +472,8 @@
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "grp_status_panel",
|
||||
"name": "fan-out Port 0 (status + charts + raw)",
|
||||
"func": "// Port 0 emits delta-only — cache last-known so deltas never blank a row.\n// Keys with dots use the runtime childId (= node id), so we pattern-match\n// by prefix rather than hardcoding.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\nconst findByPrefix = (prefix) => {\n for (const k of Object.keys(cache)) if (k.startsWith(prefix)) return cache[k];\n return null;\n};\nconst num = (v, dp, unit) => {\n const n = +v;\n if (!Number.isFinite(n)) return '—';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\n\nconst level = findByPrefix('level.predicted.atequipment.');\nconst volume = findByPrefix('volume.predicted.atequipment.');\nconst volPct = findByPrefix('volumePercent.predicted.atequipment.');\nconst qInS = findByPrefix('flow.predicted.in.');\nconst qOutS = findByPrefix('flow.predicted.out.');\nconst qNetS = findByPrefix('netFlowRate.predicted.atequipment.');\nconst qInH = Number.isFinite(+qInS) ? +qInS * 3600 : null;\nconst qOutH = Number.isFinite(+qOutS) ? +qOutS * 3600 : null;\nconst qNetH = Number.isFinite(+qNetS) ? +qNetS * 3600 : null;\nconst pct = cache.percControl;\nconst dem = cache.manualDemand;\nconst mode = cache.mode || '—';\nconst dir = cache.direction || '—';\n\nconst chart = (topic, v) => Number.isFinite(+v) ? { topic, payload: +v } : null;\n\n// Raw view: every cached key, sorted, with values prettified for display.\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '—';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0–6: status text widgets\n { payload: mode },\n { payload: dir },\n { payload: num(level, 2, 'm') },\n { payload: num(volume, 2, 'm³') },\n { payload: num(volPct, 2, '%') },\n { payload: num(pct, 1, '%') },\n { payload: mode === 'manual'\n ? (Number.isFinite(+dem) ? num(dem, 1, 'm³/h') : 'not set')\n : '—' },\n // 7–9: single-series charts\n chart('Level', level),\n chart('Volume', volume),\n chart('Volume %', volPct),\n // 10–12: flow chart (three series share the same chart node)\n chart('Inflow', qInH),\n chart('Outflow', qOutH),\n chart('Net', qNetH),\n // 13: raw key/value rows for the ui-template\n { payload: rawRows },\n];\n",
|
||||
"outputs": 14,
|
||||
"func": "// Port 0 emits delta-only \u2014 cache last-known so deltas never blank a row.\n// Keys with dots use the runtime childId (= node id), so we pattern-match\n// by prefix rather than hardcoding.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\nconst findByPrefix = (prefix) => {\n for (const k of Object.keys(cache)) if (k.startsWith(prefix)) return cache[k];\n return null;\n};\nconst num = (v, dp, unit) => {\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\n\nconst level = findByPrefix('level.predicted.atequipment.');\nconst volume = findByPrefix('volume.predicted.atequipment.');\nconst volPct = findByPrefix('volumePercent.predicted.atequipment.');\nconst qInS = findByPrefix('flow.predicted.in.');\nconst qOutS = findByPrefix('flow.predicted.out.');\nconst qNetS = findByPrefix('netFlowRate.predicted.atequipment.');\nconst qInH = Number.isFinite(+qInS) ? +qInS * 3600 : null;\nconst qOutH = Number.isFinite(+qOutS) ? +qOutS * 3600 : null;\nconst qNetH = Number.isFinite(+qNetS) ? +qNetS * 3600 : null;\nconst pct = cache.percControl;\nconst dem = cache.manualDemand;\nconst mode = cache.mode || '\u2014';\nconst dir = cache.direction || '\u2014';\n\nconst chart = (topic, v) => Number.isFinite(+v) ? { topic, payload: +v } : null;\n\n// Raw view: every cached key, sorted, with values prettified for display.\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '\u2014';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0\u20136: status text widgets\n { payload: mode },\n { payload: dir },\n { payload: num(level, 2, 'm') },\n { payload: num(volume, 2, 'm\u00b3') },\n { payload: num(volPct, 2, '%') },\n { payload: num(pct, 1, '%') },\n { payload: mode === 'manual'\n ? (Number.isFinite(+dem) ? num(dem, 1, 'm\u00b3/h') : 'not set')\n : '\u2014' },\n // 7\u20139: single-series charts\n chart('Level', level),\n chart('Volume', volume),\n chart('Volume %', volPct),\n // 10\u201312: flow chart (three series share the same chart node)\n chart('Inflow', qInH),\n chart('Outflow', qOutH),\n chart('Net', qNetH),\n // 13: raw key/value rows for the ui-template\n { payload: rawRows },\n // 14: percControl chart\n chart('percControl', pct),\n];\n",
|
||||
"outputs": 15,
|
||||
"timeout": 0,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
@@ -523,6 +523,9 @@
|
||||
],
|
||||
[
|
||||
"ui_tpl_raw"
|
||||
],
|
||||
[
|
||||
"ui_chart_pumping_perccontrol"
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -740,8 +743,8 @@
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "grp_status_panel",
|
||||
"group": "ui_group_trends",
|
||||
"name": "Volume (m³)",
|
||||
"label": "Volume (m³)",
|
||||
"name": "Volume (m\u00b3)",
|
||||
"label": "Volume (m\u00b3)",
|
||||
"order": 2,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
@@ -754,7 +757,7 @@
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"yAxisLabel": "m³",
|
||||
"yAxisLabel": "m\u00b3",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"xmin": "",
|
||||
@@ -862,8 +865,8 @@
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "grp_status_panel",
|
||||
"group": "ui_group_trends",
|
||||
"name": "Flow (m³/h)",
|
||||
"label": "Flow (m³/h) — Inflow / Outflow / Net",
|
||||
"name": "Flow (m\u00b3/h)",
|
||||
"label": "Flow (m\u00b3/h) \u2014 Inflow / Outflow / Net",
|
||||
"order": 4,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
@@ -876,7 +879,7 @@
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"yAxisLabel": "m³/h",
|
||||
"yAxisLabel": "m\u00b3/h",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"xmin": "",
|
||||
@@ -1029,7 +1032,7 @@
|
||||
"enableLog": false,
|
||||
"logLevel": "error",
|
||||
"positionVsParent": "atEquipment",
|
||||
"positionIcon": "⊥",
|
||||
"positionIcon": "\u22a5",
|
||||
"hasDistance": false,
|
||||
"distance": "",
|
||||
"controlMode": "levelbased",
|
||||
@@ -1066,5 +1069,68 @@
|
||||
"modules": {
|
||||
"EVOLV": "1.0.29"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ui_chart_pumping_perccontrol",
|
||||
"type": "ui-chart",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "grp_status_panel",
|
||||
"group": "ui_group_trends",
|
||||
"name": "percControl",
|
||||
"label": "percControl (%) \u2014 pumping-station demand",
|
||||
"order": 5,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
"chartType": "line",
|
||||
"category": "topic",
|
||||
"categoryType": "msg",
|
||||
"xAxisLabel": "time",
|
||||
"xAxisType": "time",
|
||||
"xAxisProperty": "",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"yAxisLabel": "%",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"xmin": "",
|
||||
"xmax": "",
|
||||
"ymin": "0",
|
||||
"ymax": "100",
|
||||
"removeOlder": "15",
|
||||
"removeOlderUnit": "60",
|
||||
"removeOlderPoints": "",
|
||||
"bins": 10,
|
||||
"action": "append",
|
||||
"stackSeries": false,
|
||||
"pointShape": "circle",
|
||||
"pointRadius": 4,
|
||||
"interpolation": "linear",
|
||||
"showLegend": false,
|
||||
"className": "",
|
||||
"colors": [
|
||||
"#A347E1",
|
||||
"#FF0000",
|
||||
"#FF7F0E",
|
||||
"#2CA02C",
|
||||
"#0095FF",
|
||||
"#D62728",
|
||||
"#FF9896",
|
||||
"#9467BD",
|
||||
"#C5B0D5"
|
||||
],
|
||||
"textColor": [
|
||||
"#666666"
|
||||
],
|
||||
"textColorDefault": true,
|
||||
"gridColor": [
|
||||
"#e5e5e5"
|
||||
],
|
||||
"gridColorDefault": true,
|
||||
"x": 1240,
|
||||
"y": 560,
|
||||
"wires": [
|
||||
[]
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -79,8 +79,12 @@ These flows follow the EVOLV layout rule set in
|
||||
- **Group boxes** wrap each parent + its direct children, coloured by the
|
||||
parent's S88 level.
|
||||
|
||||
## Regenerating
|
||||
## Maintaining
|
||||
|
||||
The current example JSON files are hand-maintained. If you re-introduce a
|
||||
generator, regenerate `01-Basic.json` and `02-Dashboard.json` from it
|
||||
rather than editing the JSON directly.
|
||||
These example flows are **hand-authored one-offs** — edit the JSON directly.
|
||||
There is intentionally no generator: examples are illustrative, not produced in
|
||||
bulk. Validate any change with `flow-lint`:
|
||||
|
||||
```bash
|
||||
node ../../../tools/flow-lint/bin/flow-lint.js 01-Basic.json 02-Dashboard.json
|
||||
```
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<script>//test
|
||||
RED.nodes.registerType("pumpingStation", {
|
||||
category: "EVOLV",
|
||||
color: "#0c99d9", // color for the node based on the S88 schema
|
||||
color: "#8B4513",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
shiftArmPercent: { value: 95 },
|
||||
startLevel: { value: 1 }, // m, pump-on threshold (engagement edge)
|
||||
stopLevel: { value: 0.5 }, // m, pump-off threshold (hysteresis fall-back)
|
||||
holdLevel: { value: 1 }, // m, ramp 0%-foot; defaults to startLevel (= no hold zone)
|
||||
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 },
|
||||
@@ -418,6 +420,11 @@
|
||||
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</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><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
|
||||
<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-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-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-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" />
|
||||
@@ -565,6 +573,7 @@
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="frost">frost</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
//
|
||||
// Invariants enforced (level-space, bottom → top):
|
||||
// 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.
|
||||
// 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 { 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 = [
|
||||
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
|
||||
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||
...(holdLevelProvided ? [
|
||||
['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel],
|
||||
['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||
] : []),
|
||||
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
|
||||
];
|
||||
|
||||
|
||||
@@ -48,42 +48,29 @@ exports.calibrateLevel = (source, msg, ctx) => {
|
||||
source.calibratePredictedLevel(v);
|
||||
};
|
||||
|
||||
exports.setInflow = (source, msg) => {
|
||||
// Payload is either a number (legacy q_in shape) or
|
||||
// { value, unit, timestamp } (richer object form).
|
||||
const p = msg.payload;
|
||||
let value;
|
||||
let unit;
|
||||
let timestamp;
|
||||
if (p !== null && typeof p === 'object') {
|
||||
value = Number(p.value);
|
||||
unit = p.unit;
|
||||
timestamp = p.timestamp || Date.now();
|
||||
} else {
|
||||
value = Number(p);
|
||||
unit = msg?.unit;
|
||||
timestamp = msg?.timestamp || Date.now();
|
||||
// The registry has already normalised any accepted shape (number, numeric
|
||||
// string, or { value, unit } object) to a number in the descriptor unit
|
||||
// (m3/h) and tagged msg.unit. Handlers just read the normalised scalar.
|
||||
exports.setInflow = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const value = Number(msg.payload);
|
||||
if (!Number.isFinite(value)) {
|
||||
log?.warn?.(`set.inflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
|
||||
return;
|
||||
}
|
||||
source.setManualInflow(value, timestamp, unit);
|
||||
source.setManualInflow(value, msg.timestamp, msg.unit);
|
||||
};
|
||||
|
||||
exports.setOutflow = (source, msg) => {
|
||||
// Manual q_out — basin-docs dashboard injects a drain rate without
|
||||
// wiring a real pump. Same payload shape as q_in.
|
||||
const p = msg.payload;
|
||||
let value;
|
||||
let unit;
|
||||
let timestamp;
|
||||
if (p !== null && typeof p === 'object') {
|
||||
value = Number(p.value);
|
||||
unit = p.unit;
|
||||
timestamp = p.timestamp || Date.now();
|
||||
} else {
|
||||
value = Number(p);
|
||||
unit = msg?.unit;
|
||||
timestamp = msg?.timestamp || Date.now();
|
||||
exports.setOutflow = (source, msg, ctx) => {
|
||||
// Manual q_out — basin-docs dashboard injects a drain rate without wiring a
|
||||
// real pump. Same normalised shape as set.inflow.
|
||||
const log = _logger(source, ctx);
|
||||
const value = Number(msg.payload);
|
||||
if (!Number.isFinite(value)) {
|
||||
log?.warn?.(`set.outflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
|
||||
return;
|
||||
}
|
||||
source.setManualOutflow(value, timestamp, unit);
|
||||
source.setManualOutflow(value, msg.timestamp, msg.unit);
|
||||
};
|
||||
|
||||
exports.setDemand = (source, msg, ctx) => {
|
||||
|
||||
@@ -26,9 +26,10 @@ module.exports = [
|
||||
{
|
||||
topic: 'cmd.calibrate.volume',
|
||||
aliases: ['calibratePredictedVolume'],
|
||||
// any: payload may be a number or numeric string.
|
||||
// any: payload may be a number, numeric string, or { value, unit } object —
|
||||
// the registry normalises all of them to a number in `unit` before the handler.
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'volume', default: 'm3' },
|
||||
unit: 'm3',
|
||||
description: 'Calibrate the predicted-volume integrator to a known basin volume.',
|
||||
handler: handlers.calibrateVolume,
|
||||
},
|
||||
@@ -36,16 +37,15 @@ module.exports = [
|
||||
topic: 'cmd.calibrate.level',
|
||||
aliases: ['calibratePredictedLevel'],
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'length', default: 'm' },
|
||||
unit: 'm',
|
||||
description: 'Calibrate the predicted-volume integrator to a known basin level.',
|
||||
handler: handlers.calibrateLevel,
|
||||
},
|
||||
{
|
||||
topic: 'set.inflow',
|
||||
aliases: ['q_in'],
|
||||
// any: number, numeric string, or { value, unit, timestamp } object.
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||
unit: 'm3/h',
|
||||
description: 'Push a measured inflow value into the basin balance.',
|
||||
handler: handlers.setInflow,
|
||||
},
|
||||
@@ -53,7 +53,7 @@ module.exports = [
|
||||
topic: 'set.outflow',
|
||||
aliases: ['q_out'],
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||
unit: 'm3/h',
|
||||
description: 'Push a measured outflow value into the basin balance.',
|
||||
handler: handlers.setOutflow,
|
||||
},
|
||||
@@ -61,7 +61,7 @@ module.exports = [
|
||||
topic: 'set.demand',
|
||||
aliases: ['Qd'],
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||
unit: 'm3/h',
|
||||
description: 'Operator outflow demand setpoint for the station.',
|
||||
handler: handlers.setDemand,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
// through the dead band [stopLevel, startLevel] emitting a small
|
||||
// keep-alive demand so MGC keeps a single pump draining the basin.
|
||||
// 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
|
||||
// shiftArmPercent the strategy ARMS; on the next filling→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) {
|
||||
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
||||
await Promise.all(
|
||||
Object.values(machineGroups).map((group) =>
|
||||
group.handleInput('parent', percentControl).catch((err) => {
|
||||
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
// The caller (run() below) already gated turn-off via the minLevel
|
||||
// hard-stop, stopLevel falling-edge, and the rising-edge engagement gate.
|
||||
// By the time we get here, pumps should be running — `0 %` is the engaged
|
||||
// "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) {
|
||||
@@ -118,6 +128,8 @@ async function run(ctx, controlState, direction) {
|
||||
controlState.percControl = 0;
|
||||
if (host) {
|
||||
host._stopHystRunning = false;
|
||||
host._shiftArmed = false;
|
||||
host._shiftHoldValue = null;
|
||||
host._lastDirection = direction;
|
||||
}
|
||||
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
|
||||
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
|
||||
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
|
||||
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
|
||||
// 3. Engagement gate. Pumps stay OFF until level rises through startLevel
|
||||
// for the first time (rising-edge); once engaged they stay on until
|
||||
// level drops through stopLevel (falling-edge — handled by case 2).
|
||||
// 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);
|
||||
|
||||
// 4. Shifted-ramp arming.
|
||||
// 5. Shifted-ramp arming.
|
||||
if (host) {
|
||||
if (cfg.enableShiftedRamp) {
|
||||
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
||||
@@ -177,10 +214,14 @@ async function run(ctx, controlState, direction) {
|
||||
let percControl;
|
||||
if (!inDrainingHold) {
|
||||
if (level < rampFoot) {
|
||||
// While engaged via stopLevel hysteresis AND inside the dead band
|
||||
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
|
||||
// single pump running.
|
||||
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
|
||||
// Engaged (we passed the gate above) but below the ramp foot. Two
|
||||
// sub-cases:
|
||||
// (a) Inside the configurable hold band [startLevel, holdLevel] —
|
||||
// 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))
|
||||
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
||||
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}`
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ async function run() {
|
||||
}
|
||||
|
||||
async function forwardDemand(ctx, demand) {
|
||||
const { machineGroups, machines, logger } = ctx;
|
||||
const { machineGroups, machines, unitPolicy, logger } = ctx;
|
||||
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
||||
|
||||
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
||||
const groupDemand = unitPolicy.convert(demand, 'm3/h', 'm3/s', 'manual demand to machineGroups');
|
||||
await Promise.all(
|
||||
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}`);
|
||||
})
|
||||
)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
// ≤-checks below are skipped rather than false-flagged).
|
||||
const basinHraw = fNum('basinHeight');
|
||||
const start = fNum('startLevel');
|
||||
const hold = fNum('holdLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
const ovfl = fNum('overflowLevel');
|
||||
@@ -154,8 +155,12 @@
|
||||
issues.push('outflowLevel must be > 0');
|
||||
if (!ok(dryLvl, start, '<'))
|
||||
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
|
||||
if (!ok(start, inlet, '<='))
|
||||
issues.push('startLevel must be ≤ inflowLevel');
|
||||
if (!ok(start, max, '<'))
|
||||
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, '<='))
|
||||
issues.push('inflowLevel must be ≤ maxLevel');
|
||||
if (!ok(max, ovfl, '<='))
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
// the current values of related inputs, so the up/down arrows stop at
|
||||
// values that respect the basin hierarchy:
|
||||
//
|
||||
// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
|
||||
// ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
|
||||
// 0 < outflowLevel < dryRunLevel < startLevel < 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
|
||||
// min/max only constrain the spinner). The validation ribbons in
|
||||
@@ -52,10 +58,10 @@
|
||||
|
||||
setBounds('startLevel',
|
||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||
inlet ?? max ?? overflow ?? basinHeight);
|
||||
max ?? overflow ?? basinHeight);
|
||||
|
||||
setBounds('inflowLevel',
|
||||
start ?? EPS,
|
||||
EPS,
|
||||
max ?? overflow ?? basinHeight);
|
||||
|
||||
setBounds('maxLevel',
|
||||
@@ -73,6 +79,14 @@
|
||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||
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).
|
||||
if (shiftEnabled) {
|
||||
setBounds('shiftLevel',
|
||||
|
||||
@@ -11,10 +11,13 @@
|
||||
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) => {
|
||||
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.
|
||||
|
||||
@@ -23,13 +23,16 @@
|
||||
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
||||
if (!svg) return;
|
||||
const start = fNum('startLevel');
|
||||
const hold = fNum('holdLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
// Optional stopLevel — explicit pump-off threshold. Drawn as its
|
||||
// own marker line; does NOT shift the ramp foot. Must be < startLevel
|
||||
// for the marker to render.
|
||||
// own marker line; does NOT shift the ramp foot. Renders as long as
|
||||
// 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 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%
|
||||
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
||||
// we draw it as the leftmost vertical marker so the user sees
|
||||
@@ -91,18 +94,17 @@
|
||||
};
|
||||
|
||||
// Up curve. Engagement edge is startLevel (pump-on threshold); the
|
||||
// ramp foot is inflowLevel — matching the runtime in
|
||||
// _controlLevelBased, which scales demand over [inflowLevel, maxLevel].
|
||||
// The OFF baseline is drawn for level < startLevel; between startLevel
|
||||
// and inflowLevel demand sits flat at 0 % (system armed but not yet
|
||||
// ramping); from inflowLevel demand ramps to 100 % at maxLevel.
|
||||
// ramp foot is holdLevel, with a Math.max(startLevel, …) safety
|
||||
// floor — matching the runtime in levelBased.run.
|
||||
// - 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 down = document.getElementById('ps-mode-curve-down');
|
||||
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
||||
// Runtime falls back to startLevel when inflowLevel is missing
|
||||
// (basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel); mirror that
|
||||
// in the preview so the curve is still drawn instead of blank.
|
||||
const upFoot = Number.isFinite(inlet) && inlet > start ? inlet : start;
|
||||
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
|
||||
@@ -167,6 +169,7 @@
|
||||
['dryRunLevel', dryRun],
|
||||
['startLevel', start],
|
||||
['stopLevel', stop],
|
||||
['holdLevel', hold],
|
||||
['inflowLevel', inlet],
|
||||
['maxLevel', max],
|
||||
['overflowLevel', overflow],
|
||||
|
||||
@@ -65,6 +65,17 @@
|
||||
|
||||
// Numeric field defaults.
|
||||
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-logCurveFactor', node.logCurveFactor);
|
||||
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
||||
@@ -77,16 +88,22 @@
|
||||
const shiftCheckbox = document.getElementById('node-input-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(
|
||||
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
|
||||
'startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
|
||||
ns.basinDiagram.redraw
|
||||
);
|
||||
ns.bindRedraw(
|
||||
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
|
||||
// so the mode preview must redraw when either of those change.
|
||||
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
['startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
|
||||
'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
'dryRunThresholdPercent',
|
||||
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
||||
'shiftArmPercent'],
|
||||
@@ -97,7 +114,7 @@
|
||||
// so the next redraw + validation sees the correct min/max attrs.
|
||||
ns.bindRedraw(
|
||||
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
||||
'inflowLevel', 'startLevel', 'outflowLevel',
|
||||
'inflowLevel', 'startLevel', 'stopLevel', 'holdLevel', 'outflowLevel',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
||||
() => ns.bounds?.apply()
|
||||
|
||||
@@ -50,6 +50,15 @@
|
||||
node.logCurveFactor = parseNum('node-input-logCurveFactor');
|
||||
node.startLevel = parseNum('node-input-startLevel');
|
||||
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
|
||||
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const flowUnit = 'm3/s';
|
||||
const now = Date.now();
|
||||
@@ -64,8 +90,13 @@ class FlowAggregator {
|
||||
// Synthetic spill flow lives at its OWN position ('overflow') —
|
||||
// not as a child of 'out'. That keeps it out of the operational
|
||||
// outflow sum here so no self-subtraction is needed.
|
||||
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
||||
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||
// Inflow + outflow are resolved per-side: a real measured upstream
|
||||
// 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 };
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class MeasurementRouter {
|
||||
|
||||
onLevelMeasurement(position, value, context = {}) {
|
||||
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 levelMeters = series.getCurrentValue('m');
|
||||
|
||||
@@ -37,6 +37,7 @@ class nodeClass extends BaseNodeAdapter {
|
||||
minLevel: uiConfig.minLevel,
|
||||
startLevel: uiConfig.startLevel,
|
||||
stopLevel: uiConfig.stopLevel,
|
||||
holdLevel: uiConfig.holdLevel,
|
||||
maxLevel: uiConfig.maxLevel,
|
||||
// Editor names the field levelCurveType; runtime uses curveType.
|
||||
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
||||
@@ -44,6 +45,7 @@ class nodeClass extends BaseNodeAdapter {
|
||||
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
||||
shiftLevel: uiConfig.shiftLevel,
|
||||
shiftArmPercent: uiConfig.shiftArmPercent,
|
||||
deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent,
|
||||
},
|
||||
},
|
||||
safety: {
|
||||
|
||||
@@ -18,8 +18,14 @@ class PumpingStation extends BaseDomain {
|
||||
static name = 'pumpingStation';
|
||||
|
||||
// Internal math runs in m3/s for flow and m for level so the volume
|
||||
// integrator (flow × dt) is unit-consistent. Strict canonicals make
|
||||
// unit drift in child-fed measurements an explicit error.
|
||||
// integrator (flow × dt) is unit-consistent — canonical stays m3/s, the
|
||||
// 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
|
||||
// MeasurementContainer keeps the integrator's m³ unit on those streams
|
||||
// (FlowAggregator writes spill / underflow per tick).
|
||||
@@ -146,6 +152,7 @@ class PumpingStation extends BaseDomain {
|
||||
levelVariants: this.levelVariants,
|
||||
volVariants: this.volVariants,
|
||||
flowThreshold: this.flowThreshold,
|
||||
unitPolicy: this.unitPolicy,
|
||||
host: this,
|
||||
};
|
||||
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
|
||||
@@ -262,7 +269,7 @@ class PumpingStation extends BaseDomain {
|
||||
};
|
||||
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
|
||||
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
|
||||
const netFlowM3h = (this.state?.netFlow ?? 0) * 3600;
|
||||
const netFlowM3h = this.unitPolicy.convert(this.state?.netFlow ?? 0, 'm3/s', 'm3/h', 'status badge netFlow');
|
||||
const mode = this.mode || '?';
|
||||
const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
|
||||
? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
|
||||
@@ -285,14 +292,32 @@ class PumpingStation extends BaseDomain {
|
||||
const measurementType = child.config.asset.type;
|
||||
const eventName = `${measurementType}.measured.${position}`;
|
||||
|
||||
child.measurements.emitter.on(eventName, (eventData = {}) => {
|
||||
const handle = (eventData = {}) => {
|
||||
this.logger.debug(
|
||||
`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)
|
||||
.value(eventData.value, eventData.timestamp, eventData.unit);
|
||||
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) {
|
||||
|
||||
101
test/_output-manifest.md
Normal file
101
test/_output-manifest.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# pumpingStation output manifest
|
||||
|
||||
> Single source of truth for **what this node emits and where it is tested**, per
|
||||
> [`.claude/rules/output-coverage.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/output-coverage.md).
|
||||
> Generated against code-ref `a83a85e`. Regenerate the wiki contract with
|
||||
> `npm run wiki:all` and re-check this table whenever `getOutput()`,
|
||||
> `src/commands/index.js`, or an `examples/*.json` fan-out changes.
|
||||
|
||||
**Null convention for this node:** a Port-0 key whose source is not yet
|
||||
available is emitted as **explicit `null`** (e.g. `timeleft`, `flowSource`,
|
||||
`manualDemand` outside manual mode), never silently absent. Delta-compression on
|
||||
Port 0 then drops keys whose value is unchanged since the previous tick.
|
||||
|
||||
## Port 0 (process data) — `specificClass.getOutput()` → `outputUtils.formatMsg(..., 'process')`
|
||||
|
||||
`msg.topic = config.general.name`. Keys below are the full pre-delta-compression set.
|
||||
|
||||
| Key | Source | Type | States tested | Test file |
|
||||
|---|---|---|---|---|
|
||||
| `mode` | `getOutput` ← `this.mode` | string (`levelbased`/`manual`/`flowbased`/`none`) | populated (`manual`) | test/basic/specificClass.test.js |
|
||||
| `manualDemand` | `getOutput` ← `_manualDemand` | number m³/h, `null` outside manual | populated, null | test/basic/specificClass.test.js |
|
||||
| `direction` | `getOutput` ← `state.direction` | string (`filling`/`draining`/`steady`) | present | test/basic/specificClass.test.js |
|
||||
| `flowSource` | `getOutput` ← `state.flowSource` | string, `null` when no source | null (pre-child) | test/basic/specificClass.test.js |
|
||||
| `timeleft` | `getOutput` ← `state.seconds` | number s, `null` when steady | present, null | test/basic/specificClass.test.js |
|
||||
| `percControl` | `getOutput` ← `controlState.percControl` | number % 0..100 | 0, 25, 50, 75, 85, 100 | test/basic/specificClass.test.js |
|
||||
| `dryRunLevel` | `_computeSafetyPoints` | number m | populated | test/basic/specificClass.test.js |
|
||||
| `dryRunSafetyVol` | `_computeSafetyPoints` | number m³ | populated | test/basic/specificClass.test.js |
|
||||
| `highVolumeSafetyLevel` | `_computeSafetyPoints` | number m | populated | test/basic/specificClass.test.js |
|
||||
| `highVolumeSafetyVol` | `_computeSafetyPoints` | number m³ | populated | test/basic/specificClass.test.js |
|
||||
| `predictedOverflowVolume` | `measurements` overflowVolume | number m³ | populated, 0 | test/basic/specificClass.test.js |
|
||||
| `predictedOverflowRate` | `measurements` flow.overflow | number m³/s | populated, 0 | test/basic/specificClass.test.js |
|
||||
| `predictedUnderflowVolume` | `measurements` underflowVolume | number m³ | 0 | test/basic/specificClass.test.js |
|
||||
| `volume.predicted.atequipment.<childId>` | `measurements.getFlattenedOutput` | number m³ | populated | test/basic/specificClass.test.js |
|
||||
| basin geometry: `heightBasin`, `surfaceArea`, `maxVol`, `minVol`, `maxVolAtOverflow`, `minVolAtInflow`, `minVolAtOutflow`, `volEmptyBasin`, `inflowLevel`, `outflowLevel`, `overflowLevel`, `inletPipeDiameter`, `outletPipeDiameter`, `minHeightBasedOn` | `basin.snapshot()` | number (m/m²/m³) / string | populated | test/basic/specificClass.test.js, test/basic/BasinGeometry.basic.test.js |
|
||||
|
||||
## Port 1 (InfluxDB telemetry) — `formatMsg(..., 'influxdb')`
|
||||
|
||||
Same key set as Port 0 (formatted via the `influxdb` formatter rather than
|
||||
`process`). Field names == Port-0 keys; `config.general.name` is the measurement
|
||||
tag. No Port-1-only fields. Covered transitively by the Port-0 tests above; a
|
||||
dedicated Port-1 line-protocol assertion is a **gap** (see below).
|
||||
|
||||
## Port 2 (registration / control plumbing) — `BaseNodeAdapter._scheduleRegistration`
|
||||
|
||||
| Topic | Source | Payload shape | States tested | Test file |
|
||||
|---|---|---|---|---|
|
||||
| `child.register` | `BaseNodeAdapter.js:122` | `{ topic:'child.register', payload:<node.id>, positionVsParent, distance }` | — | _(gap — see below)_ |
|
||||
|
||||
> Note: the canonical outgoing topic is **`child.register`** (matching the input
|
||||
> registry). Earlier docs said `registerChild`; that is the deprecated input
|
||||
> alias, not what this node emits.
|
||||
|
||||
## Child-facing events — `measurements.emitter`
|
||||
|
||||
Fired as `<type>.<variant>.<position>` when a series receives a value. Parents
|
||||
subscribe by event name (data-driven, not a fixed catalogue):
|
||||
|
||||
| Event | When | Test file |
|
||||
|---|---|---|
|
||||
| `volume.predicted.atequipment` | each integrator tick | test/basic/flowAggregator.basic.test.js |
|
||||
| `level.predicted.atequipment` | recomputed from volume | test/basic/specificClass.test.js |
|
||||
| `flow.predicted.in` (child `manual-qin`) | `set.inflow` handler | test/basic/measurementRouter.basic.test.js |
|
||||
| `overflowVolume`/`underflowVolume`/`flow.predicted.overflow` | integrator hits a physical bound | test/basic/flowAggregator.basic.test.js |
|
||||
|
||||
## Example-flow function-node fan-out
|
||||
|
||||
### examples/02-Dashboard.json :: `fn_status_split` (outputs: 15)
|
||||
|
||||
| # | Target widget | Payload | Populated | Degraded/null |
|
||||
|---|---|---|---|---|
|
||||
| 0 | ui-text "Mode" | string | ✔ structure | gap |
|
||||
| 1 | ui-text "Direction" | string | ✔ | gap |
|
||||
| 2 | ui-text "Level" | number m | ✔ | gap |
|
||||
| 3 | ui-text "Volume" | number m³ | ✔ | gap |
|
||||
| 4 | ui-text "Volume %" | number % | ✔ | gap |
|
||||
| 5 | ui-text "percControl" | number % | ✔ | gap |
|
||||
| 6 | ui-text "Manual demand" | number m³/h or — | gap | gap |
|
||||
| 7 | ui-chart "Level (m)" | `{topic,payload:number}` or no-msg | ✔ | gap |
|
||||
| 8 | ui-chart "Volume (m³)" | ″ | ✔ | gap |
|
||||
| 9 | ui-chart "Volume %" | ″ | ✔ | gap |
|
||||
| 10 | ui-chart "Flow (m³/h)" — Inflow | ″ | ✔ | gap |
|
||||
| 11 | ui-chart "Flow (m³/h)" — Outflow | ″ | ✔ | gap |
|
||||
| 12 | ui-chart "Flow (m³/h)" — Net | ″ | ✔ | gap |
|
||||
| 13 | ui-template "Raw output table" | whole object (array) | ✔ | gap |
|
||||
| 14 | ui-chart "percControl" | `{topic:'percControl',payload:number}` | ✔ | gap |
|
||||
|
||||
Populated/structure coverage: test/integration/basic-dashboard-flow.test.js
|
||||
(asserts output count = 15 and routes outputs 0–14). **Degraded/empty-input**
|
||||
coverage (no `payload:null` reaching any `ui-chart`) is still a gap — see below.
|
||||
|
||||
## Known coverage gaps (tracked, prospective per the rule)
|
||||
|
||||
The output-coverage rule applies prospectively. Outstanding items for this node:
|
||||
|
||||
- [ ] Dedicated `test/basic/output-port0.test.js` exercising **every** key above
|
||||
in both populated and degraded (pre-tick / null) states.
|
||||
- [ ] Port-1 line-protocol assertion (field names + tag).
|
||||
- [ ] Port-2 `child.register` payload-shape test.
|
||||
- [ ] `fn_status_split` degraded/empty-input fan-out test (no `payload:null` to
|
||||
any `ui-chart`) — the failure mode the rule was written for. The structure
|
||||
test in `basic-dashboard-flow.test.js` covers the populated path only.
|
||||
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) {
|
||||
const calls = { handleInput: [], turnOff: 0 };
|
||||
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||
return {
|
||||
config: { general: { name } },
|
||||
setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); },
|
||||
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||
turnOffAllMachines: () => { calls.turnOff += 1; },
|
||||
_calls: calls,
|
||||
@@ -59,31 +60,38 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
|
||||
assert.equal(state.percControl, 0);
|
||||
for (const g of Object.values(ctx.machineGroups)) {
|
||||
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
|
||||
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
|
||||
// only the explicit minLevel hard-stop path skips handleInput.
|
||||
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
|
||||
// Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge
|
||||
// hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so
|
||||
// MGC doesn't kick a pump on at flow.min before the gate is ever passed.
|
||||
test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => {
|
||||
const ctx = makeCtx(1.5);
|
||||
const state = { percControl: 17 };
|
||||
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)) {
|
||||
assert.equal(g._calls.turnOff, 0);
|
||||
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
|
||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
|
||||
assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff');
|
||||
assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement');
|
||||
}
|
||||
});
|
||||
|
||||
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 state = { percControl: null };
|
||||
await levelBased.run(ctx, state);
|
||||
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 () => {
|
||||
@@ -101,19 +109,65 @@ test('level above maxLevel → percControl clamped at 100 (interpolation limit_i
|
||||
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 state = { percControl: null };
|
||||
await levelBased.run(ctx, state);
|
||||
|
||||
assert.equal(state.percControl, 50);
|
||||
for (const g of Object.values(ctx.machineGroups)) {
|
||||
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
|
||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
||||
assert.equal(g._calls.setDemand.length, 1, 'one forward per group');
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const ctx = makeCtx(NaN);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 assert = require('node:assert/strict');
|
||||
|
||||
const { UnitPolicy } = require('generalFunctions');
|
||||
const manual = require('../../src/control/manual');
|
||||
|
||||
const unitPolicy = UnitPolicy.declare({
|
||||
canonical: { flow: 'm3/s' },
|
||||
output: { flow: 'm3/s' },
|
||||
requireUnitForTypes: [],
|
||||
});
|
||||
|
||||
function makeGroup(name) {
|
||||
const calls = { handleInput: [] };
|
||||
return {
|
||||
@@ -28,15 +35,15 @@ function makeLogger() {
|
||||
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 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)) {
|
||||
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 () => {
|
||||
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 });
|
||||
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}`);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { fa, measurements } = makeAggregator();
|
||||
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 assert = require('node:assert/strict');
|
||||
|
||||
const { MeasurementContainer } = require('generalFunctions');
|
||||
const PumpingStation = require('../../src/specificClass');
|
||||
|
||||
// machineGroups is a registry-backed getter (declareChildGetter) — direct
|
||||
// assignment is no longer possible. Tests inject mock groups through the
|
||||
// real registration handshake so the registry remains the source of truth.
|
||||
function registerMockGroup(ps, id, behavior = {}) {
|
||||
const calls = { handleInput: [], turnOff: 0 };
|
||||
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||
const mock = {
|
||||
config: {
|
||||
general: { id, name: id },
|
||||
@@ -21,6 +22,8 @@ function registerMockGroup(ps, id, behavior = {}) {
|
||||
emitter: { on: () => {} },
|
||||
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
||||
},
|
||||
setDemand: behavior.setDemand
|
||||
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
|
||||
handleInput: behavior.handleInput
|
||||
|| (async (...args) => { calls.handleInput.push(args); }),
|
||||
turnOffAllMachines: behavior.turnOffAllMachines
|
||||
@@ -82,6 +85,39 @@ function makeConfig(overrides = {}) {
|
||||
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) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
@@ -163,7 +199,10 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||
});
|
||||
|
||||
await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
|
||||
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({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
@@ -171,7 +210,8 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
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', () => {
|
||||
@@ -261,51 +301,77 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
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());
|
||||
ps.percControl = 42; // simulated previous demand
|
||||
const mock = registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||
await ps._controlLevelBased();
|
||||
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 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');
|
||||
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 () => {
|
||||
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 () => {
|
||||
await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
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);
|
||||
await ps._controlLevelBased();
|
||||
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();
|
||||
// Without shift the foot is inflowLevel → 0% in the hold zone.
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
|
||||
});
|
||||
|
||||
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
|
||||
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
|
||||
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
|
||||
// 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.
|
||||
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
@@ -313,7 +379,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['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,
|
||||
},
|
||||
},
|
||||
@@ -355,7 +421,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['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,
|
||||
},
|
||||
},
|
||||
@@ -381,7 +449,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
control: {
|
||||
mode: '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');
|
||||
|
||||
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
@@ -22,27 +22,29 @@ function makeContextStub() {
|
||||
|
||||
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const ps = flow.find((n) => n.id === 'ps_node_basic');
|
||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
||||
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
|
||||
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
|
||||
const ps = flow.find((n) => n.type === 'pumpingStation');
|
||||
const parser = flow.find((n) => n.id === 'fn_status_split');
|
||||
const levelChart = flow.find((n) => n.id === 'ui_chart_level');
|
||||
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.controlMode, 'levelbased');
|
||||
assert.equal(ps.levelCurveType, 'linear');
|
||||
assert.equal(ps.inletPipeDiameter, 0.4);
|
||||
assert.equal(ps.inletPipeDiameter, 0.3);
|
||||
assert.equal(ps.outletPipeDiameter, 0.3);
|
||||
assert.ok(parser, 'ps_parse_output should exist');
|
||||
assert.equal(parser.outputs, 6);
|
||||
assert.ok(parser, 'fn_status_split should exist');
|
||||
assert.equal(parser.outputs, 15);
|
||||
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', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
||||
assert.ok(parser, 'ps_parse_output should exist');
|
||||
const parser = flow.find((n) => n.id === 'fn_status_split');
|
||||
assert.ok(parser, 'fn_status_split should exist');
|
||||
|
||||
const func = new Function('msg', 'context', 'node', parser.func);
|
||||
const context = makeContextStub();
|
||||
@@ -56,8 +58,12 @@ test('basic dashboard parser routes process fields to charts and state text', ()
|
||||
payload: {
|
||||
'level.predicted.atequipment.default': 3.25,
|
||||
'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,
|
||||
percControl: 25,
|
||||
mode: 'levelbased',
|
||||
direction: 'filling',
|
||||
safetyState: 'normal',
|
||||
isOverflowing: false,
|
||||
@@ -66,22 +72,26 @@ test('basic dashboard parser routes process fields to charts and state text', ()
|
||||
}, context, node);
|
||||
|
||||
assert.ok(Array.isArray(out));
|
||||
assert.equal(out.length, 6);
|
||||
assert.equal(out[0].topic, 'level');
|
||||
assert.equal(out[0].payload, 3.25);
|
||||
assert.equal(out[1].topic, 'volume');
|
||||
assert.equal(out[1].payload, 32.5);
|
||||
assert.equal(out[2].topic, 'demand');
|
||||
assert.equal(out[2].payload, 25);
|
||||
assert.equal(out[3].topic, 'net_flow');
|
||||
assert.equal(out[3].payload, 0.003);
|
||||
assert.match(out[4].payload, /normal/);
|
||||
assert.match(out[5].payload, /level=3.25 m/);
|
||||
assert.equal(out.length, 15);
|
||||
assert.equal(out[0].payload, 'levelbased');
|
||||
assert.equal(out[1].payload, 'filling');
|
||||
assert.equal(out[2].payload, '3.25 m');
|
||||
assert.equal(out[3].payload, '32.50 m³');
|
||||
assert.equal(out[4].payload, '65.00 %');
|
||||
assert.equal(out[5].payload, '25.0 %');
|
||||
assert.deepEqual(out[7], { topic: 'Level', payload: 3.25 });
|
||||
assert.deepEqual(out[8], { topic: 'Volume', payload: 32.5 });
|
||||
assert.deepEqual(out[9], { topic: 'Volume %', payload: 65 });
|
||||
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));
|
||||
assert.deepEqual(out[14], { topic: 'percControl', payload: 25 });
|
||||
});
|
||||
|
||||
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
||||
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 context = makeContextStub();
|
||||
const node = { send() {} };
|
||||
@@ -89,6 +99,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);
|
||||
const out = func({ payload: { percControl: 20 } }, context, node);
|
||||
|
||||
assert.equal(out[0].payload, 3.1);
|
||||
assert.equal(out[2].payload, 20);
|
||||
assert.equal(out[2].payload, '3.10 m');
|
||||
assert.equal(out[5].payload, '20.0 %');
|
||||
});
|
||||
|
||||
@@ -37,7 +37,11 @@ function makeConfig() {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
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,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
|
||||
@@ -1,949 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* build-examples.js — regenerate the three example flows for pumpingStation.
|
||||
*
|
||||
* Source of truth for the Tier 1/2/3 example flows under examples/.
|
||||
* Follows EVOLV/.claude/rules/node-red-flow-layout.md:
|
||||
* - Lane positions L0..L7 = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
|
||||
* - S88 colours per Node-RED group (Process Cell = #0c99d9, Unit = #50a8d9,
|
||||
* Equipment Module = #86bbdd, Control Module = #a9daee, neutral = #dddddd)
|
||||
* - Cross-tab wiring via named link out/link in channels (cmd:* / evt:* / setup:*)
|
||||
* - ui-chart objects carry every mandatory key (interpolation, yAxisProperty,
|
||||
* xAxisPropertyType, action, removeOlder*, colors, etc.) — omitting any
|
||||
* causes FlowFuse to render the chart blank with no error.
|
||||
*
|
||||
* Only canonical pumpingStation topic names are used (per CONTRACT.md):
|
||||
* set.mode, set.inflow, set.demand, cmd.calibrate.volume, cmd.calibrate.level.
|
||||
*
|
||||
* Run from repo root or any cwd:
|
||||
* node nodes/pumpingStation/tools/build-examples.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OUT_DIR = path.join(__dirname, '..', 'examples');
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Layout constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800];
|
||||
const S88 = {
|
||||
AR: '#0f52a5',
|
||||
PC: '#0c99d9',
|
||||
UN: '#50a8d9',
|
||||
EM: '#86bbdd',
|
||||
CM: '#a9daee',
|
||||
neutral: '#dddddd',
|
||||
};
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#0095FF', '#FF0000', '#FF7F0E', '#2CA02C', '#A347E1',
|
||||
'#D62728', '#FF9896', '#9467BD', '#C5B0D5',
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function tab(id, label, info) {
|
||||
return { id, type: 'tab', label, disabled: false, info: info || '' };
|
||||
}
|
||||
|
||||
function comment(id, z, name, x, y) {
|
||||
return { id, type: 'comment', z, name, info: '', x, y, wires: [] };
|
||||
}
|
||||
|
||||
function linkOut(id, z, name, x, y, links) {
|
||||
return { id, type: 'link out', z, name, mode: 'link', links: links || [], x, y, wires: [] };
|
||||
}
|
||||
|
||||
function linkIn(id, z, name, x, y, links, downstream) {
|
||||
return { id, type: 'link in', z, name, links: links || [], x, y, wires: [downstream || []] };
|
||||
}
|
||||
|
||||
function inject(id, z, name, topic, payload, payloadType, x, y, wires, opts) {
|
||||
const o = opts || {};
|
||||
return {
|
||||
id, type: 'inject', z, name,
|
||||
props: [
|
||||
{ p: 'topic', vt: 'str' },
|
||||
{ p: 'payload', v: String(payload), vt: payloadType },
|
||||
],
|
||||
topic,
|
||||
repeat: o.repeat || '',
|
||||
crontab: '',
|
||||
once: !!o.once,
|
||||
onceDelay: o.onceDelay || '',
|
||||
x, y,
|
||||
wires: [wires || []],
|
||||
};
|
||||
}
|
||||
|
||||
function fn(id, z, name, code, x, y, wires, outputs) {
|
||||
return {
|
||||
id, type: 'function', z, name,
|
||||
func: code,
|
||||
outputs: outputs || 1,
|
||||
noerr: 0,
|
||||
initialize: '',
|
||||
finalize: '',
|
||||
libs: [],
|
||||
x, y,
|
||||
wires: wires || [[]],
|
||||
};
|
||||
}
|
||||
|
||||
function debugNode(id, z, name, x, y, complete, targetType, active) {
|
||||
return {
|
||||
id, type: 'debug', z, name,
|
||||
active: active !== false,
|
||||
tosidebar: true,
|
||||
console: false,
|
||||
tostatus: false,
|
||||
complete: complete || 'payload',
|
||||
targetType: targetType || 'msg',
|
||||
x, y, wires: [],
|
||||
};
|
||||
}
|
||||
|
||||
function group(id, z, name, color, nodes, bbox) {
|
||||
return {
|
||||
id, type: 'group', z, name,
|
||||
style: { label: true, stroke: '#000000', fill: color, 'fill-opacity': '0.10' },
|
||||
nodes,
|
||||
x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
|
||||
};
|
||||
}
|
||||
|
||||
function bboxOf(nodeList, ids, pad) {
|
||||
const p = pad == null ? 20 : pad;
|
||||
const ns = nodeList.filter((n) => ids.includes(n.id));
|
||||
const xs = ns.map((n) => n.x || 0);
|
||||
const ys = ns.map((n) => n.y || 0);
|
||||
const minX = Math.min(...xs) - p;
|
||||
const minY = Math.min(...ys) - p - 20;
|
||||
const w = Math.max(...xs) - Math.min(...xs) + 200 + 2 * p;
|
||||
const h = Math.max(...ys) - Math.min(...ys) + 60 + 2 * p;
|
||||
return { x: minX, y: minY, w, h };
|
||||
}
|
||||
|
||||
/* Build a fully-specified pumpingStation node. Every config field is set
|
||||
* explicitly per rule §9 (no schema-default reliance for operational
|
||||
* parameters). 50 m³ basin, 3.5 m height, inflow at 3 m, outflow at 0.2 m,
|
||||
* overflow at 3.2 m. Level thresholds chosen so levelbased control activates
|
||||
* mid-tank and saturates near overflow.
|
||||
*/
|
||||
function pumpingStationNode(id, z, name, x, y, wires) {
|
||||
return {
|
||||
id, type: 'pumpingStation', z, name,
|
||||
simulator: false,
|
||||
basinVolume: 50,
|
||||
basinHeight: 3.5,
|
||||
inflowLevel: 3.0,
|
||||
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, y,
|
||||
wires: wires || [[], [], []],
|
||||
};
|
||||
}
|
||||
|
||||
function measurementLevelNode(id, z, name, x, y, wires) {
|
||||
return {
|
||||
id, type: 'measurement', z, name,
|
||||
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, y,
|
||||
wires: wires || [[], [], []],
|
||||
};
|
||||
}
|
||||
|
||||
function machineGroupControlNode(id, z, name, x, y, wires) {
|
||||
return {
|
||||
id, type: 'machineGroupControl', z, name,
|
||||
enableLog: true,
|
||||
logLevel: 'info',
|
||||
positionVsParent: 'atEquipment',
|
||||
positionIcon: '',
|
||||
hasDistance: false,
|
||||
distance: '',
|
||||
distanceUnit: 'm',
|
||||
x, y,
|
||||
wires: wires || [[], [], []],
|
||||
};
|
||||
}
|
||||
|
||||
function rotatingMachineNode(id, z, name, uuid, x, y, wires) {
|
||||
return {
|
||||
id, type: 'rotatingMachine', z, name,
|
||||
speed: '1',
|
||||
startup: '2', warmup: '1', shutdown: '2', cooldown: '1',
|
||||
movementMode: 'staticspeed',
|
||||
machineCurve: '',
|
||||
uuid,
|
||||
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, y,
|
||||
wires: wires || [[], [], []],
|
||||
};
|
||||
}
|
||||
|
||||
/* FlowFuse ui-chart with every required key (per layout rule §4). */
|
||||
function uiChart(id, z, group, name, label, order, yAxisLabel, x, y, color) {
|
||||
return {
|
||||
id, type: 'ui-chart', z, group, name, label,
|
||||
order, width: 12, height: 6,
|
||||
chartType: 'line',
|
||||
category: 'topic',
|
||||
categoryType: 'msg',
|
||||
xAxisLabel: 'time',
|
||||
xAxisType: 'time',
|
||||
xAxisProperty: '',
|
||||
xAxisPropertyType: 'timestamp',
|
||||
xAxisFormat: '',
|
||||
xAxisFormatType: 'auto',
|
||||
yAxisLabel,
|
||||
yAxisProperty: 'payload',
|
||||
yAxisPropertyType: 'msg',
|
||||
xmin: '', xmax: '', ymin: '', ymax: '',
|
||||
bins: 10,
|
||||
action: 'append',
|
||||
stackSeries: false,
|
||||
pointShape: 'circle',
|
||||
pointRadius: 4,
|
||||
interpolation: 'linear',
|
||||
showLegend: true,
|
||||
className: '',
|
||||
removeOlder: '15',
|
||||
removeOlderUnit: '60',
|
||||
removeOlderPoints: '200',
|
||||
colors: color ? [color, ...CHART_COLORS.slice(1)] : CHART_COLORS,
|
||||
textColor: ['#666666'],
|
||||
textColorDefault: true,
|
||||
gridColor: ['#e5e5e5'],
|
||||
gridColorDefault: true,
|
||||
x, y, wires: [],
|
||||
};
|
||||
}
|
||||
|
||||
function uiText(id, z, group, name, label, order, x, y, format) {
|
||||
return {
|
||||
id, type: 'ui-text', z, group, name, label,
|
||||
order, width: 4, height: 1,
|
||||
format: format || '{{msg.payload}}',
|
||||
layout: 'row-spread',
|
||||
x, y, wires: [],
|
||||
};
|
||||
}
|
||||
|
||||
function uiSlider(id, z, group, name, label, order, x, y, topic, min, max, step) {
|
||||
return {
|
||||
id, type: 'ui-slider', z, group, name, label,
|
||||
order, width: 6, height: 1,
|
||||
passthru: true,
|
||||
outs: 'end',
|
||||
topic,
|
||||
topicType: 'str',
|
||||
min, max, step,
|
||||
icon: '',
|
||||
thumbLabel: 'always',
|
||||
showValue: true,
|
||||
className: '',
|
||||
x, y, wires: [[]],
|
||||
};
|
||||
}
|
||||
|
||||
function uiDropdown(id, z, group, name, label, order, x, y, topic, options, wires) {
|
||||
return {
|
||||
id, type: 'ui-dropdown', z, group, name, label,
|
||||
order, width: 6, height: 1,
|
||||
passthru: true,
|
||||
multiple: false,
|
||||
options: options.map((o) => ({ label: o, value: o, type: 'str' })),
|
||||
payload: '',
|
||||
topic,
|
||||
topicType: 'str',
|
||||
x, y,
|
||||
wires: [wires || []],
|
||||
};
|
||||
}
|
||||
|
||||
function uiBase(id) {
|
||||
return {
|
||||
id, type: 'ui-base',
|
||||
name: 'EVOLV Demo',
|
||||
path: '/dashboard',
|
||||
appIcon: '',
|
||||
includeClientData: true,
|
||||
acceptsClientConfig: ['ui-notification', 'ui-control'],
|
||||
showPathInSidebar: false,
|
||||
headerContent: 'page',
|
||||
navigationStyle: 'default',
|
||||
titleBarStyle: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
function uiTheme(id) {
|
||||
return {
|
||||
id, type: 'ui-theme',
|
||||
name: 'EVOLV Theme',
|
||||
colors: {
|
||||
surface: '#ffffff', primary: '#0c99d9', bgPage: '#eeeeee',
|
||||
groupBg: '#ffffff', groupOutline: '#cccccc',
|
||||
},
|
||||
sizes: {
|
||||
density: 'default', pagePadding: '14px', groupGap: '14px',
|
||||
groupBorderRadius: '6px', widgetGap: '12px',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function uiPage(id, base, theme, name, path, order) {
|
||||
return {
|
||||
id, type: 'ui-page', name, ui: base, path,
|
||||
icon: 'water',
|
||||
layout: 'grid', theme,
|
||||
breakpoints: [{ name: 'Default', px: '0', cols: '12' }],
|
||||
order, className: '',
|
||||
};
|
||||
}
|
||||
|
||||
function uiGroup(id, page, name, width, height, order) {
|
||||
return {
|
||||
id, type: 'ui-group', name, page, width, height, order,
|
||||
showTitle: true, className: '',
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tier 1 — 01-Basic.json */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildBasic() {
|
||||
const Z = 'ps_basic_tab';
|
||||
const nodes = [];
|
||||
|
||||
nodes.push(tab(Z, 'PumpingStation - Basic',
|
||||
'Tier 1: single pumpingStation node driven by inject nodes only. ' +
|
||||
'Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand.'));
|
||||
|
||||
nodes.push(comment('ps_basic_title', Z,
|
||||
'PumpingStation - Basic\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'A 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\n' +
|
||||
'overflow at 3.2 m). controlMode = levelbased, manual demand allowed\n' +
|
||||
'only when set.mode = manual.\n\n' +
|
||||
'HOW 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\n' +
|
||||
'Aliases (changemode, q_in, Qd, …) still work but log a deprecation\n' +
|
||||
'warning - fresh flows use the canonical names.', 600, 40));
|
||||
|
||||
// Lane 0: link-in placeholders (none for Tier 1 - all inputs are local).
|
||||
// Lane 2..3: inject nodes (we keep them in lane 1 for proximity).
|
||||
const injectMode = inject('ps_basic_inj_mode', Z, 'set.mode = manual', 'set.mode', 'manual', 'str', 200, 160, ['ps_basic_node']);
|
||||
const injectModeLvl = inject('ps_basic_inj_mode_lvl',Z, 'set.mode = levelbased','set.mode', 'levelbased', 'str', 220, 200, ['ps_basic_node']);
|
||||
const injectInflow = inject('ps_basic_inj_inflow', Z, 'set.inflow = 60 m3/h', 'set.inflow', '60', 'num', 200, 260, ['ps_basic_node']);
|
||||
const injectDemand = inject('ps_basic_inj_demand', Z, 'set.demand = 40 %', 'set.demand', '40', 'num', 200, 300, ['ps_basic_node']);
|
||||
const injectCalVol = inject('ps_basic_inj_calvol', Z, 'calibrate volume 25 m3','cmd.calibrate.volume','25','num', 220, 360, ['ps_basic_node']);
|
||||
const injectCalLvl = inject('ps_basic_inj_callvl', Z, 'calibrate level 1.5 m','cmd.calibrate.level','1.5','num', 220, 400, ['ps_basic_node']);
|
||||
nodes.push(injectMode, injectModeLvl, injectInflow, injectDemand, injectCalVol, injectCalLvl);
|
||||
|
||||
// Lane 5 (PC): the pumpingStation itself.
|
||||
const ps = pumpingStationNode('ps_basic_node', Z, 'Pumping Station', LANE_X[5], 300,
|
||||
[['ps_basic_format'], ['ps_basic_dbg_influx'], ['ps_basic_dbg_parent']]);
|
||||
nodes.push(ps);
|
||||
|
||||
// Lane 6: format/merge function for Port 0.
|
||||
const formatFn = fn('ps_basic_format', Z, 'Merge deltas + format',
|
||||
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||
"const cache = context.get('c') || {};\n" +
|
||||
"Object.assign(cache, p);\n" +
|
||||
"context.set('c', cache);\n" +
|
||||
"function 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" +
|
||||
"}\n" +
|
||||
"const vol = pick('volume.predicted.atequipment');\n" +
|
||||
"const lvl = pick('level.predicted.atequipment');\n" +
|
||||
"const flIn = pick('flow.predicted.in');\n" +
|
||||
"msg.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;",
|
||||
LANE_X[6], 280, [['ps_basic_dbg_process']]);
|
||||
nodes.push(formatFn);
|
||||
|
||||
// Lane 7: debug taps.
|
||||
nodes.push(debugNode('ps_basic_dbg_process', Z, 'Port 0: Process', LANE_X[7], 240, 'payload', 'msg', true));
|
||||
nodes.push(debugNode('ps_basic_dbg_influx', Z, 'Port 1: InfluxDB', LANE_X[7], 320, 'true', 'full', false));
|
||||
nodes.push(debugNode('ps_basic_dbg_parent', Z, 'Port 2: Parent reg', LANE_X[7], 380, 'true', 'full', true));
|
||||
|
||||
// Wrap the station + its formatter in a Process Cell group box.
|
||||
const psGroupIds = ['ps_basic_node', 'ps_basic_format'];
|
||||
nodes.push(group('grp_ps_basic', Z, 'Pumping Station (PC)', S88.PC, psGroupIds,
|
||||
bboxOf(nodes, psGroupIds, 30)));
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tier 2 — 02-Integration.json */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildIntegration() {
|
||||
const TAB_PROC = 'ps_int_proc';
|
||||
const TAB_SETUP = 'ps_int_setup';
|
||||
const nodes = [];
|
||||
|
||||
nodes.push(tab(TAB_PROC, 'Process Plant',
|
||||
'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.'));
|
||||
nodes.push(tab(TAB_SETUP, 'Setup',
|
||||
'Deploy-time once-true injects that initialise control modes on the EVOLV nodes.'));
|
||||
|
||||
/* ---------- Process Plant tab ---------------------------------- */
|
||||
|
||||
nodes.push(comment('ps_int_title', TAB_PROC,
|
||||
'PumpingStation - Integration\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'L0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\n' +
|
||||
'Pumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\n' +
|
||||
'Cross-tab channels: setup:* drive once-true initialisation from the Setup tab.', 600, 40));
|
||||
|
||||
/* Link-ins on L0 receive from the Setup tab. */
|
||||
const linInMode = linkIn('lin_setup_mode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 500, [], ['ps_int_station']);
|
||||
const linInInflow = linkIn('lin_setup_inflow', TAB_PROC, 'setup:to-ps-inflow', LANE_X[0], 560, [], ['ps_int_station']);
|
||||
const linInMgcMode = linkIn('lin_setup_mgcmode', TAB_PROC, 'setup:to-mgc-mode', LANE_X[0], 360, [], ['ps_int_mgc']);
|
||||
nodes.push(linInMode, linInInflow, linInMgcMode);
|
||||
|
||||
/* L2: level measurement (Control Module). */
|
||||
const levelMeas = measurementLevelNode('meas_level', TAB_PROC, 'Basin level sensor',
|
||||
LANE_X[2], 700, [['ps_int_dbg_level'], [], ['ps_int_station']]);
|
||||
nodes.push(levelMeas);
|
||||
// Simulator measurement injector for the level sensor (push a varying level so PS sees something).
|
||||
const levelInj = inject('ps_int_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num', LANE_X[0], 700, ['meas_level']);
|
||||
nodes.push(levelInj);
|
||||
|
||||
/* L3: two rotatingMachine pumps (Equipment Module). */
|
||||
const pumpA = rotatingMachineNode('pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
|
||||
LANE_X[3], 320, [['ps_int_dbg_pa'], [], ['ps_int_mgc']]);
|
||||
const pumpB = rotatingMachineNode('pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
|
||||
LANE_X[3], 400, [['ps_int_dbg_pb'], [], ['ps_int_mgc']]);
|
||||
nodes.push(pumpA, pumpB);
|
||||
|
||||
/* L4: MGC (Unit). */
|
||||
const mgc = machineGroupControlNode('ps_int_mgc', TAB_PROC, 'Pump Group',
|
||||
LANE_X[4], 360, [['ps_int_dbg_mgc'], [], ['ps_int_station']]);
|
||||
nodes.push(mgc);
|
||||
|
||||
/* L5: pumpingStation (Process Cell). */
|
||||
const station = pumpingStationNode('ps_int_station', TAB_PROC, 'Pumping Station',
|
||||
LANE_X[5], 520, [['ps_int_format'], ['ps_int_dbg_influx'], []]);
|
||||
nodes.push(station);
|
||||
|
||||
/* L6: formatter for the station's Port 0. */
|
||||
const formatFn = fn('ps_int_format', TAB_PROC, 'Merge deltas + format',
|
||||
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
|
||||
"function 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; }\n" +
|
||||
"const vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\n" +
|
||||
"msg.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;",
|
||||
LANE_X[6], 520, [['ps_int_dbg_process']]);
|
||||
nodes.push(formatFn);
|
||||
|
||||
/* L7: debug taps for the various ports. */
|
||||
nodes.push(debugNode('ps_int_dbg_process', TAB_PROC, 'PS Port 0: Process', LANE_X[7], 480, 'payload', 'msg', true));
|
||||
nodes.push(debugNode('ps_int_dbg_influx', TAB_PROC, 'PS Port 1: InfluxDB', LANE_X[7], 540, 'true', 'full', false));
|
||||
nodes.push(debugNode('ps_int_dbg_mgc', TAB_PROC, 'MGC Port 0', LANE_X[7], 360, 'payload', 'msg', true));
|
||||
nodes.push(debugNode('ps_int_dbg_pa', TAB_PROC, 'Pump A Port 0', LANE_X[7], 320, 'payload', 'msg', false));
|
||||
nodes.push(debugNode('ps_int_dbg_pb', TAB_PROC, 'Pump B Port 0', LANE_X[7], 400, 'payload', 'msg', false));
|
||||
nodes.push(debugNode('ps_int_dbg_level', TAB_PROC, 'Level Port 0', LANE_X[7], 700, 'payload', 'msg', false));
|
||||
|
||||
/* Group boxes. */
|
||||
const pumpAIds = ['pump_a', 'ps_int_dbg_pa'];
|
||||
const pumpBIds = ['pump_b', 'ps_int_dbg_pb'];
|
||||
const mgcIds = ['ps_int_mgc', 'ps_int_dbg_mgc', 'lin_setup_mgcmode'];
|
||||
const stationIds = ['ps_int_station', 'ps_int_format', 'ps_int_dbg_process', 'ps_int_dbg_influx', 'lin_setup_mode', 'lin_setup_inflow'];
|
||||
const levelIds = ['meas_level', 'ps_int_inj_level', 'ps_int_dbg_level'];
|
||||
nodes.push(group('grp_pumpa', TAB_PROC, 'Pump A (EM)', S88.EM, pumpAIds, bboxOf(nodes, pumpAIds, 25)));
|
||||
nodes.push(group('grp_pumpb', TAB_PROC, 'Pump B (EM)', S88.EM, pumpBIds, bboxOf(nodes, pumpBIds, 25)));
|
||||
nodes.push(group('grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, mgcIds, bboxOf(nodes, mgcIds, 25)));
|
||||
nodes.push(group('grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, stationIds, bboxOf(nodes, stationIds, 25)));
|
||||
nodes.push(group('grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, levelIds, bboxOf(nodes, levelIds, 25)));
|
||||
|
||||
/* ---------- Setup tab ----------------------------------------- */
|
||||
|
||||
nodes.push(comment('setup_title', TAB_SETUP,
|
||||
'Deploy-time setup\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'Fires once after each deploy: pushes the canonical set.mode / set.inflow /\n' +
|
||||
'set.demand topics across cross-tab channels into the Process Plant tab.',
|
||||
LANE_X[2], 40));
|
||||
|
||||
const setMode = inject('setup_inj_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str', LANE_X[0], 160, ['lout_setup_mode'], { once: true, onceDelay: '0.5' });
|
||||
const setMgc = inject('setup_inj_mgcmode', TAB_SETUP, 'MGC set.mode = auto', 'set.mode', 'auto', 'str', LANE_X[0], 220, ['lout_setup_mgcmode'],{ once: true, onceDelay: '0.5' });
|
||||
const setInflow = inject('setup_inj_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num', LANE_X[0], 280, ['lout_setup_inflow'], { once: true, onceDelay: '1.0' });
|
||||
nodes.push(setMode, setMgc, setInflow);
|
||||
|
||||
const loutMode = linkOut('lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_setup_mode']);
|
||||
const loutMgcMode = linkOut('lout_setup_mgcmode', TAB_SETUP, 'setup:to-mgc-mode', LANE_X[7], 220, ['lin_setup_mgcmode']);
|
||||
const loutInflow = linkOut('lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 280, ['lin_setup_inflow']);
|
||||
nodes.push(loutMode, loutMgcMode, loutInflow);
|
||||
|
||||
// Setup tab group.
|
||||
const setupIds = ['setup_inj_mode', 'setup_inj_mgcmode', 'setup_inj_inflow',
|
||||
'lout_setup_mode', 'lout_setup_mgcmode', 'lout_setup_inflow'];
|
||||
nodes.push(group('grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tier 3 — 03-Dashboard.json */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildDashboard() {
|
||||
const TAB_PROC = 'ps_dash_proc';
|
||||
const TAB_UI = 'ps_dash_ui';
|
||||
const TAB_SETUP = 'ps_dash_setup';
|
||||
const nodes = [];
|
||||
|
||||
nodes.push(tab(TAB_PROC, 'Process Plant',
|
||||
'Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard.'));
|
||||
nodes.push(tab(TAB_UI, 'Dashboard UI',
|
||||
'FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders.'));
|
||||
nodes.push(tab(TAB_SETUP, 'Setup',
|
||||
'Once-true injects: initial mode + initial inflow seed.'));
|
||||
|
||||
/* ---------- FlowFuse dashboard scaffolding -------------------- */
|
||||
|
||||
nodes.push(uiBase('ps_dash_base'));
|
||||
nodes.push(uiTheme('ps_dash_theme'));
|
||||
nodes.push(uiPage('ps_dash_page', 'ps_dash_base', 'ps_dash_theme', 'PumpingStation Demo', '/pumping-station', 1));
|
||||
nodes.push(uiGroup('ps_dash_grp_ctrl', 'ps_dash_page', 'Controls', 6, 1, 1));
|
||||
nodes.push(uiGroup('ps_dash_grp_status', 'ps_dash_page', 'Operator Status', 6, 1, 2));
|
||||
nodes.push(uiGroup('ps_dash_grp_trend', 'ps_dash_page', 'Live Trends', 12, 1, 3));
|
||||
|
||||
/* ---------- Process Plant tab --------------------------------- */
|
||||
|
||||
nodes.push(comment('ps_dash_proc_title', TAB_PROC,
|
||||
'Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\n' +
|
||||
'Events go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.',
|
||||
600, 40));
|
||||
|
||||
/* L0 link-ins: setup + dashboard commands. */
|
||||
const linModeProc = linkIn('lin_proc_mode', TAB_PROC, 'cmd:ps-mode', LANE_X[0], 480, [], ['ps_dash_station']);
|
||||
const linDemandProc = linkIn('lin_proc_demand', TAB_PROC, 'cmd:ps-demand', LANE_X[0], 540, [], ['ps_dash_station']);
|
||||
const linSetupMode = linkIn('lin_proc_setupmode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 420, [], ['ps_dash_station']);
|
||||
const linSetupInflow= linkIn('lin_proc_setupinflow', TAB_PROC, 'setup:to-ps-inflow',LANE_X[0], 600, [], ['ps_dash_station']);
|
||||
nodes.push(linModeProc, linDemandProc, linSetupMode, linSetupInflow);
|
||||
|
||||
/* L2 level sensor with simulator. */
|
||||
const levelMeas = measurementLevelNode('ps_dash_meas_level', TAB_PROC, 'Basin level sensor',
|
||||
LANE_X[2], 700, [[], [], ['ps_dash_station']]);
|
||||
nodes.push(levelMeas);
|
||||
nodes.push(inject('ps_dash_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num',
|
||||
LANE_X[0], 700, ['ps_dash_meas_level']));
|
||||
|
||||
/* L3 pumps. */
|
||||
const pumpA = rotatingMachineNode('ps_dash_pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
|
||||
LANE_X[3], 320, [[], [], ['ps_dash_mgc']]);
|
||||
const pumpB = rotatingMachineNode('ps_dash_pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
|
||||
LANE_X[3], 400, [[], [], ['ps_dash_mgc']]);
|
||||
nodes.push(pumpA, pumpB);
|
||||
|
||||
/* L4 MGC. */
|
||||
const mgc = machineGroupControlNode('ps_dash_mgc', TAB_PROC, 'Pump Group',
|
||||
LANE_X[4], 360, [[], [], ['ps_dash_station']]);
|
||||
nodes.push(mgc);
|
||||
|
||||
/* L5 pumpingStation. */
|
||||
const station = pumpingStationNode('ps_dash_station', TAB_PROC, 'Pumping Station',
|
||||
LANE_X[5], 520, [['ps_dash_trend_split'], [], []]);
|
||||
nodes.push(station);
|
||||
|
||||
/* L6 trend-split fn: one output per chart + one output for the status text widgets.
|
||||
* Outputs:
|
||||
* 0 -> chart_flow ({topic: 'Inflow', payload: m3/h}, {topic: 'Outflow', payload: m3/h})
|
||||
* 1 -> chart_level ({topic: 'Level', payload: m})
|
||||
* 2 -> chart_volpct ({topic: 'Volume%', payload: %})
|
||||
* 3 -> text_status (compact state string)
|
||||
* 4 -> text_perc (percControl)
|
||||
* 5 -> text_direction (direction)
|
||||
* 6 -> text_timetoempty(timeToEmpty)
|
||||
*/
|
||||
const trendCode =
|
||||
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
|
||||
"function 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; }\n" +
|
||||
"const flowIn = pick('flow.predicted.in');\n" +
|
||||
"const flowOut = pick('flow.predicted.out');\n" +
|
||||
"const level = pick('level.predicted.atequipment');\n" +
|
||||
"const volPct = Number(cache.volumePercent);\n" +
|
||||
"const ts = Date.now();\n" +
|
||||
"const flowMsgs = [];\n" +
|
||||
"if (flowIn != null) flowMsgs.push({ topic: 'Inflow', payload: flowIn * 3600, timestamp: ts });\n" +
|
||||
"if (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\n" +
|
||||
"const flowOut1 = flowMsgs.length ? flowMsgs : null;\n" +
|
||||
"const levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\n" +
|
||||
"const volOut = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\n" +
|
||||
"const stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\n" +
|
||||
"const percStr = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\n" +
|
||||
"const dirStr = cache.direction || 'n/a';\n" +
|
||||
"const tEmpty = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\n" +
|
||||
"return [\n" +
|
||||
" flowOut1,\n" +
|
||||
" levelOut,\n" +
|
||||
" volOut,\n" +
|
||||
" { payload: stateStr },\n" +
|
||||
" { payload: percStr },\n" +
|
||||
" { payload: dirStr },\n" +
|
||||
" { payload: tEmpty }\n" +
|
||||
"];";
|
||||
const trendSplit = fn('ps_dash_trend_split', TAB_PROC, 'Trend split + status', trendCode,
|
||||
LANE_X[6], 520,
|
||||
[
|
||||
['lout_evt_flow'],
|
||||
['lout_evt_level'],
|
||||
['lout_evt_volpct'],
|
||||
['lout_evt_state'],
|
||||
['lout_evt_perc'],
|
||||
['lout_evt_dir'],
|
||||
['lout_evt_tempty'],
|
||||
], 7);
|
||||
nodes.push(trendSplit);
|
||||
|
||||
/* L7 link-outs into the Dashboard UI tab. */
|
||||
const loutFlow = linkOut('lout_evt_flow', TAB_PROC, 'evt:flow', LANE_X[7], 420, ['lin_ui_flow']);
|
||||
const loutLevel = linkOut('lout_evt_level', TAB_PROC, 'evt:level', LANE_X[7], 460, ['lin_ui_level']);
|
||||
const loutVolPct = linkOut('lout_evt_volpct', TAB_PROC, 'evt:volpct', LANE_X[7], 500, ['lin_ui_volpct']);
|
||||
const loutState = linkOut('lout_evt_state', TAB_PROC, 'evt:state', LANE_X[7], 540, ['lin_ui_state']);
|
||||
const loutPerc = linkOut('lout_evt_perc', TAB_PROC, 'evt:perc', LANE_X[7], 580, ['lin_ui_perc']);
|
||||
const loutDir = linkOut('lout_evt_dir', TAB_PROC, 'evt:dir', LANE_X[7], 620, ['lin_ui_dir']);
|
||||
const loutTempty = linkOut('lout_evt_tempty', TAB_PROC, 'evt:tempty', LANE_X[7], 660, ['lin_ui_tempty']);
|
||||
nodes.push(loutFlow, loutLevel, loutVolPct, loutState, loutPerc, loutDir, loutTempty);
|
||||
|
||||
/* Process tab groups. */
|
||||
const procStationIds = ['ps_dash_station', 'ps_dash_trend_split',
|
||||
'lin_proc_mode', 'lin_proc_demand', 'lin_proc_setupmode', 'lin_proc_setupinflow',
|
||||
'lout_evt_flow', 'lout_evt_level', 'lout_evt_volpct', 'lout_evt_state', 'lout_evt_perc', 'lout_evt_dir', 'lout_evt_tempty'];
|
||||
const procPumpAIds = ['ps_dash_pump_a'];
|
||||
const procPumpBIds = ['ps_dash_pump_b'];
|
||||
const procMgcIds = ['ps_dash_mgc'];
|
||||
const procLevelIds = ['ps_dash_meas_level', 'ps_dash_inj_level'];
|
||||
nodes.push(group('ps_dash_grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, procStationIds, bboxOf(nodes, procStationIds, 25)));
|
||||
nodes.push(group('ps_dash_grp_pa', TAB_PROC, 'Pump A (EM)', S88.EM, procPumpAIds, bboxOf(nodes, procPumpAIds, 25)));
|
||||
nodes.push(group('ps_dash_grp_pb', TAB_PROC, 'Pump B (EM)', S88.EM, procPumpBIds, bboxOf(nodes, procPumpBIds, 25)));
|
||||
nodes.push(group('ps_dash_grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, procMgcIds, bboxOf(nodes, procMgcIds, 25)));
|
||||
nodes.push(group('ps_dash_grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, procLevelIds, bboxOf(nodes, procLevelIds, 25)));
|
||||
|
||||
/* ---------- Dashboard UI tab ---------------------------------- */
|
||||
|
||||
nodes.push(comment('ps_dash_ui_title', TAB_UI,
|
||||
'Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\n' +
|
||||
'Sliders on L2 emit cmd:* back to Process Plant.\n' +
|
||||
'Charts use the trend-split pattern: one chart per metric, series labelled by msg.topic.',
|
||||
600, 40));
|
||||
|
||||
/* L0 link-ins from the process side. */
|
||||
nodes.push(linkIn('lin_ui_flow', TAB_UI, 'evt:flow', LANE_X[0], 220, [], ['ui_chart_flow']));
|
||||
nodes.push(linkIn('lin_ui_level', TAB_UI, 'evt:level', LANE_X[0], 320, [], ['ui_chart_level']));
|
||||
nodes.push(linkIn('lin_ui_volpct', TAB_UI, 'evt:volpct', LANE_X[0], 420, [], ['ui_chart_volpct']));
|
||||
nodes.push(linkIn('lin_ui_state', TAB_UI, 'evt:state', LANE_X[0], 520, [], ['ui_text_state']));
|
||||
nodes.push(linkIn('lin_ui_perc', TAB_UI, 'evt:perc', LANE_X[0], 560, [], ['ui_text_perc']));
|
||||
nodes.push(linkIn('lin_ui_dir', TAB_UI, 'evt:dir', LANE_X[0], 600, [], ['ui_text_dir']));
|
||||
nodes.push(linkIn('lin_ui_tempty', TAB_UI, 'evt:tempty', LANE_X[0], 640, [], ['ui_text_tempty']));
|
||||
|
||||
/* L4 charts and text widgets. */
|
||||
nodes.push(uiChart('ui_chart_flow', TAB_UI, 'ps_dash_grp_trend', 'Flow trend', 'Flow (m³/h)', 1, 'm³/h', LANE_X[4], 220));
|
||||
nodes.push(uiChart('ui_chart_level', TAB_UI, 'ps_dash_grp_trend', 'Level trend', 'Level (m)', 2, 'm', LANE_X[4], 320));
|
||||
nodes.push(uiChart('ui_chart_volpct', TAB_UI, 'ps_dash_grp_trend', 'Volume %', 'Volume (%)', 3, '%', LANE_X[4], 420));
|
||||
nodes.push(uiText( 'ui_text_state', TAB_UI, 'ps_dash_grp_status','State', 'Station state',1, LANE_X[4], 520));
|
||||
nodes.push(uiText( 'ui_text_perc', TAB_UI, 'ps_dash_grp_status','percControl', 'Control %', 2, LANE_X[4], 560));
|
||||
nodes.push(uiText( 'ui_text_dir', TAB_UI, 'ps_dash_grp_status','direction', 'Direction', 3, LANE_X[4], 600));
|
||||
nodes.push(uiText( 'ui_text_tempty', TAB_UI, 'ps_dash_grp_status','timeToEmpty', 'Time to empty',4, LANE_X[4], 640));
|
||||
|
||||
/* L2 controls: dropdown for mode + slider for demand. */
|
||||
const modeDropdown = uiDropdown('ui_dd_mode', TAB_UI, 'ps_dash_grp_ctrl',
|
||||
'Mode', 'Control mode', 1, LANE_X[2], 160, 'set.mode',
|
||||
['manual', 'levelbased', 'flowbased', 'none'], ['ui_wrap_mode']);
|
||||
const demandSlider = uiSlider('ui_sl_demand', TAB_UI, 'ps_dash_grp_ctrl',
|
||||
'Demand', 'Manual demand (m³/h)', 2, LANE_X[2], 220, 'set.demand', 0, 200, 5);
|
||||
nodes.push(modeDropdown, demandSlider);
|
||||
// Slider wires need explicit wiring (uiSlider helper leaves wires empty so we set them post-creation).
|
||||
demandSlider.wires = [['ui_wrap_demand']];
|
||||
|
||||
/* L4 wrappers: enforce the canonical topic on the outgoing msg. */
|
||||
const wrapMode = fn('ui_wrap_mode', TAB_UI, 'topic=set.mode',
|
||||
"msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;",
|
||||
LANE_X[4], 160, [['lout_cmd_mode']]);
|
||||
const wrapDemand = fn('ui_wrap_demand', TAB_UI, 'topic=set.demand',
|
||||
"msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
||||
LANE_X[4], 220, [['lout_cmd_demand']]);
|
||||
nodes.push(wrapMode, wrapDemand);
|
||||
|
||||
/* L7 link-outs to the process plant. */
|
||||
nodes.push(linkOut('lout_cmd_mode', TAB_UI, 'cmd:ps-mode', LANE_X[7], 160, ['lin_proc_mode']));
|
||||
nodes.push(linkOut('lout_cmd_demand', TAB_UI, 'cmd:ps-demand', LANE_X[7], 220, ['lin_proc_demand']));
|
||||
|
||||
/* UI tab groups (mirror the dashboard groups). */
|
||||
const uiCtrlIds = ['ui_dd_mode', 'ui_sl_demand', 'ui_wrap_mode', 'ui_wrap_demand',
|
||||
'lout_cmd_mode', 'lout_cmd_demand'];
|
||||
const uiStatusIds = ['ui_text_state', 'ui_text_perc', 'ui_text_dir', 'ui_text_tempty',
|
||||
'lin_ui_state', 'lin_ui_perc', 'lin_ui_dir', 'lin_ui_tempty'];
|
||||
const uiTrendIds = ['ui_chart_flow', 'ui_chart_level', 'ui_chart_volpct',
|
||||
'lin_ui_flow', 'lin_ui_level', 'lin_ui_volpct'];
|
||||
nodes.push(group('grp_ui_ctrl', TAB_UI, 'Controls (PC)', S88.PC, uiCtrlIds, bboxOf(nodes, uiCtrlIds, 25)));
|
||||
nodes.push(group('grp_ui_status', TAB_UI, 'Operator status (PC)', S88.PC, uiStatusIds, bboxOf(nodes, uiStatusIds, 25)));
|
||||
nodes.push(group('grp_ui_trend', TAB_UI, 'Live trends (PC)', S88.PC, uiTrendIds, bboxOf(nodes, uiTrendIds, 25)));
|
||||
|
||||
/* ---------- Setup tab ----------------------------------------- */
|
||||
|
||||
nodes.push(comment('ps_dash_setup_title', TAB_SETUP, 'Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'Initialises set.mode = levelbased and seeds an inflow at deploy time.',
|
||||
LANE_X[2], 40));
|
||||
|
||||
nodes.push(inject('ps_dash_setup_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str',
|
||||
LANE_X[0], 160, ['ps_dash_lout_setup_mode'], { once: true, onceDelay: '0.5' }));
|
||||
nodes.push(inject('ps_dash_setup_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num',
|
||||
LANE_X[0], 220, ['ps_dash_lout_setup_inflow'], { once: true, onceDelay: '1.0' }));
|
||||
|
||||
nodes.push(linkOut('ps_dash_lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_proc_setupmode']));
|
||||
nodes.push(linkOut('ps_dash_lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 220, ['lin_proc_setupinflow']));
|
||||
|
||||
const setupIds = ['ps_dash_setup_mode', 'ps_dash_setup_inflow',
|
||||
'ps_dash_lout_setup_mode', 'ps_dash_lout_setup_inflow'];
|
||||
nodes.push(group('ps_dash_grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* README */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const README = `# pumpingStation - Example Flows
|
||||
|
||||
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
|
||||
canonical topic API (\`set.mode\`, \`set.inflow\`, \`set.demand\`,
|
||||
\`cmd.calibrate.volume\`, \`cmd.calibrate.level\`). Legacy aliases
|
||||
(\`changemode\`, \`q_in\`, \`Qd\`, \`calibratePredictedVolume\`,
|
||||
\`calibratePredictedLevel\`, \`registerChild\`) still work but log a
|
||||
one-time deprecation warning; these fresh flows use the canonical names only.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Tier | Tabs | Purpose |
|
||||
|---|---|---|---|
|
||||
| \`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. |
|
||||
| \`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
|
||||
|
||||
- Node-RED with the EVOLV package installed (so the \`pumpingStation\`,
|
||||
\`measurement\`, \`machineGroupControl\`, and \`rotatingMachine\` node
|
||||
types are registered).
|
||||
- For \`03-Dashboard.json\`: \`@flowfuse/node-red-dashboard\` (Dashboard 2.0).
|
||||
|
||||
## How to load
|
||||
|
||||
\`\`\`bash
|
||||
# Drop a file into a running Node-RED instance using its Admin API.
|
||||
curl -X POST -H 'Content-Type: application/json' \\
|
||||
--data @nodes/pumpingStation/examples/01-Basic.json \\
|
||||
http://localhost:1880/flows
|
||||
\`\`\`
|
||||
|
||||
Or in the editor: **Menu -> Import -> select file -> Import**. The flows
|
||||
import into their own tabs and can be deployed immediately.
|
||||
|
||||
## 01-Basic - what to try
|
||||
|
||||
1. Deploy.
|
||||
2. Inject \`set.mode = manual\`.
|
||||
3. Inject \`set.inflow = 60 m3/h\` - the basin starts filling. Watch the
|
||||
formatted Port 0 payload in the debug sidebar.
|
||||
4. Inject \`set.demand = 40 %\` - in manual mode this would feed any
|
||||
registered children; here there are no pump children so it is logged
|
||||
and shown on Port 0.
|
||||
5. Inject \`cmd.calibrate.volume = 25 m3\` to jump the predicted-volume
|
||||
integrator to half-full.
|
||||
|
||||
## 02-Integration - 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.
|
||||
2. Open the dashboard at \`http://localhost:1880/dashboard/page/pumping-station\`.
|
||||
3. Use the **Control mode** dropdown to switch between \`manual\`,
|
||||
\`levelbased\`, \`flowbased\`, \`none\`.
|
||||
4. In manual mode, drag the **Manual demand** slider - the demand cascades
|
||||
to the MGC and on to the pumps.
|
||||
5. The three charts (flow, level, volume %) plot live data; the four text
|
||||
widgets show state, percControl, direction, and time-to-empty.
|
||||
|
||||
## Layout conventions
|
||||
|
||||
These flows follow the EVOLV layout rule set in
|
||||
\`.claude/rules/node-red-flow-layout.md\`:
|
||||
|
||||
- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
|
||||
(\`ui-*\` widgets) / Setup (once-true injects).
|
||||
- Cross-tab wiring via **named link out / link in channels**:
|
||||
\`setup:to-ps-mode\`, \`setup:to-ps-inflow\`, \`setup:to-mgc-mode\`,
|
||||
\`cmd:ps-mode\`, \`cmd:ps-demand\`, \`evt:flow\`, \`evt:level\`,
|
||||
\`evt:volpct\`, \`evt:state\`, \`evt:perc\`, \`evt:dir\`, \`evt:tempty\`.
|
||||
- **Lane positions** L0-L7 = \`[120, 360, 600, 840, 1080, 1320, 1560, 1800]\`,
|
||||
driven by each node's S88 level (Process Cell on L5, Unit on L4,
|
||||
Equipment on L3, Control Module on L2).
|
||||
- **Group boxes** wrap each parent + its direct children, coloured by the
|
||||
parent's S88 level.
|
||||
|
||||
## Regenerating
|
||||
|
||||
These flows are generated from \`tools/build-examples.js\`. Edit the
|
||||
generator, never the JSON, then:
|
||||
|
||||
\`\`\`bash
|
||||
node nodes/pumpingStation/tools/build-examples.js
|
||||
\`\`\`
|
||||
|
||||
The script writes \`01-Basic.json\`, \`02-Integration.json\`, and
|
||||
\`03-Dashboard.json\` into this directory.
|
||||
`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function writeFlow(filename, builder) {
|
||||
const flow = builder();
|
||||
const dest = path.join(OUT_DIR, filename);
|
||||
fs.writeFileSync(dest, JSON.stringify(flow, null, 2) + '\n', 'utf8');
|
||||
console.log(`wrote ${dest} (${flow.length} nodes)`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
writeFlow('01-Basic.json', buildBasic);
|
||||
writeFlow('02-Integration.json', buildIntegration);
|
||||
writeFlow('03-Dashboard.json', buildDashboard);
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'README.md'), README, 'utf8');
|
||||
console.log(`wrote ${path.join(OUT_DIR, 'README.md')}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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`.
|
||||
@@ -11,7 +11,11 @@
|
||||
|
||||
## 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.
|
||||
The **Unit** column reflects each descriptor's declared unit (via the `unit: 'm3/h'` shorthand or the legacy `units: { measure, default }`; the measure is derived from the unit). The default unit is what the commandRegistry coerces incoming values to before the handler runs.
|
||||
|
||||
**Command envelope (all EVOLV nodes).** Every command shares one envelope on top of `msg.topic`:
|
||||
- **Value + unit** — send `msg.payload` as a number (with optional sibling `msg.unit`) **or** as `{ value, unit }`. The registry always converts the value to the descriptor's unit before the handler; numeric strings are converted too. A missing unit assumes the descriptor default.
|
||||
- **`msg.origin`** — the control authority that issued the command: `parent` (automation/parent controller, the default), `GUI` (SCADA/HMI operator), or `fysical` (physical buttons). On nodes with a control mode, the mode's `allowedSources` decides which origins are accepted; releasing control is done by changing the mode.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||
|
||||
@@ -19,14 +23,56 @@ The **Unit** column reflects each descriptor's `units: { measure, default }` dec
|
||||
|---|---|---|---|---|
|
||||
| `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. |
|
||||
| `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 -->
|
||||
|
||||
### Input message examples
|
||||
|
||||
One worked `msg` per accepted topic. Send these into **Port 0**. For unit-bearing
|
||||
topics the commandRegistry converts `msg.unit` (or a `{ value, unit }` payload) to
|
||||
the default unit *before* the handler runs — so the unit is optional and any
|
||||
[compatible unit](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) is accepted.
|
||||
|
||||
```js
|
||||
// 1. set.mode — switch control strategy
|
||||
msg = { topic: 'set.mode', payload: 'manual' }; // manual | levelbased | flowbased | none
|
||||
|
||||
// 2. child.register — register a child (usually arrives on Port 2 from the child;
|
||||
// this is the manual form). payload = the child node's Node-RED id.
|
||||
msg = { topic: 'child.register', payload: 'a1b2c3d4.ef567', positionVsParent: 'upstream' };
|
||||
// positionVsParent: upstream | downstream | atequipment (or in | out for predicted-flow children)
|
||||
|
||||
// 3. cmd.calibrate.volume — seed the predicted-volume integrator (default m³)
|
||||
msg = { topic: 'cmd.calibrate.volume', payload: 12.5 }; // 12.5 m³
|
||||
msg = { topic: 'cmd.calibrate.volume', payload: 12500, unit: 'L' }; // 12 500 L → auto-converted to 12.5 m³
|
||||
|
||||
// 4. cmd.calibrate.level — seed the predicted level (default m)
|
||||
msg = { topic: 'cmd.calibrate.level', payload: 1.8 }; // 1.8 m
|
||||
|
||||
// 5. set.inflow — push a measured inflow (default m³/h)
|
||||
msg = { topic: 'set.inflow', payload: 45 }; // 45 m³/h
|
||||
msg = { topic: 'set.inflow', payload: 12.5, unit: 'L/s' }; // 12.5 L/s → 45 m³/h
|
||||
msg = { topic: 'set.inflow', payload: { value: 45, unit: 'm3/h' }, timestamp: 1716998400000 };
|
||||
|
||||
// 6. set.outflow — push a measured/forced outflow (default m³/h)
|
||||
msg = { topic: 'set.outflow', payload: 30 }; // 30 m³/h drawn from the basin
|
||||
|
||||
// 7. set.demand — operator outflow setpoint (default m³/h); ignored unless mode === 'manual'
|
||||
msg = { topic: 'set.demand', payload: 120 }; // 120 m³/h
|
||||
|
||||
// Built-in (every EVOLV node): query.units — ask which units each topic accepts.
|
||||
// Replies on Port 0 with { topic:'query.units', payload:{ node, units } }.
|
||||
msg = { topic: 'query.units', payload: null };
|
||||
```
|
||||
|
||||
> Deprecated aliases behave identically and log a one-time warning, e.g.
|
||||
> `{ topic: 'q_in', payload: 45 }` ≡ `set.inflow`, `{ topic: 'Qd', payload: 120 }` ≡ `set.demand`.
|
||||
|
||||
---
|
||||
|
||||
## Data model — `getOutput()` shape
|
||||
@@ -39,35 +85,88 @@ Keys composed each tick by `specificClass.getOutput()` and emitted via `outputUt
|
||||
|---|---|---|---|
|
||||
| `direction` | string | — | `"steady"` |
|
||||
| `dryRunLevel` | number | — | `0.20400000000000001` |
|
||||
| `dryRunSafetyVol` | number | — | `0.20400000000000001` |
|
||||
| `dryRunSafetyVol` | number | — | `2.55` |
|
||||
| `flowSource` | null | — | `null` |
|
||||
| `heightBasin` | number | m | `1` |
|
||||
| `highVolumeSafetyLevel` | number | — | `2.45` |
|
||||
| `highVolumeSafetyVol` | number | — | `2.45` |
|
||||
| `inflowLevel` | number | m | `2` |
|
||||
| `heightBasin` | number | m | `4` |
|
||||
| `highVolumeSafetyLevel` | number | — | `3.7239999999999998` |
|
||||
| `highVolumeSafetyVol` | number | — | `46.55` |
|
||||
| `inflowLevel` | number | m | `1.5` |
|
||||
| `inletPipeDiameter` | number | — | `0.4` |
|
||||
| `maxVol` | number | m3 | `1` |
|
||||
| `maxVolAtOverflow` | number | m3 | `2.5` |
|
||||
| `manualDemand` | null | — | `null` |
|
||||
| `maxVol` | number | m3 | `50` |
|
||||
| `maxVolAtOverflow` | number | m3 | `47.5` |
|
||||
| `minHeightBasedOn` | string | — | `"outlet"` |
|
||||
| `minVol` | number | m3 | `0.2` |
|
||||
| `minVolAtInflow` | number | m3 | `2` |
|
||||
| `minVolAtOutflow` | number | m3 | `0.2` |
|
||||
| `minVol` | number | m3 | `2.5` |
|
||||
| `minVolAtInflow` | number | m3 | `18.75` |
|
||||
| `minVolAtOutflow` | number | m3 | `2.5` |
|
||||
| `mode` | string | — | `"levelbased"` |
|
||||
| `outflowLevel` | number | m | `0.2` |
|
||||
| `outletPipeDiameter` | number | — | `0.4` |
|
||||
| `overflowLevel` | number | m | `2.5` |
|
||||
| `overflowLevel` | number | m | `3.8` |
|
||||
| `percControl` | number | % | `0` |
|
||||
| `predictedOverflowRate` | number | — | `0` |
|
||||
| `predictedOverflowVolume` | number | — | `0` |
|
||||
| `predictedUnderflowVolume` | number | — | `0` |
|
||||
| `surfaceArea` | number | m2 | `1` |
|
||||
| `surfaceArea` | number | m2 | `12.5` |
|
||||
| `timeleft` | null | s | `null` |
|
||||
| `volEmptyBasin` | number | m3 | `1` |
|
||||
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` |
|
||||
| `volEmptyBasin` | number | m3 | `50` |
|
||||
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `2.5` |
|
||||
|
||||
<!-- 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).
|
||||
|
||||
> [!NOTE]
|
||||
> Two control-state keys carry the live operating mode rather than a measurement:
|
||||
> - `mode` — string, the active control strategy (`levelbased` / `manual` / `flowbased` / `none`). Echoes the most recent `set.mode` input.
|
||||
> - `manualDemand` — number (m³/h) or `null`. The operator outflow setpoint last accepted via `set.demand`; `null` outside `manual` mode.
|
||||
|
||||
### Output message examples
|
||||
|
||||
The node emits on three ports every tick (`outputUtils.formatMsg`). Port 0 / Port 1
|
||||
fire only when at least one field changed (delta-compression); Port 2 fires once at
|
||||
startup. `topic` is the station's configured name (here `"PS-Influent-01"`).
|
||||
|
||||
```js
|
||||
// Port 0 — process data. payload = only the keys that changed this tick.
|
||||
msg = {
|
||||
topic: 'PS-Influent-01',
|
||||
payload: {
|
||||
mode: 'levelbased',
|
||||
direction: 'filling',
|
||||
percControl: 25,
|
||||
'level.predicted.atequipment.default': 3.25, // m
|
||||
'volume.predicted.atequipment.default': 32.5, // m³
|
||||
timeleft: 400, // s, or null when steady
|
||||
manualDemand: null // m³/h, or null outside manual mode
|
||||
}
|
||||
};
|
||||
|
||||
// Port 1 — InfluxDB telemetry. Same changed fields, wrapped for the InfluxDB node.
|
||||
msg = {
|
||||
topic: 'PS-Influent-01',
|
||||
payload: {
|
||||
measurement: 'PS-Influent-01',
|
||||
fields: { percControl: 25, 'volume.predicted.atequipment.default': 32.5 },
|
||||
tags: { id: 'a1b2c3d4.ef567', softwareType: 'pumpingstation', type: 'pumpingStation' },
|
||||
timestamp: '2026-05-29T10:00:00.000Z' // Date
|
||||
}
|
||||
};
|
||||
|
||||
// Port 2 — registration handshake, sent once at startup to the upstream parent.
|
||||
msg = {
|
||||
topic: 'child.register',
|
||||
payload: 'a1b2c3d4.ef567', // this node's id
|
||||
positionVsParent: 'atEquipment',
|
||||
distance: null
|
||||
};
|
||||
```
|
||||
|
||||
> **Child-facing events** are not Port messages — they fire on
|
||||
> `source.measurements.emitter` as `<type>.<variant>.<position>`, e.g. event
|
||||
> `volume.predicted.atequipment` with payload `{ value: 32.5, unit: 'm3', timestamp }`.
|
||||
> Parents subscribe by event name.
|
||||
|
||||
---
|
||||
|
||||
## Configuration schema — editor form to config keys
|
||||
|
||||
Reference in New Issue
Block a user