Compare commits
17 Commits
fe5fa3577b
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a83a85e958 | ||
|
|
e041877ae4 | ||
|
|
8216480950 | ||
|
|
dfaa0c3ae8 | ||
|
|
6e727d929b | ||
| ef07f2a5b2 | |||
|
|
2d68a4f504 | ||
|
|
a3536b7b7f | ||
|
|
f5c6282478 | ||
|
|
df18e97b8b | ||
|
|
2e4ad8d3f1 | ||
|
|
d4de3cf5c5 | ||
|
|
304df7f135 | ||
|
|
03440e1e6c | ||
|
|
2c7fe1792f | ||
|
|
6e89e4916f | ||
|
|
285fd01a5d |
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay
|
||||||
|
# in sync — anything that shouldn't be committed AND shouldn't ship in the
|
||||||
|
# npm tarball goes in both files.
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
31
.npmignore
Normal file
31
.npmignore
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# === Mirrors .gitignore — items below this block are also excluded from
|
||||||
|
# the npm tarball. Kept here verbatim so npm pack doesn't fall back to
|
||||||
|
# the .gitignore inheritance (silent + surprising). ===
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# === Dev-only content the npm tarball doesn't need ===
|
||||||
|
# Tests + their harness — Node-RED loads the entry .js, not the test tree.
|
||||||
|
test/
|
||||||
|
*.test.js
|
||||||
|
|
||||||
|
# Wiki, screenshots, drawio diagrams — useful in the repo, big in the pack.
|
||||||
|
wiki/
|
||||||
|
|
||||||
|
# Local simulation harness + scenario data (dev-only). 870+ KB on disk.
|
||||||
|
simulations/
|
||||||
|
|
||||||
|
# Build/maintenance tooling not used at runtime.
|
||||||
|
tools/
|
||||||
|
|
||||||
|
# Project memory + IDE configs.
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
.repo-mem/
|
||||||
|
CLAUDE.md
|
||||||
|
CLAUDE.local.md
|
||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -21,3 +21,20 @@ Key points for this node:
|
|||||||
- Stack same-level siblings vertically.
|
- Stack same-level siblings vertically.
|
||||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||||
- Wrap in a Node-RED group box coloured `#0c99d9` (Process Cell).
|
- Wrap in a Node-RED group box coloured `#0c99d9` (Process Cell).
|
||||||
|
|
||||||
|
## Folder & File Layout
|
||||||
|
|
||||||
|
Every per-node file MUST use the folder name (`pumpingStation`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
|
||||||
|
|
||||||
|
| Path | Required name |
|
||||||
|
|---|---|
|
||||||
|
| Entry file | `pumpingStation.js` |
|
||||||
|
| Editor HTML | `pumpingStation.html` |
|
||||||
|
| Node adapter | `src/nodeClass.js` |
|
||||||
|
| Domain logic | `src/specificClass.js` |
|
||||||
|
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
|
||||||
|
| Tests | `test/{basic,integration,edge}/*.test.js` |
|
||||||
|
| Example flows | `examples/*.flow.json` |
|
||||||
|
|
||||||
|
|
||||||
|
When adding new files, read the rule above first to avoid drift.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Hand-maintained for Phase 2; the `## Inputs` table is generated from
|
|||||||
| `cmd.calibrate.volume` | `calibratePredictedVolume` | numeric (number or numeric string) — m³ | Resets the predicted-volume series and seeds it with the supplied value; recomputes level. |
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` | numeric (number or numeric string) — m³ | Resets the predicted-volume series and seeds it with the supplied value; recomputes level. |
|
||||||
| `cmd.calibrate.level` | `calibratePredictedLevel` | numeric — metres | Resets the predicted-level series and seeds it with the supplied value; recomputes volume. |
|
| `cmd.calibrate.level` | `calibratePredictedLevel` | numeric — metres | Resets the predicted-level series and seeds it with the supplied value; recomputes volume. |
|
||||||
| `set.inflow` | `q_in` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a manual inflow measurement onto the predicted-flow series. `unit` may be on the message (`msg.unit`) or inside the object payload. |
|
| `set.inflow` | `q_in` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a manual inflow measurement onto the predicted-flow series. `unit` may be on the message (`msg.unit`) or inside the object payload. |
|
||||||
|
| `set.outflow` | `q_out` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a measured outflow value into the basin balance. Same payload conventions as `set.inflow`. |
|
||||||
| `set.demand` | `Qd` | numeric — child setpoint demand | Forwards the demand to direct children (machineGroups / machines / stations). Only honoured in `manual` mode; in other modes the call is logged at `debug` and discarded. |
|
| `set.demand` | `Qd` | numeric — child setpoint demand | Forwards the demand to direct children (machineGroups / machines / stations). Only honoured in `manual` mode; in other modes the call is logged at `debug` and discarded. |
|
||||||
|
|
||||||
Aliases log a one-time deprecation warning the first time they fire.
|
Aliases log a one-time deprecation warning the first time they fire.
|
||||||
|
|||||||
@@ -166,8 +166,8 @@
|
|||||||
"id": "b30af582f935bcb7",
|
"id": "b30af582f935bcb7",
|
||||||
"type": "comment",
|
"type": "comment",
|
||||||
"z": "77f00aef1c966167",
|
"z": "77f00aef1c966167",
|
||||||
"name": "PumpingStation — Dashboard (Tier 2)",
|
"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 → 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.",
|
"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,
|
"x": 660,
|
||||||
"y": 320,
|
"y": 320,
|
||||||
"wires": []
|
"wires": []
|
||||||
@@ -332,13 +332,13 @@
|
|||||||
"z": "77f00aef1c966167",
|
"z": "77f00aef1c966167",
|
||||||
"g": "a9f9b38b0e00c1d7",
|
"g": "a9f9b38b0e00c1d7",
|
||||||
"group": "ui_group_ctrl",
|
"group": "ui_group_ctrl",
|
||||||
"name": "Inflow 60 m³/h",
|
"name": "Inflow 60 m\u00b3/h",
|
||||||
"label": "Inflow 60 m³/h",
|
"label": "Inflow 60 m\u00b3/h",
|
||||||
"order": 3,
|
"order": 3,
|
||||||
"width": "3",
|
"width": "3",
|
||||||
"height": "1",
|
"height": "1",
|
||||||
"emulateClick": false,
|
"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": "",
|
"color": "",
|
||||||
"bgcolor": "",
|
"bgcolor": "",
|
||||||
"icon": "south",
|
"icon": "south",
|
||||||
@@ -360,13 +360,13 @@
|
|||||||
"z": "77f00aef1c966167",
|
"z": "77f00aef1c966167",
|
||||||
"g": "a9f9b38b0e00c1d7",
|
"g": "a9f9b38b0e00c1d7",
|
||||||
"group": "ui_group_ctrl",
|
"group": "ui_group_ctrl",
|
||||||
"name": "Outflow 80 m³/h",
|
"name": "Outflow 80 m\u00b3/h",
|
||||||
"label": "Outflow 80 m³/h",
|
"label": "Outflow 80 m\u00b3/h",
|
||||||
"order": 4,
|
"order": 4,
|
||||||
"width": "3",
|
"width": "3",
|
||||||
"height": "1",
|
"height": "1",
|
||||||
"emulateClick": false,
|
"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": "",
|
"color": "",
|
||||||
"bgcolor": "",
|
"bgcolor": "",
|
||||||
"icon": "north",
|
"icon": "north",
|
||||||
@@ -388,13 +388,13 @@
|
|||||||
"z": "77f00aef1c966167",
|
"z": "77f00aef1c966167",
|
||||||
"g": "42bf82c87d05f498",
|
"g": "42bf82c87d05f498",
|
||||||
"group": "ui_group_ctrl",
|
"group": "ui_group_ctrl",
|
||||||
"name": "Demand 40 m³/h",
|
"name": "Demand 40 m\u00b3/h",
|
||||||
"label": "Demand 40 m³/h (manual)",
|
"label": "Demand 40 m\u00b3/h (manual)",
|
||||||
"order": 5,
|
"order": 5,
|
||||||
"width": "6",
|
"width": "6",
|
||||||
"height": "1",
|
"height": "1",
|
||||||
"emulateClick": false,
|
"emulateClick": false,
|
||||||
"tooltip": "Operator outflow demand — only forwarded when mode = manual",
|
"tooltip": "Operator outflow demand \u2014 only forwarded when mode = manual",
|
||||||
"color": "",
|
"color": "",
|
||||||
"bgcolor": "",
|
"bgcolor": "",
|
||||||
"icon": "speed",
|
"icon": "speed",
|
||||||
@@ -416,13 +416,13 @@
|
|||||||
"z": "77f00aef1c966167",
|
"z": "77f00aef1c966167",
|
||||||
"g": "234bdce20170061a",
|
"g": "234bdce20170061a",
|
||||||
"group": "ui_group_ctrl",
|
"group": "ui_group_ctrl",
|
||||||
"name": "Calibrate V=25 m³",
|
"name": "Calibrate V=25 m\u00b3",
|
||||||
"label": "Calibrate V = 25 m³",
|
"label": "Calibrate V = 25 m\u00b3",
|
||||||
"order": 6,
|
"order": 6,
|
||||||
"width": "3",
|
"width": "3",
|
||||||
"height": "1",
|
"height": "1",
|
||||||
"emulateClick": false,
|
"emulateClick": false,
|
||||||
"tooltip": "Snap the predicted-volume integrator to 25 m³",
|
"tooltip": "Snap the predicted-volume integrator to 25 m\u00b3",
|
||||||
"color": "",
|
"color": "",
|
||||||
"bgcolor": "",
|
"bgcolor": "",
|
||||||
"icon": "tune",
|
"icon": "tune",
|
||||||
@@ -472,8 +472,8 @@
|
|||||||
"z": "77f00aef1c966167",
|
"z": "77f00aef1c966167",
|
||||||
"g": "grp_status_panel",
|
"g": "grp_status_panel",
|
||||||
"name": "fan-out Port 0 (status + charts + raw)",
|
"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",
|
"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": 14,
|
"outputs": 15,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
"initialize": "",
|
"initialize": "",
|
||||||
@@ -523,6 +523,9 @@
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"ui_tpl_raw"
|
"ui_tpl_raw"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"ui_chart_pumping_perccontrol"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -740,8 +743,8 @@
|
|||||||
"z": "77f00aef1c966167",
|
"z": "77f00aef1c966167",
|
||||||
"g": "grp_status_panel",
|
"g": "grp_status_panel",
|
||||||
"group": "ui_group_trends",
|
"group": "ui_group_trends",
|
||||||
"name": "Volume (m³)",
|
"name": "Volume (m\u00b3)",
|
||||||
"label": "Volume (m³)",
|
"label": "Volume (m\u00b3)",
|
||||||
"order": 2,
|
"order": 2,
|
||||||
"width": 6,
|
"width": 6,
|
||||||
"height": 4,
|
"height": 4,
|
||||||
@@ -754,7 +757,7 @@
|
|||||||
"xAxisPropertyType": "timestamp",
|
"xAxisPropertyType": "timestamp",
|
||||||
"xAxisFormat": "",
|
"xAxisFormat": "",
|
||||||
"xAxisFormatType": "auto",
|
"xAxisFormatType": "auto",
|
||||||
"yAxisLabel": "m³",
|
"yAxisLabel": "m\u00b3",
|
||||||
"yAxisProperty": "payload",
|
"yAxisProperty": "payload",
|
||||||
"yAxisPropertyType": "msg",
|
"yAxisPropertyType": "msg",
|
||||||
"xmin": "",
|
"xmin": "",
|
||||||
@@ -862,8 +865,8 @@
|
|||||||
"z": "77f00aef1c966167",
|
"z": "77f00aef1c966167",
|
||||||
"g": "grp_status_panel",
|
"g": "grp_status_panel",
|
||||||
"group": "ui_group_trends",
|
"group": "ui_group_trends",
|
||||||
"name": "Flow (m³/h)",
|
"name": "Flow (m\u00b3/h)",
|
||||||
"label": "Flow (m³/h) — Inflow / Outflow / Net",
|
"label": "Flow (m\u00b3/h) \u2014 Inflow / Outflow / Net",
|
||||||
"order": 4,
|
"order": 4,
|
||||||
"width": 6,
|
"width": 6,
|
||||||
"height": 4,
|
"height": 4,
|
||||||
@@ -876,7 +879,7 @@
|
|||||||
"xAxisPropertyType": "timestamp",
|
"xAxisPropertyType": "timestamp",
|
||||||
"xAxisFormat": "",
|
"xAxisFormat": "",
|
||||||
"xAxisFormatType": "auto",
|
"xAxisFormatType": "auto",
|
||||||
"yAxisLabel": "m³/h",
|
"yAxisLabel": "m\u00b3/h",
|
||||||
"yAxisProperty": "payload",
|
"yAxisProperty": "payload",
|
||||||
"yAxisPropertyType": "msg",
|
"yAxisPropertyType": "msg",
|
||||||
"xmin": "",
|
"xmin": "",
|
||||||
@@ -1029,7 +1032,7 @@
|
|||||||
"enableLog": false,
|
"enableLog": false,
|
||||||
"logLevel": "error",
|
"logLevel": "error",
|
||||||
"positionVsParent": "atEquipment",
|
"positionVsParent": "atEquipment",
|
||||||
"positionIcon": "⊥",
|
"positionIcon": "\u22a5",
|
||||||
"hasDistance": false,
|
"hasDistance": false,
|
||||||
"distance": "",
|
"distance": "",
|
||||||
"controlMode": "levelbased",
|
"controlMode": "levelbased",
|
||||||
@@ -1066,5 +1069,68 @@
|
|||||||
"modules": {
|
"modules": {
|
||||||
"EVOLV": "1.0.29"
|
"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": [
|
||||||
|
[]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<script>//test
|
<script>//test
|
||||||
RED.nodes.registerType("pumpingStation", {
|
RED.nodes.registerType("pumpingStation", {
|
||||||
category: "EVOLV",
|
category: "EVOLV",
|
||||||
color: "#0c99d9", // color for the node based on the S88 schema
|
color: "#8B4513",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
|
|
||||||
@@ -86,6 +86,8 @@
|
|||||||
shiftArmPercent: { value: 95 },
|
shiftArmPercent: { value: 95 },
|
||||||
startLevel: { value: 1 }, // m, pump-on threshold (engagement edge)
|
startLevel: { value: 1 }, // m, pump-on threshold (engagement edge)
|
||||||
stopLevel: { value: 0.5 }, // m, pump-off threshold (hysteresis fall-back)
|
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)
|
minLevel: { value: 0.3 }, // m, hard-stop (just above outflow pipe top)
|
||||||
maxLevel: { value: 3.8 }, // m, 100% demand saturation
|
maxLevel: { value: 3.8 }, // m, 100% demand saturation
|
||||||
flowSetpoint: { value: null },
|
flowSetpoint: { value: null },
|
||||||
@@ -418,6 +420,11 @@
|
|||||||
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
|
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
|
||||||
<span class="ps-unit">m</span>
|
<span class="ps-unit">m</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#27AE60" data-couples-line="ps-mode-line-holdLevel">
|
||||||
|
<div><label>holdLevel</label><div class="ps-sub">0 % ramp foot — leave at startLevel for no hold band</div></div>
|
||||||
|
<input type="number" id="node-input-holdLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
|
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
|
||||||
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
|
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
|
||||||
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
|
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
|
||||||
@@ -475,6 +482,7 @@
|
|||||||
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-holdLevel" y1="24" y2="140" stroke="#27AE60" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
|
||||||
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
||||||
@@ -565,6 +573,7 @@
|
|||||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||||
<option value="influxdb">influxdb</option>
|
<option value="influxdb">influxdb</option>
|
||||||
|
<option value="frost">frost</option>
|
||||||
<option value="json">json</option>
|
<option value="json">json</option>
|
||||||
<option value="csv">csv</option>
|
<option value="csv">csv</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -4,7 +4,14 @@
|
|||||||
//
|
//
|
||||||
// Invariants enforced (level-space, bottom → top):
|
// Invariants enforced (level-space, bottom → top):
|
||||||
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||||
// dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
// dryRunLevel ≤ minLevel ≤ startLevel ≤ holdLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
||||||
|
//
|
||||||
|
// startLevel is INTENTIONALLY not constrained against inflowLevel: setting
|
||||||
|
// startLevel above the gravity-feed inlet is the "buffer in the sewer"
|
||||||
|
// configuration where the upstream pipe network is used as overflow storage
|
||||||
|
// before pumping engages. holdLevel (optional, defaults to startLevel when
|
||||||
|
// omitted) is the 0 % ramp foot — pumps engage at startLevel but hold at
|
||||||
|
// min flow until level rises through holdLevel.
|
||||||
//
|
//
|
||||||
// dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
|
// dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
|
||||||
// The validator recomputes them so a config that places minLevel below the
|
// The validator recomputes them so a config that places minLevel below the
|
||||||
@@ -56,14 +63,26 @@ function validateThresholdOrdering(basin, levelbased, safety) {
|
|||||||
const points = computeSafetyPoints(basin, safety);
|
const points = computeSafetyPoints(basin, safety);
|
||||||
const { dryRunLevel, highVolumeSafetyLevel } = points;
|
const { dryRunLevel, highVolumeSafetyLevel } = points;
|
||||||
|
|
||||||
|
// holdLevel is optional — when omitted (null/undefined/NaN) it equals
|
||||||
|
// startLevel at runtime, so skip both holdLevel-related checks in that
|
||||||
|
// case (the canonical engine semantics still hold). Explicit null/undefined
|
||||||
|
// check first so `Number(null) === 0` doesn't accidentally flag a default
|
||||||
|
// schema value as a real operator-provided one.
|
||||||
|
const rawHold = lvl.holdLevel;
|
||||||
|
const holdLevelProvided = rawHold != null && Number.isFinite(Number(rawHold));
|
||||||
|
const holdLevel = holdLevelProvided ? Number(rawHold) : null;
|
||||||
|
|
||||||
const checks = [
|
const checks = [
|
||||||
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||||
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||||
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||||
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||||
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||||
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
|
|
||||||
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
...(holdLevelProvided ? [
|
||||||
|
['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel],
|
||||||
|
['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
] : []),
|
||||||
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
|
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,14 @@ exports.setOutflow = (source, msg) => {
|
|||||||
|
|
||||||
exports.setDemand = (source, msg, ctx) => {
|
exports.setDemand = (source, msg, ctx) => {
|
||||||
const log = _logger(source, ctx);
|
const log = _logger(source, ctx);
|
||||||
const demand = Number(msg.payload);
|
// generalFunctions/commandRegistry's _normaliseUnits has already converted
|
||||||
|
// msg.payload to m3/h (the descriptor's units.default — see
|
||||||
|
// commands/index.js). Accepts {value, unit} objects upstream; we just read
|
||||||
|
// the normalized number here. _manualDemand is stored in m3/h, no further
|
||||||
|
// conversion needed.
|
||||||
|
const demand = Number(msg?.payload);
|
||||||
if (!Number.isFinite(demand)) {
|
if (!Number.isFinite(demand)) {
|
||||||
log?.warn?.(`set.demand: invalid Qd value '${msg.payload}'`);
|
log?.warn?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source.mode !== 'manual') {
|
if (source.mode !== 'manual') {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
// through the dead band [stopLevel, startLevel] emitting a small
|
// through the dead band [stopLevel, startLevel] emitting a small
|
||||||
// keep-alive demand so MGC keeps a single pump draining the basin.
|
// keep-alive demand so MGC keeps a single pump draining the basin.
|
||||||
// 3. Up-curve mapping — level mapped to demand 0..100 % across
|
// 3. Up-curve mapping — level mapped to demand 0..100 % across
|
||||||
// [inflowLevel, maxLevel] using linear or log shape.
|
// [max(startLevel, inflowLevel), maxLevel] using linear or log shape.
|
||||||
|
// Foot at startLevel when startLevel > inflowLevel allows buffering
|
||||||
|
// in the upstream sewer above the gravity-feed point.
|
||||||
// 4. Shifted-ramp hysteresis — when the up-curve crosses
|
// 4. Shifted-ramp hysteresis — when the up-curve crosses
|
||||||
// shiftArmPercent the strategy ARMS; on the next filling→draining
|
// shiftArmPercent the strategy ARMS; on the next filling→draining
|
||||||
// flip it captures the up-curve value as `hold`; while draining
|
// flip it captures the up-curve value as `hold`; while draining
|
||||||
@@ -45,13 +47,21 @@ function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
|
|||||||
|
|
||||||
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
||||||
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
||||||
await Promise.all(
|
// The caller (run() below) already gated turn-off via the minLevel
|
||||||
Object.values(machineGroups).map((group) =>
|
// hard-stop, stopLevel falling-edge, and the rising-edge engagement gate.
|
||||||
group.handleInput('parent', percentControl).catch((err) => {
|
// By the time we get here, pumps should be running — `0 %` is the engaged
|
||||||
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
|
// "min flow" floor (MGC.setDemand interpolates 0 → dt.flow.min), NOT a
|
||||||
})
|
// soft turn-off. Forward unconditionally.
|
||||||
)
|
const forward = (group) => {
|
||||||
);
|
if (typeof group.setDemand !== 'function') {
|
||||||
|
logger?.error?.(`Group "${group.config?.general?.name}" missing setDemand — refusing to call handleInput with a percent value`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.resolve(group.setDemand(percentControl, '%')).catch((err) => {
|
||||||
|
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err && err.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await Promise.all(Object.values(machineGroups).map(forward));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
||||||
@@ -118,6 +128,8 @@ async function run(ctx, controlState, direction) {
|
|||||||
controlState.percControl = 0;
|
controlState.percControl = 0;
|
||||||
if (host) {
|
if (host) {
|
||||||
host._stopHystRunning = false;
|
host._stopHystRunning = false;
|
||||||
|
host._shiftArmed = false;
|
||||||
|
host._shiftHoldValue = null;
|
||||||
host._lastDirection = direction;
|
host._lastDirection = direction;
|
||||||
}
|
}
|
||||||
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||||
@@ -131,13 +143,38 @@ async function run(ctx, controlState, direction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Up-curve mapping. Foot stays at inflowLevel (the basin's
|
// 3. Engagement gate. Pumps stay OFF until level rises through startLevel
|
||||||
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
|
// for the first time (rising-edge); once engaged they stay on until
|
||||||
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
|
// level drops through stopLevel (falling-edge — handled by case 2).
|
||||||
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
|
// Without an explicit stopLevel the gate collapses to `level >= startLevel`.
|
||||||
|
// Moved out of the percentControl path so 0 % can mean "engaged at
|
||||||
|
// min flow" instead of "stopped". Disengagement also clears the
|
||||||
|
// shifted-ramp hysteresis so it doesn't survive a stop/start cycle.
|
||||||
|
const isEngaged = host ? host._stopHystRunning : (level >= startLevel);
|
||||||
|
if (!isEngaged) {
|
||||||
|
controlState.percControl = 0;
|
||||||
|
if (host) {
|
||||||
|
host._shiftArmed = false;
|
||||||
|
host._shiftHoldValue = null;
|
||||||
|
host._lastDirection = direction;
|
||||||
|
}
|
||||||
|
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Up-curve mapping. Foot = holdLevel (defaults to startLevel; operators
|
||||||
|
// can raise it to introduce a hold band [startLevel, holdLevel] where
|
||||||
|
// pumps run at min flow before the ramp begins). `inflowLevel` does NOT
|
||||||
|
// shape the curve — it's basin geometry, not a control setpoint.
|
||||||
|
// Explicit null/undefined check first so `Number(null) === 0` doesn't
|
||||||
|
// silently put the ramp foot at the basin floor.
|
||||||
|
const rawHold = cfg.holdLevel;
|
||||||
|
const holdLevel = (rawHold != null && Number.isFinite(Number(rawHold)))
|
||||||
|
? Number(rawHold) : startLevel;
|
||||||
|
const rampFoot = Math.max(startLevel, holdLevel);
|
||||||
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
|
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
|
||||||
|
|
||||||
// 4. Shifted-ramp arming.
|
// 5. Shifted-ramp arming.
|
||||||
if (host) {
|
if (host) {
|
||||||
if (cfg.enableShiftedRamp) {
|
if (cfg.enableShiftedRamp) {
|
||||||
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
||||||
@@ -177,10 +214,14 @@ async function run(ctx, controlState, direction) {
|
|||||||
let percControl;
|
let percControl;
|
||||||
if (!inDrainingHold) {
|
if (!inDrainingHold) {
|
||||||
if (level < rampFoot) {
|
if (level < rampFoot) {
|
||||||
// While engaged via stopLevel hysteresis AND inside the dead band
|
// Engaged (we passed the gate above) but below the ramp foot. Two
|
||||||
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
|
// sub-cases:
|
||||||
// single pump running.
|
// (a) Inside the configurable hold band [startLevel, holdLevel] —
|
||||||
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
|
// emit 0 %, which MGC's setDemand interpolates to flow.min.
|
||||||
|
// (b) Inside the falling-edge keep-alive band [stopLevel, startLevel]
|
||||||
|
// — emit deadZoneKeepAlivePercent (default 1 %) so MGC keeps
|
||||||
|
// at least one pump turning rather than dispatching a clean min.
|
||||||
|
if (stopThresholdActive && level < startLevel) {
|
||||||
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
|
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
|
||||||
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
||||||
percControl = Math.max(0, keepAlive);
|
percControl = Math.max(0, keepAlive);
|
||||||
@@ -212,6 +253,26 @@ async function run(ctx, controlState, direction) {
|
|||||||
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
|
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// We are past every off-gate, so the station is engaged and the computed
|
||||||
|
// demand is meant to drive pumps. If no machine group is registered the
|
||||||
|
// demand has nowhere to go and the pumps stay silent — the signature of a
|
||||||
|
// dropped Port 2 parent↔group registration (e.g. after a partial redeploy
|
||||||
|
// that recreated this node). Warn once until a group reappears so the
|
||||||
|
// failure isn't invisible.
|
||||||
|
const groupCount = machineGroups ? Object.keys(machineGroups).length : 0;
|
||||||
|
if (groupCount === 0) {
|
||||||
|
if (host && !host._warnedNoMachineGroup) {
|
||||||
|
logger?.warn?.(
|
||||||
|
`Level-based control engaged (demand ${percControl.toFixed(1)} %) but no machine group is registered — `
|
||||||
|
+ `pumps cannot be driven. The parent↔group registration was likely lost on a partial redeploy; `
|
||||||
|
+ `redeploy/restart fully to re-run the Port 2 registration handshake.`
|
||||||
|
);
|
||||||
|
host._warnedNoMachineGroup = true;
|
||||||
|
}
|
||||||
|
} else if (host) {
|
||||||
|
host._warnedNoMachineGroup = false;
|
||||||
|
}
|
||||||
|
|
||||||
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function forwardDemand(ctx, demand) {
|
async function forwardDemand(ctx, demand) {
|
||||||
const { machineGroups, machines, logger } = ctx;
|
const { machineGroups, machines, unitPolicy, logger } = ctx;
|
||||||
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
||||||
|
|
||||||
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
||||||
|
const groupDemand = unitPolicy.convert(demand, 'm3/h', 'm3/s', 'manual demand to machineGroups');
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.values(machineGroups).map((group) =>
|
Object.values(machineGroups).map((group) =>
|
||||||
group.handleInput('parent', demand).catch((err) => {
|
group.handleInput('parent', groupDemand).catch((err) => {
|
||||||
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
|
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,18 @@ async function forwardDemand(ctx, demand) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neither a group nor a direct machine is registered, so the operator's
|
||||||
|
// demand silently goes nowhere. Surface it — the usual cause is a dropped
|
||||||
|
// Port 2 parent↔child registration after a partial redeploy.
|
||||||
|
const noGroups = !machineGroups || Object.keys(machineGroups).length === 0;
|
||||||
|
const noMachines = !machines || Object.keys(machines).length === 0;
|
||||||
|
if (noGroups && noMachines) {
|
||||||
|
logger?.warn?.(
|
||||||
|
`Manual demand ${demand} not forwarded — no machine group or machine is registered to this pumping station. `
|
||||||
|
+ `Check the parent↔child Port 2 registration (redeploy/restart fully to restore it).`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
// ≤-checks below are skipped rather than false-flagged).
|
// ≤-checks below are skipped rather than false-flagged).
|
||||||
const basinHraw = fNum('basinHeight');
|
const basinHraw = fNum('basinHeight');
|
||||||
const start = fNum('startLevel');
|
const start = fNum('startLevel');
|
||||||
|
const hold = fNum('holdLevel');
|
||||||
const inlet = fNum('inflowLevel');
|
const inlet = fNum('inflowLevel');
|
||||||
const max = fNum('maxLevel');
|
const max = fNum('maxLevel');
|
||||||
const ovfl = fNum('overflowLevel');
|
const ovfl = fNum('overflowLevel');
|
||||||
@@ -154,8 +155,12 @@
|
|||||||
issues.push('outflowLevel must be > 0');
|
issues.push('outflowLevel must be > 0');
|
||||||
if (!ok(dryLvl, start, '<'))
|
if (!ok(dryLvl, start, '<'))
|
||||||
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
|
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
|
||||||
if (!ok(start, inlet, '<='))
|
if (!ok(start, max, '<'))
|
||||||
issues.push('startLevel must be ≤ inflowLevel');
|
issues.push('startLevel must be < maxLevel');
|
||||||
|
if (!ok(start, hold, '<='))
|
||||||
|
issues.push('holdLevel must be ≥ startLevel (use startLevel for no hold band)');
|
||||||
|
if (!ok(hold, max, '<'))
|
||||||
|
issues.push('holdLevel must be < maxLevel');
|
||||||
if (!ok(inlet, max, '<='))
|
if (!ok(inlet, max, '<='))
|
||||||
issues.push('inflowLevel must be ≤ maxLevel');
|
issues.push('inflowLevel must be ≤ maxLevel');
|
||||||
if (!ok(max, ovfl, '<='))
|
if (!ok(max, ovfl, '<='))
|
||||||
|
|||||||
@@ -3,8 +3,14 @@
|
|||||||
// the current values of related inputs, so the up/down arrows stop at
|
// the current values of related inputs, so the up/down arrows stop at
|
||||||
// values that respect the basin hierarchy:
|
// values that respect the basin hierarchy:
|
||||||
//
|
//
|
||||||
// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
|
// 0 < outflowLevel < dryRunLevel < startLevel < maxLevel ≤ overflowLevel ≤ basinHeight
|
||||||
// ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
|
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||||
|
//
|
||||||
|
// startLevel is intentionally NOT clamped against inflowLevel: pushing
|
||||||
|
// startLevel above the gravity-feed inlet is the "buffer in the sewer"
|
||||||
|
// configuration where upstream pipe storage absorbs flow before pumping
|
||||||
|
// engages. The level-based ramp foot is max(startLevel, inflowLevel) so
|
||||||
|
// either ordering is valid.
|
||||||
//
|
//
|
||||||
// The user can still type out-of-range values via the keyboard (HTML5
|
// The user can still type out-of-range values via the keyboard (HTML5
|
||||||
// min/max only constrain the spinner). The validation ribbons in
|
// min/max only constrain the spinner). The validation ribbons in
|
||||||
@@ -52,10 +58,10 @@
|
|||||||
|
|
||||||
setBounds('startLevel',
|
setBounds('startLevel',
|
||||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||||
inlet ?? max ?? overflow ?? basinHeight);
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
setBounds('inflowLevel',
|
setBounds('inflowLevel',
|
||||||
start ?? EPS,
|
EPS,
|
||||||
max ?? overflow ?? basinHeight);
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
setBounds('maxLevel',
|
setBounds('maxLevel',
|
||||||
@@ -73,6 +79,14 @@
|
|||||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||||
start ?? inlet ?? max ?? overflow ?? basinHeight);
|
start ?? inlet ?? max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
|
// holdLevel — 0 % ramp foot. Defaults to startLevel (no hold band);
|
||||||
|
// when raised above startLevel, pumps engage at startLevel but emit
|
||||||
|
// 0 % across [startLevel, holdLevel] before the ramp begins. Bounds:
|
||||||
|
// startLevel ≤ holdLevel < maxLevel.
|
||||||
|
setBounds('holdLevel',
|
||||||
|
Number.isFinite(start) ? start : EPS,
|
||||||
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
// Shift inputs (only relevant when shifted ramp enabled).
|
// Shift inputs (only relevant when shifted ramp enabled).
|
||||||
if (shiftEnabled) {
|
if (shiftEnabled) {
|
||||||
setBounds('shiftLevel',
|
setBounds('shiftLevel',
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
return Number.isFinite(v) ? v : null;
|
return Number.isFinite(v) ? v : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set a numeric input's value, or blank if not finite.
|
// Set a numeric input's value, or blank if not finite. Accepts numeric
|
||||||
|
// strings (Node-RED's auto-form-binding stores form values as strings).
|
||||||
ns.setNumberField = (id, val) => {
|
ns.setNumberField = (id, val) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
if (!el) return;
|
||||||
|
const num = typeof val === 'number' ? val : parseFloat(val);
|
||||||
|
el.value = Number.isFinite(num) ? num : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add input + change listeners to a list of node-input-* ids.
|
// Add input + change listeners to a list of node-input-* ids.
|
||||||
|
|||||||
@@ -23,13 +23,16 @@
|
|||||||
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
const start = fNum('startLevel');
|
const start = fNum('startLevel');
|
||||||
|
const hold = fNum('holdLevel');
|
||||||
const inlet = fNum('inflowLevel');
|
const inlet = fNum('inflowLevel');
|
||||||
const max = fNum('maxLevel');
|
const max = fNum('maxLevel');
|
||||||
// Optional stopLevel — explicit pump-off threshold. Drawn as its
|
// Optional stopLevel — explicit pump-off threshold. Drawn as its
|
||||||
// own marker line; does NOT shift the ramp foot. Must be < startLevel
|
// own marker line; does NOT shift the ramp foot. Renders as long as
|
||||||
// for the marker to render.
|
// the typed value is a non-negative number — the start-vs-stop
|
||||||
|
// ordering check belongs to the validation ribbon, not the visual
|
||||||
|
// marker (otherwise the line vanishes while the user is mid-edit).
|
||||||
const stopRaw = fNum('stopLevel');
|
const stopRaw = fNum('stopLevel');
|
||||||
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null;
|
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 ? stopRaw : null;
|
||||||
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
|
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
|
||||||
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
||||||
// we draw it as the leftmost vertical marker so the user sees
|
// we draw it as the leftmost vertical marker so the user sees
|
||||||
@@ -91,18 +94,17 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Up curve. Engagement edge is startLevel (pump-on threshold); the
|
// Up curve. Engagement edge is startLevel (pump-on threshold); the
|
||||||
// ramp foot is inflowLevel — matching the runtime in
|
// ramp foot is holdLevel, with a Math.max(startLevel, …) safety
|
||||||
// _controlLevelBased, which scales demand over [inflowLevel, maxLevel].
|
// floor — matching the runtime in levelBased.run.
|
||||||
// The OFF baseline is drawn for level < startLevel; between startLevel
|
// - holdLevel == startLevel (default): no hold band, 0..100 % across
|
||||||
// and inflowLevel demand sits flat at 0 % (system armed but not yet
|
// [startLevel, maxLevel].
|
||||||
// ramping); from inflowLevel demand ramps to 100 % at maxLevel.
|
// - holdLevel > startLevel: pumps engaged across [startLevel,
|
||||||
|
// holdLevel] at 0 % (= MGC flow.min), then 0..100 % across
|
||||||
|
// [holdLevel, maxLevel].
|
||||||
const up = document.getElementById('ps-mode-curve-up');
|
const up = document.getElementById('ps-mode-curve-up');
|
||||||
const down = document.getElementById('ps-mode-curve-down');
|
const down = document.getElementById('ps-mode-curve-down');
|
||||||
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
||||||
// Runtime falls back to startLevel when inflowLevel is missing
|
const upFoot = Number.isFinite(hold) && hold > start ? hold : start;
|
||||||
// (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;
|
|
||||||
if (up) up.setAttribute('points', buildPath(start, upFoot, max));
|
if (up) up.setAttribute('points', buildPath(start, upFoot, max));
|
||||||
|
|
||||||
// Shifted-DOWN curve (only when shift enabled): represents the
|
// Shifted-DOWN curve (only when shift enabled): represents the
|
||||||
@@ -167,6 +169,7 @@
|
|||||||
['dryRunLevel', dryRun],
|
['dryRunLevel', dryRun],
|
||||||
['startLevel', start],
|
['startLevel', start],
|
||||||
['stopLevel', stop],
|
['stopLevel', stop],
|
||||||
|
['holdLevel', hold],
|
||||||
['inflowLevel', inlet],
|
['inflowLevel', inlet],
|
||||||
['maxLevel', max],
|
['maxLevel', max],
|
||||||
['overflowLevel', overflow],
|
['overflowLevel', overflow],
|
||||||
|
|||||||
@@ -65,6 +65,17 @@
|
|||||||
|
|
||||||
// Numeric field defaults.
|
// Numeric field defaults.
|
||||||
ns.setNumberField('node-input-startLevel', node.startLevel);
|
ns.setNumberField('node-input-startLevel', node.startLevel);
|
||||||
|
ns.setNumberField('node-input-stopLevel', node.stopLevel);
|
||||||
|
// holdLevel defaults to startLevel when omitted (no hold band). Show
|
||||||
|
// the saved value if there is one; otherwise mirror startLevel so the
|
||||||
|
// user immediately sees the "no hold band" baseline. Coerce to Number
|
||||||
|
// because Node-RED form-bind stores numeric inputs as strings.
|
||||||
|
const holdNum = parseFloat(node.holdLevel);
|
||||||
|
ns.setNumberField('node-input-holdLevel',
|
||||||
|
Number.isFinite(holdNum) ? holdNum : node.startLevel);
|
||||||
|
const deadZoneNum = parseFloat(node.deadZoneKeepAlivePercent);
|
||||||
|
ns.setNumberField('node-input-deadZoneKeepAlivePercent',
|
||||||
|
Number.isFinite(deadZoneNum) ? deadZoneNum : 1);
|
||||||
ns.setNumberField('node-input-maxLevel', node.maxLevel);
|
ns.setNumberField('node-input-maxLevel', node.maxLevel);
|
||||||
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
|
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
|
||||||
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
||||||
@@ -77,16 +88,22 @@
|
|||||||
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
||||||
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
||||||
|
|
||||||
// Bind redraws to the inputs each diagram cares about.
|
// Bind redraws to the inputs each diagram cares about. The basin
|
||||||
|
// diagram itself only paints inflow/outflow/overflow lines, but its
|
||||||
|
// validation ribbon also enforces startLevel/holdLevel/maxLevel
|
||||||
|
// ordering — so it has to refire when any of those change too, or
|
||||||
|
// the "Fix before deploy" ribbon goes stale mid-edit.
|
||||||
ns.bindRedraw(
|
ns.bindRedraw(
|
||||||
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
|
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
|
||||||
|
'startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
|
||||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
|
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
|
||||||
ns.basinDiagram.redraw
|
ns.basinDiagram.redraw
|
||||||
);
|
);
|
||||||
ns.bindRedraw(
|
ns.bindRedraw(
|
||||||
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
|
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
|
||||||
// so the mode preview must redraw when either of those change.
|
// so the mode preview must redraw when either of those change.
|
||||||
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
['startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
|
||||||
|
'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||||
'dryRunThresholdPercent',
|
'dryRunThresholdPercent',
|
||||||
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
||||||
'shiftArmPercent'],
|
'shiftArmPercent'],
|
||||||
@@ -97,7 +114,7 @@
|
|||||||
// so the next redraw + validation sees the correct min/max attrs.
|
// so the next redraw + validation sees the correct min/max attrs.
|
||||||
ns.bindRedraw(
|
ns.bindRedraw(
|
||||||
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
||||||
'inflowLevel', 'startLevel', 'outflowLevel',
|
'inflowLevel', 'startLevel', 'stopLevel', 'holdLevel', 'outflowLevel',
|
||||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||||
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
||||||
() => ns.bounds?.apply()
|
() => ns.bounds?.apply()
|
||||||
|
|||||||
@@ -50,6 +50,15 @@
|
|||||||
node.logCurveFactor = parseNum('node-input-logCurveFactor');
|
node.logCurveFactor = parseNum('node-input-logCurveFactor');
|
||||||
node.startLevel = parseNum('node-input-startLevel');
|
node.startLevel = parseNum('node-input-startLevel');
|
||||||
node.maxLevel = parseNum('node-input-maxLevel');
|
node.maxLevel = parseNum('node-input-maxLevel');
|
||||||
|
// Persist as numbers — Node-RED's auto-form-binding would store these as
|
||||||
|
// strings, and oneditprepare's setNumberField rejects non-Number values,
|
||||||
|
// so the input would blank out on reopen.
|
||||||
|
const stopLevelVal = parseNum('node-input-stopLevel');
|
||||||
|
node.stopLevel = Number.isFinite(stopLevelVal) ? stopLevelVal : null;
|
||||||
|
const holdLevelVal = parseNum('node-input-holdLevel');
|
||||||
|
if (Number.isFinite(holdLevelVal)) node.holdLevel = holdLevelVal;
|
||||||
|
const deadZoneVal = parseNum('node-input-deadZoneKeepAlivePercent');
|
||||||
|
if (Number.isFinite(deadZoneVal)) node.deadZoneKeepAlivePercent = deadZoneVal;
|
||||||
// minLevel is no longer a user input — it's the derived dryRunLevel
|
// minLevel is no longer a user input — it's the derived dryRunLevel
|
||||||
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
|
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
|
||||||
// uses node.minLevel as the unconditional STOP threshold; we set it
|
// uses node.minLevel as the unconditional STOP threshold; we set it
|
||||||
|
|||||||
@@ -57,6 +57,32 @@ class FlowAggregator {
|
|||||||
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pick the best-available variant for one side of the basin balance.
|
||||||
|
// Mirrors selectBestNetFlow's variant precedence (measured first, then
|
||||||
|
// predicted) but resolves each side independently — so a real measured
|
||||||
|
// upstream sensor + a predicted pump outflow both feed the integrator.
|
||||||
|
// Returns the summed flow at the requested positions. The first variant
|
||||||
|
// that has any registered measurement at one of those positions wins,
|
||||||
|
// even if its sum is 0 (a sensor that reads 0 is still data).
|
||||||
|
_pickFlowSum(positions, flowUnit = 'm3/s') {
|
||||||
|
const buckets = this.measurements.measurements?.flow;
|
||||||
|
if (!buckets) return { sum: 0, variant: null };
|
||||||
|
for (const variant of this.flowVariants) {
|
||||||
|
const variantBucket = buckets[variant];
|
||||||
|
if (!variantBucket) continue;
|
||||||
|
const hasAny = positions.some((pos) => {
|
||||||
|
const posBucket = variantBucket[pos];
|
||||||
|
return posBucket && Object.keys(posBucket).length > 0;
|
||||||
|
});
|
||||||
|
if (!hasAny) continue;
|
||||||
|
return {
|
||||||
|
sum: this.measurements.sum('flow', variant, positions, flowUnit) || 0,
|
||||||
|
variant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { sum: 0, variant: null };
|
||||||
|
}
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
const flowUnit = 'm3/s';
|
const flowUnit = 'm3/s';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -64,8 +90,13 @@ class FlowAggregator {
|
|||||||
// Synthetic spill flow lives at its OWN position ('overflow') —
|
// Synthetic spill flow lives at its OWN position ('overflow') —
|
||||||
// not as a child of 'out'. That keeps it out of the operational
|
// not as a child of 'out'. That keeps it out of the operational
|
||||||
// outflow sum here so no self-subtraction is needed.
|
// outflow sum here so no self-subtraction is needed.
|
||||||
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
// Inflow + outflow are resolved per-side: a real measured upstream
|
||||||
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
// sensor (variant=measured) + a predicted pump-curve outflow
|
||||||
|
// (variant=predicted) is the common realistic mix.
|
||||||
|
const inflowPick = this._pickFlowSum(this.flowPositions.inflow, flowUnit);
|
||||||
|
const outflowPick = this._pickFlowSum(this.flowPositions.outflow, flowUnit);
|
||||||
|
const inflow = inflowPick.sum;
|
||||||
|
const outflowReal = outflowPick.sum;
|
||||||
|
|
||||||
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class MeasurementRouter {
|
|||||||
|
|
||||||
onLevelMeasurement(position, value, context = {}) {
|
onLevelMeasurement(position, value, context = {}) {
|
||||||
this.measurements.type('level').variant('measured').position(position)
|
this.measurements.type('level').variant('measured').position(position)
|
||||||
.value(value).unit(context.unit);
|
.value(value, context.timestamp, context.unit);
|
||||||
|
|
||||||
const series = this.measurements.type('level').variant('measured').position(position);
|
const series = this.measurements.type('level').variant('measured').position(position);
|
||||||
const levelMeters = series.getCurrentValue('m');
|
const levelMeters = series.getCurrentValue('m');
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class nodeClass extends BaseNodeAdapter {
|
|||||||
minLevel: uiConfig.minLevel,
|
minLevel: uiConfig.minLevel,
|
||||||
startLevel: uiConfig.startLevel,
|
startLevel: uiConfig.startLevel,
|
||||||
stopLevel: uiConfig.stopLevel,
|
stopLevel: uiConfig.stopLevel,
|
||||||
|
holdLevel: uiConfig.holdLevel,
|
||||||
maxLevel: uiConfig.maxLevel,
|
maxLevel: uiConfig.maxLevel,
|
||||||
// Editor names the field levelCurveType; runtime uses curveType.
|
// Editor names the field levelCurveType; runtime uses curveType.
|
||||||
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
||||||
@@ -44,6 +45,7 @@ class nodeClass extends BaseNodeAdapter {
|
|||||||
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
||||||
shiftLevel: uiConfig.shiftLevel,
|
shiftLevel: uiConfig.shiftLevel,
|
||||||
shiftArmPercent: uiConfig.shiftArmPercent,
|
shiftArmPercent: uiConfig.shiftArmPercent,
|
||||||
|
deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
safety: {
|
safety: {
|
||||||
|
|||||||
@@ -18,15 +18,21 @@ class PumpingStation extends BaseDomain {
|
|||||||
static name = 'pumpingStation';
|
static name = 'pumpingStation';
|
||||||
|
|
||||||
// Internal math runs in m3/s for flow and m for level so the volume
|
// Internal math runs in m3/s for flow and m for level so the volume
|
||||||
// integrator (flow × dt) is unit-consistent. Strict canonicals make
|
// integrator (flow × dt) is unit-consistent — canonical stays m3/s, the
|
||||||
// unit drift in child-fed measurements an explicit error.
|
// platform-wide convention every cross-node consumer (MGC demand math,
|
||||||
|
// physics-sanity) assumes. Strict canonicals make unit drift in child-fed
|
||||||
|
// measurements an explicit error.
|
||||||
|
// Output flow / netFlowRate are emitted in m3/h so telemetry/dashboard
|
||||||
|
// series land on the same axis as the rest of the pump group (verified
|
||||||
|
// slice #47); the m3/s→m3/h presentation conversion happens at the output
|
||||||
|
// boundary only — it never touches the canonical integrator basis.
|
||||||
// overflowVolume / underflowVolume are listed in output so the
|
// overflowVolume / underflowVolume are listed in output so the
|
||||||
// MeasurementContainer keeps the integrator's m³ unit on those streams
|
// MeasurementContainer keeps the integrator's m³ unit on those streams
|
||||||
// (FlowAggregator writes spill / underflow per tick).
|
// (FlowAggregator writes spill / underflow per tick).
|
||||||
static unitPolicy = UnitPolicy.declare({
|
static unitPolicy = UnitPolicy.declare({
|
||||||
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||||
output: {
|
output: {
|
||||||
flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3',
|
flow: 'm3/h', netFlowRate: 'm3/h', level: 'm', volume: 'm3',
|
||||||
overflowVolume: 'm3', underflowVolume: 'm3',
|
overflowVolume: 'm3', underflowVolume: 'm3',
|
||||||
},
|
},
|
||||||
requireUnitForTypes: [],
|
requireUnitForTypes: [],
|
||||||
@@ -146,6 +152,7 @@ class PumpingStation extends BaseDomain {
|
|||||||
levelVariants: this.levelVariants,
|
levelVariants: this.levelVariants,
|
||||||
volVariants: this.volVariants,
|
volVariants: this.volVariants,
|
||||||
flowThreshold: this.flowThreshold,
|
flowThreshold: this.flowThreshold,
|
||||||
|
unitPolicy: this.unitPolicy,
|
||||||
host: this,
|
host: this,
|
||||||
};
|
};
|
||||||
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
|
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
|
||||||
@@ -262,7 +269,7 @@ class PumpingStation extends BaseDomain {
|
|||||||
};
|
};
|
||||||
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
|
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
|
||||||
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
|
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
|
||||||
const 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 mode = this.mode || '?';
|
||||||
const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
|
const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
|
||||||
? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
|
? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
|
||||||
@@ -285,14 +292,32 @@ class PumpingStation extends BaseDomain {
|
|||||||
const measurementType = child.config.asset.type;
|
const measurementType = child.config.asset.type;
|
||||||
const eventName = `${measurementType}.measured.${position}`;
|
const eventName = `${measurementType}.measured.${position}`;
|
||||||
|
|
||||||
child.measurements.emitter.on(eventName, (eventData = {}) => {
|
const handle = (eventData = {}) => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
|
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
|
||||||
);
|
);
|
||||||
|
if (measurementType === 'level') {
|
||||||
|
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.measurements.type(measurementType).variant('measured').position(position)
|
this.measurements.type(measurementType).variant('measured').position(position)
|
||||||
.value(eventData.value, eventData.timestamp, eventData.unit);
|
.value(eventData.value, eventData.timestamp, eventData.unit);
|
||||||
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
|
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
child.measurements.emitter.on(eventName, handle);
|
||||||
|
|
||||||
|
// Seed from the child's current value. The emitter only delivers FUTURE
|
||||||
|
// updates, so a parent that registers after the child already emitted
|
||||||
|
// (e.g. a once-only inject that fired during startup before this
|
||||||
|
// subscription existed) would otherwise never see that value. Replaying
|
||||||
|
// the last sample makes a late subscriber pick up the present state.
|
||||||
|
const series = child.measurements
|
||||||
|
.type(measurementType).variant('measured').position(position).get?.();
|
||||||
|
const sample = series?.getLaggedSample?.(0);
|
||||||
|
if (sample && sample.value != null) {
|
||||||
|
handle({ ...sample, childName: child.config.general.name });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_subscribePredictedFlow(child) {
|
_subscribePredictedFlow(child) {
|
||||||
|
|||||||
85
test/basic/_probe_upstream_emit.test.js
Normal file
85
test/basic/_probe_upstream_emit.test.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Throwaway probe — exercises the exact path:
|
||||||
|
// measurement child writes flow.measured.upstream → pumpingStation parent
|
||||||
|
// subscribes → getOutput() (≡ what Port 0 emits).
|
||||||
|
// Run with: node --test test/basic/_probe_upstream_emit.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
const { MeasurementContainer, configManager } = require('generalFunctions');
|
||||||
|
const EventEmitter = require('node:events');
|
||||||
|
|
||||||
|
// Minimal PumpingStation config — matches the editor defaults shape.
|
||||||
|
function makePsConfig() {
|
||||||
|
const ui = {
|
||||||
|
name: 'PS', basinVolume: 50, basinHeight: 5,
|
||||||
|
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||||
|
minHeightBasedOn: 'outlet',
|
||||||
|
controlMode: 'levelbased',
|
||||||
|
minLevel: 1, startLevel: 2, maxLevel: 4,
|
||||||
|
levelCurveType: 'linear',
|
||||||
|
processOutputFormat: 'process', dbaseOutputFormat: 'influxdb',
|
||||||
|
};
|
||||||
|
const cm = new configManager();
|
||||||
|
// Use the same buildConfig pipeline the runtime uses.
|
||||||
|
return cm.buildConfig('pumpingStation', ui, 'ps-probe', {
|
||||||
|
basin: {
|
||||||
|
volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||||
|
},
|
||||||
|
hydraulics: { minHeightBasedOn: 'outlet' },
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
|
||||||
|
},
|
||||||
|
safety: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake measurement child that looks exactly like the real one to the router:
|
||||||
|
// - softwareType 'measurement'
|
||||||
|
// - config.asset.type = 'flow'
|
||||||
|
// - config.functionality.positionVsParent = 'upstream'
|
||||||
|
// - .measurements is a real MeasurementContainer with a real emitter
|
||||||
|
function makeMeasurementChild(id = 'meas-probe') {
|
||||||
|
const measurements = new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { flow: 'm3/s' },
|
||||||
|
});
|
||||||
|
// Real container ships an emitter; sanity check.
|
||||||
|
assert.ok(measurements.emitter instanceof EventEmitter || typeof measurements.emitter?.on === 'function');
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source: {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
|
||||||
|
asset: { type: 'flow' },
|
||||||
|
},
|
||||||
|
measurements,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('PROBE: measurement child writes flow.measured.upstream — parent surfaces it on getOutput()', () => {
|
||||||
|
const ps = new PumpingStation(makePsConfig());
|
||||||
|
const child = makeMeasurementChild();
|
||||||
|
|
||||||
|
// Register the child the same way the runtime does.
|
||||||
|
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
|
||||||
|
|
||||||
|
// Drive a value through the child's MeasurementContainer the way Channel
|
||||||
|
// does — type/variant/position chain then .value().
|
||||||
|
child.source.measurements
|
||||||
|
.type('flow').variant('measured').position('upstream')
|
||||||
|
.value(12, Date.now(), 'm3/h'); // 12 m³/h ≈ 0.00333 m³/s
|
||||||
|
|
||||||
|
const out = ps.getOutput();
|
||||||
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||||
|
console.log('flow.measured.upstream.* keys in Port 0 payload:', upstreamKeys);
|
||||||
|
for (const k of upstreamKeys) console.log(` ${k} = ${out[k]}`);
|
||||||
|
|
||||||
|
// The contract: the parent should surface the upstream measurement.
|
||||||
|
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* on Port 0');
|
||||||
|
});
|
||||||
@@ -24,9 +24,10 @@ function makeMeasurements(levelMeters) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeGroup(name) {
|
function makeGroup(name) {
|
||||||
const calls = { handleInput: [], turnOff: 0 };
|
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||||
return {
|
return {
|
||||||
config: { general: { name } },
|
config: { general: { name } },
|
||||||
|
setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); },
|
||||||
handleInput: async (...args) => { calls.handleInput.push(args); },
|
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||||
turnOffAllMachines: () => { calls.turnOff += 1; },
|
turnOffAllMachines: () => { calls.turnOff += 1; },
|
||||||
_calls: calls,
|
_calls: calls,
|
||||||
@@ -59,31 +60,38 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
|
|||||||
assert.equal(state.percControl, 0);
|
assert.equal(state.percControl, 0);
|
||||||
for (const g of Object.values(ctx.machineGroups)) {
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
|
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
|
||||||
assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone');
|
assert.equal(g._calls.setDemand.length, 0, 'no demand sent in stop zone');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// basin-docs behavior: between minLevel and the active ramp foot, demand
|
// Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge
|
||||||
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
|
// hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so
|
||||||
// only the explicit minLevel hard-stop path skips handleInput.
|
// MGC doesn't kick a pump on at flow.min before the gate is ever passed.
|
||||||
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
|
test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => {
|
||||||
const ctx = makeCtx(1.5);
|
const ctx = makeCtx(1.5);
|
||||||
const state = { percControl: 17 };
|
const state = { percControl: 17 };
|
||||||
await levelBased.run(ctx, state);
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone');
|
assert.equal(state.percControl, 0, 'percControl held at 0 before engagement');
|
||||||
for (const g of Object.values(ctx.machineGroups)) {
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
assert.equal(g._calls.turnOff, 0);
|
assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff');
|
||||||
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
|
assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement');
|
||||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
|
test('level == startLevel → percControl == 0 dispatched as setDemand (0 % = min flow, NOT off)', async () => {
|
||||||
const ctx = makeCtx(2);
|
const ctx = makeCtx(2);
|
||||||
const state = { percControl: null };
|
const state = { percControl: null };
|
||||||
await levelBased.run(ctx, state);
|
await levelBased.run(ctx, state);
|
||||||
assert.equal(state.percControl, 0);
|
assert.equal(state.percControl, 0);
|
||||||
|
// Critical: at startLevel pumps are engaged at min flow, NOT turned off.
|
||||||
|
// The bug we're fixing: the previous soft-turnOff at pct≤0 stopped pumps
|
||||||
|
// at this boundary even though the hysteresis was armed.
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0, 'do not turnOff at startLevel');
|
||||||
|
assert.equal(g._calls.setDemand.length, 1, 'forward 0 % to MGC');
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
|
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
|
||||||
@@ -101,19 +109,65 @@ test('level above maxLevel → percControl clamped at 100 (interpolation limit_i
|
|||||||
assert.equal(state.percControl, 100);
|
assert.equal(state.percControl, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
|
test('percControl forwarded to every group via setDemand(pct, "%")', async () => {
|
||||||
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
|
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
|
||||||
const state = { percControl: null };
|
const state = { percControl: null };
|
||||||
await levelBased.run(ctx, state);
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
assert.equal(state.percControl, 50);
|
assert.equal(state.percControl, 50);
|
||||||
for (const g of Object.values(ctx.machineGroups)) {
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
|
assert.equal(g._calls.setDemand.length, 1, 'one forward per group');
|
||||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
assert.deepEqual(g._calls.setDemand[0], [50, '%']);
|
||||||
|
assert.equal(g._calls.handleInput.length, 0, 'no raw handleInput — % goes through setDemand');
|
||||||
assert.equal(g._calls.turnOff, 0);
|
assert.equal(g._calls.turnOff, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('inflowLevel does NOT shape the curve — ramp foot = startLevel regardless', async () => {
|
||||||
|
// startLevel=2, inflowLevel=3, maxLevel=4. Level=2.5 sits between
|
||||||
|
// startLevel and inflowLevel. Pre-fix this was a 0 % "hold zone"; now
|
||||||
|
// the ramp is anchored at startLevel so level=2.5 → 25 %.
|
||||||
|
const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 } });
|
||||||
|
ctx.basin = { inflowLevel: 3 };
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.ok(Math.abs(state.percControl - 25) < 1e-9,
|
||||||
|
`expected ~25 % (ramp foot at startLevel, NOT inflowLevel); got ${state.percControl}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holdLevel > startLevel opts into a hold band [startLevel, holdLevel] at 0 %', async () => {
|
||||||
|
// Same geometry but operator raises holdLevel to 3 so the ramp's 0 %
|
||||||
|
// foot moves up. Level=2.5 should now sit in the hold band: pumps are
|
||||||
|
// engaged but emit 0 % (= MGC's flow.min, NOT turn-off).
|
||||||
|
const ctx = makeCtx(2.5, {
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4 },
|
||||||
|
});
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 0, '0 % in the configurable hold band');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0, 'engaged — must not turnOff in hold band');
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falling-edge keep-alive [stopLevel, startLevel] keeps pumps spinning', async () => {
|
||||||
|
// stopLevel = 0.5, startLevel = 2. Once armed (level ≥ startLevel), the
|
||||||
|
// band [0.5, 2) stays engaged at deadZoneKeepAlivePercent (default 1 %).
|
||||||
|
const ctx = makeCtx(1.5, {
|
||||||
|
levelbased: { minLevel: 0.1, startLevel: 2, stopLevel: 0.5, maxLevel: 4 },
|
||||||
|
});
|
||||||
|
// Pre-arm: simulate that level previously crossed startLevel.
|
||||||
|
ctx.host = { _stopHystRunning: true };
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 1, 'keep-alive emits 1 % in the [stop, start) band');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [1, '%']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
|
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
|
||||||
const ctx = makeCtx(NaN);
|
const ctx = makeCtx(NaN);
|
||||||
let warned = false;
|
let warned = false;
|
||||||
@@ -128,3 +182,51 @@ test('no valid level → warns and returns without mutating percControl or calli
|
|||||||
assert.equal(g._calls.handleInput.length, 0);
|
assert.equal(g._calls.handleInput.length, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: a station engaged above startLevel but with no machine group
|
||||||
|
// registered (e.g. the Port 2 parent↔group registration was dropped by a
|
||||||
|
// partial redeploy) computes a real demand that goes nowhere. The strategy
|
||||||
|
// must surface this once, not fail silently. See the 2026-05-27 "PS not
|
||||||
|
// reacting to level" trace.
|
||||||
|
test('engaged with NO machine group registered → warns once (throttled via host)', async () => {
|
||||||
|
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); // level 3 > startLevel 2 → engaged
|
||||||
|
ctx.machineGroups = {}; // registration lost
|
||||||
|
ctx.host = {};
|
||||||
|
const warns = [];
|
||||||
|
ctx.logger.warn = (m) => warns.push(m);
|
||||||
|
|
||||||
|
const state = { percControl: 0 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.ok(state.percControl > 0, 'demand is computed even though there is no group');
|
||||||
|
assert.equal(warns.length, 1, 'warns exactly once');
|
||||||
|
assert.match(warns[0], /no machine group is registered/i);
|
||||||
|
assert.equal(ctx.host._warnedNoMachineGroup, true);
|
||||||
|
|
||||||
|
// Subsequent ticks while still group-less stay quiet (no log spam).
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1, 'throttled: no repeat warning on the next tick');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warning re-arms after a group reappears then disappears again', async () => {
|
||||||
|
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } });
|
||||||
|
ctx.host = {};
|
||||||
|
const warns = [];
|
||||||
|
ctx.logger.warn = (m) => warns.push(m);
|
||||||
|
const state = { percControl: 0 };
|
||||||
|
|
||||||
|
ctx.machineGroups = {};
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1);
|
||||||
|
|
||||||
|
// Group registers again → flag clears, no new warning.
|
||||||
|
ctx.machineGroups = { a: makeGroup('A') };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1);
|
||||||
|
assert.equal(ctx.host._warnedNoMachineGroup, false);
|
||||||
|
|
||||||
|
// Group lost again → warns once more.
|
||||||
|
ctx.machineGroups = {};
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 2, 're-armed after recovery');
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { UnitPolicy } = require('generalFunctions');
|
||||||
const manual = require('../../src/control/manual');
|
const manual = require('../../src/control/manual');
|
||||||
|
|
||||||
|
const unitPolicy = UnitPolicy.declare({
|
||||||
|
canonical: { flow: 'm3/s' },
|
||||||
|
output: { flow: 'm3/s' },
|
||||||
|
requireUnitForTypes: [],
|
||||||
|
});
|
||||||
|
|
||||||
function makeGroup(name) {
|
function makeGroup(name) {
|
||||||
const calls = { handleInput: [] };
|
const calls = { handleInput: [] };
|
||||||
return {
|
return {
|
||||||
@@ -28,15 +35,15 @@ function makeLogger() {
|
|||||||
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('forwardDemand calls handleInput("parent", demand) on every machine group', async () => {
|
test('forwardDemand calls handleInput("parent", canonical m3/s demand) on every machine group', async () => {
|
||||||
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
|
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
|
||||||
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
|
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
|
||||||
|
|
||||||
await manual.forwardDemand(ctx, 50);
|
await manual.forwardDemand(ctx, 360);
|
||||||
|
|
||||||
for (const g of Object.values(groups)) {
|
for (const g of Object.values(groups)) {
|
||||||
assert.equal(g._calls.handleInput.length, 1);
|
assert.equal(g._calls.handleInput.length, 1);
|
||||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
assert.deepEqual(g._calls.handleInput[0], ['parent', 0.1]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ test('forwardDemand with no machineGroups but direct machines splits demand even
|
|||||||
|
|
||||||
test('run() is a no-op (manual mode is event-driven)', async () => {
|
test('run() is a no-op (manual mode is event-driven)', async () => {
|
||||||
const groups = { a: makeGroup('A') };
|
const groups = { a: makeGroup('A') };
|
||||||
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
|
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
|
||||||
await manual.run(ctx, { percControl: 0 });
|
await manual.run(ctx, { percControl: 0 });
|
||||||
assert.equal(groups.a._calls.handleInput.length, 0);
|
assert.equal(groups.a._calls.handleInput.length, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,48 @@ test('FlowAggregator.update integrates inflow-outflow over delta-t', async () =>
|
|||||||
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
|
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.update integrates measured inflow when predicted side is empty', async () => {
|
||||||
|
// Regression: a real upstream sensor writes `flow.measured.upstream.<id>`
|
||||||
|
// (the measurement node hard-codes variant='measured'), but the integrator
|
||||||
|
// used to read variant='predicted' only — so level stayed flat while the
|
||||||
|
// status row reported +N m³/h. The fix mirrors selectBestNetFlow's
|
||||||
|
// variant precedence per side.
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
const t0 = Date.now() - 10_000;
|
||||||
|
// Measured inflow at 'upstream' (one of the inflow position aliases),
|
||||||
|
// no outflow side at all.
|
||||||
|
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
|
||||||
|
.value(0.01, t0, 'm3/s');
|
||||||
|
|
||||||
|
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
fa.update();
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
// Expect minVol(2) + 0.01 × ~10 ≈ 2.10 m3.
|
||||||
|
assert.ok(vol > 2.09 && vol < 2.11, `measured inflow did not integrate: vol=${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.update mixes measured inflow with predicted outflow', async () => {
|
||||||
|
// Realistic mix: real upstream sensor (measured) + pump-curve outflow
|
||||||
|
// (predicted). The picker resolves each side independently, so the net
|
||||||
|
// balance uses both.
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
const t0 = Date.now() - 10_000;
|
||||||
|
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
|
||||||
|
.value(0.01, t0, 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('downstream').child('pump-A')
|
||||||
|
.value(0.004, t0, 'm3/s');
|
||||||
|
|
||||||
|
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
fa.update();
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
// minVol(2) + (0.01 - 0.004) × ~10 ≈ 2.06 m3.
|
||||||
|
assert.ok(vol > 2.05 && vol < 2.07, `mixed-variant integration produced vol=${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
|
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
|
||||||
const { fa, measurements } = makeAggregator();
|
const { fa, measurements } = makeAggregator();
|
||||||
measurements.type('flow').variant('measured').position('in').child('m')
|
measurements.type('flow').variant('measured').position('in').child('m')
|
||||||
|
|||||||
81
test/basic/replay-on-subscribe.basic.test.js
Normal file
81
test/basic/replay-on-subscribe.basic.test.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Late-subscriber replay: a measurement child that already holds a value when
|
||||||
|
// the pumpingStation registers it (e.g. a once-only inject that fired during
|
||||||
|
// startup before the parent subscribed) must still surface on Port 0. The
|
||||||
|
// emitter only delivers future updates, so _subscribeMeasurement seeds from the
|
||||||
|
// child's current sample.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const EventEmitter = require('node:events');
|
||||||
|
|
||||||
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
const { MeasurementContainer, configManager } = require('generalFunctions');
|
||||||
|
|
||||||
|
function makePsConfig() {
|
||||||
|
const cm = new configManager();
|
||||||
|
return cm.buildConfig('pumpingStation', { name: 'PS' }, 'ps-replay', {
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||||
|
hydraulics: { minHeightBasedOn: 'outlet' },
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
|
||||||
|
},
|
||||||
|
safety: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFlowMeasurementChild(id = 'meas-replay') {
|
||||||
|
const measurements = new MeasurementContainer({ autoConvert: true, preferredUnits: { flow: 'm3/s' } });
|
||||||
|
assert.ok(typeof measurements.emitter?.on === 'function');
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source: {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
|
||||||
|
asset: { type: 'flow' },
|
||||||
|
},
|
||||||
|
measurements,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('value written BEFORE registration is replayed on subscribe (once-inject timing)', () => {
|
||||||
|
const ps = new PumpingStation(makePsConfig());
|
||||||
|
const child = makeFlowMeasurementChild();
|
||||||
|
|
||||||
|
// Child already holds a value — emitted into the void before the parent existed.
|
||||||
|
child.source.measurements
|
||||||
|
.type('flow').variant('measured').position('upstream')
|
||||||
|
.value(50, Date.now(), 'm3/h');
|
||||||
|
|
||||||
|
// Parent registers AFTER the value is present. Without replay it would only
|
||||||
|
// catch future emits and surface nothing.
|
||||||
|
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
|
||||||
|
|
||||||
|
const out = ps.getOutput();
|
||||||
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||||
|
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* after late subscribe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no stored value → nothing replayed, no crash', () => {
|
||||||
|
const ps = new PumpingStation(makePsConfig());
|
||||||
|
const child = makeFlowMeasurementChild('empty-child');
|
||||||
|
// Register with an empty child container; replay must be a safe no-op.
|
||||||
|
assert.doesNotThrow(() => ps.childRegistrationUtils.registerChild(child.source, 'upstream'));
|
||||||
|
const out = ps.getOutput();
|
||||||
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||||
|
assert.equal(upstreamKeys.length, 0, 'no upstream key when child has no value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('future emits still delivered after subscribe (listener intact)', () => {
|
||||||
|
const ps = new PumpingStation(makePsConfig());
|
||||||
|
const child = makeFlowMeasurementChild('streaming-child');
|
||||||
|
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
|
||||||
|
// Emit AFTER registration — the normal streaming-sensor path.
|
||||||
|
child.source.measurements.type('flow').variant('measured').position('upstream').value(30, Date.now(), 'm3/h');
|
||||||
|
const out = ps.getOutput();
|
||||||
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
||||||
|
assert.ok(upstreamKeys.length > 0, 'normal post-subscribe emit still surfaces');
|
||||||
|
});
|
||||||
@@ -4,13 +4,14 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
const PumpingStation = require('../../src/specificClass');
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
|
||||||
// machineGroups is a registry-backed getter (declareChildGetter) — direct
|
// machineGroups is a registry-backed getter (declareChildGetter) — direct
|
||||||
// assignment is no longer possible. Tests inject mock groups through the
|
// assignment is no longer possible. Tests inject mock groups through the
|
||||||
// real registration handshake so the registry remains the source of truth.
|
// real registration handshake so the registry remains the source of truth.
|
||||||
function registerMockGroup(ps, id, behavior = {}) {
|
function registerMockGroup(ps, id, behavior = {}) {
|
||||||
const calls = { handleInput: [], turnOff: 0 };
|
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||||
const mock = {
|
const mock = {
|
||||||
config: {
|
config: {
|
||||||
general: { id, name: id },
|
general: { id, name: id },
|
||||||
@@ -21,6 +22,8 @@ function registerMockGroup(ps, id, behavior = {}) {
|
|||||||
emitter: { on: () => {} },
|
emitter: { on: () => {} },
|
||||||
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
||||||
},
|
},
|
||||||
|
setDemand: behavior.setDemand
|
||||||
|
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
|
||||||
handleInput: behavior.handleInput
|
handleInput: behavior.handleInput
|
||||||
|| (async (...args) => { calls.handleInput.push(args); }),
|
|| (async (...args) => { calls.handleInput.push(args); }),
|
||||||
turnOffAllMachines: behavior.turnOffAllMachines
|
turnOffAllMachines: behavior.turnOffAllMachines
|
||||||
@@ -82,6 +85,39 @@ function makeConfig(overrides = {}) {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeMeasurementChild({ type = 'level', position = 'atequipment', name = 'child-level' } = {}) {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id: name, name },
|
||||||
|
functionality: { positionVsParent: position },
|
||||||
|
asset: { type },
|
||||||
|
},
|
||||||
|
measurements: new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { level: 'm', flow: 'm3/s', pressure: 'Pa' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('level child subscription records one sample per event for level-rate fallback', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
const child = makeMeasurementChild();
|
||||||
|
|
||||||
|
ps._subscribeMeasurement(child);
|
||||||
|
child.measurements.type('level').variant('measured').position('atequipment')
|
||||||
|
.value(1.0, 1000, 'm');
|
||||||
|
child.measurements.type('level').variant('measured').position('atequipment')
|
||||||
|
.value(1.1, 3000, 'm');
|
||||||
|
|
||||||
|
const series = ps.measurements.type('level').variant('measured').position('atequipment').get();
|
||||||
|
assert.deepEqual(series.values, [1.0, 1.1]);
|
||||||
|
|
||||||
|
const net = ps.flowAggregator.selectBestNetFlow();
|
||||||
|
assert.equal(net.source, 'level:measured');
|
||||||
|
assert.equal(net.direction, 'filling');
|
||||||
|
assert.ok(Math.abs(net.value - 0.5) < 1e-9, `net flow was ${net.value}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('Basin geometry — derived values', async (t) => {
|
test('Basin geometry — derived values', async (t) => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
@@ -163,7 +199,10 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
|||||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
|
await t.test('startLevel > inflowLevel is allowed (sewer-buffer mode), no issue raised', () => {
|
||||||
|
// Inflow gravity point at 3, startLevel pushed to 3.5 → basin is allowed
|
||||||
|
// to fill past the inlet before pumps engage. levelBased shifts the ramp
|
||||||
|
// foot to startLevel; the validator no longer flags the ordering.
|
||||||
const ps = new PumpingStation(makeConfig({
|
const ps = new PumpingStation(makeConfig({
|
||||||
control: {
|
control: {
|
||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
@@ -171,7 +210,8 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
|||||||
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
|
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'));
|
assert.ok(!ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'),
|
||||||
|
'startLevel vs inflowLevel ordering must not raise an issue');
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
||||||
@@ -261,51 +301,77 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
assert.equal(mock._calls.turnOff, 1);
|
assert.equal(mock._calls.turnOff, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
|
await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
ps.percControl = 42; // simulated previous demand
|
ps.percControl = 42; // simulated previous demand
|
||||||
const mock = registerMockGroup(ps, 'mgc1');
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
assert.equal(ps.percControl, 0);
|
assert.equal(ps.percControl, 0);
|
||||||
assert.equal(mock._calls.handleInput[0][1], 0);
|
// pct=0 → turnOff, no setDemand call (avoids MGC interpolating 0 % to dt.flow.min).
|
||||||
|
assert.equal(mock._calls.turnOff, 1);
|
||||||
|
assert.equal(mock._calls.setDemand.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
|
await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
const mock = registerMockGroup(ps, 'mgc1');
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
|
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3, maxLevel=4
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
// Ramp foot = startLevel (NOT inflowLevel). lerp(2.5, [2, 4], [0, 100]) = 25.
|
||||||
|
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected ~25 %, got ${ps.percControl}`);
|
||||||
|
assert.equal(mock._calls.turnOff, 0, 'engaged — pumps must not be turned off in the ramp');
|
||||||
|
assert.equal(mock._calls.setDemand.length, 1);
|
||||||
|
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 25) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('filling: level ≥ maxLevel → percControl clamped at 100, routed via setDemand', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
|
ps.calibratePredictedLevel(3.5); // 3/4 of the [2,4] ramp → 75 %.
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
assert.ok(Math.abs(ps.percControl - 75) < 1e-9, `expected ~75 %, got ${ps.percControl}`);
|
||||||
|
assert.equal(mock._calls.setDemand.length, 1);
|
||||||
|
assert.equal(mock._calls.setDemand[0][1], '%');
|
||||||
|
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 75) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('filling: holdLevel raises the ramp foot — explicit hold band [startLevel, holdLevel] sits at 0 %', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
|
ps.calibratePredictedLevel(2.5); // inside [startLevel, holdLevel]
|
||||||
await ps._controlLevelBased('filling');
|
await ps._controlLevelBased('filling');
|
||||||
assert.equal(ps.percControl, 0);
|
assert.equal(ps.percControl, 0);
|
||||||
assert.equal(mock._calls.handleInput[0][1], 0);
|
assert.equal(mock._calls.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off');
|
||||||
|
assert.deepEqual(mock._calls.setDemand[0], [0, '%']);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
|
await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const mock = registerMockGroup(ps, 'mgc1');
|
|
||||||
ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4
|
|
||||||
await ps._controlLevelBased('filling');
|
|
||||||
// lerp(3.5, [3,4], [0,100]) = 50
|
|
||||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
|
||||||
assert.equal(mock._calls.handleInput.length, 1);
|
|
||||||
assert.ok(Math.abs(mock._calls.handleInput[0][1] - 50) < 1e-9);
|
|
||||||
});
|
|
||||||
|
|
||||||
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
registerMockGroup(ps, 'mgc1');
|
registerMockGroup(ps, 'mgc1');
|
||||||
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
|
// Climb above startLevel, then fall to a level inside [start, inflow]. With
|
||||||
|
// the new semantics (ramp foot = startLevel, NOT inflowLevel) the falling
|
||||||
|
// level still produces a positive demand on the way down.
|
||||||
ps.calibratePredictedLevel(3.8);
|
ps.calibratePredictedLevel(3.8);
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
assert.ok(ps.percControl > 0);
|
assert.ok(ps.percControl > 0);
|
||||||
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
|
ps.calibratePredictedLevel(2.5); // startLevel=2, maxLevel=4 → 25 %
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
// Without shift the foot is inflowLevel → 0% in the hold zone.
|
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
|
||||||
assert.equal(ps.percControl, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
|
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
|
||||||
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
|
// The original shifted-ramp test was authored against the legacy ramp
|
||||||
|
// foot = inflowLevel (=3). With the new defaults the foot moves to
|
||||||
|
// startLevel (=2), which changes every percentage in the trace. Pin
|
||||||
|
// the foot back to 3 by setting holdLevel = 3 — that keeps this test's
|
||||||
|
// arithmetic self-consistent: up curve goes 0 %@3 to 100 %@4.
|
||||||
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
|
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
|
||||||
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
|
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
|
||||||
const ps = new PumpingStation(makeConfig({
|
const ps = new PumpingStation(makeConfig({
|
||||||
@@ -313,7 +379,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased']),
|
allowedModes: new Set(['levelbased']),
|
||||||
levelbased: {
|
levelbased: {
|
||||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -355,7 +421,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased']),
|
allowedModes: new Set(['levelbased']),
|
||||||
levelbased: {
|
levelbased: {
|
||||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
// Pin the ramp foot at 3 via holdLevel — keeps legacy arithmetic
|
||||||
|
// self-consistent with the original test (up curve 0 %@3 → 100 %@4).
|
||||||
|
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -381,7 +449,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
control: {
|
control: {
|
||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased']),
|
allowedModes: new Set(['levelbased']),
|
||||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
// holdLevel=3 keeps ramp foot at 3 so x=0.5 means level=3.5, matching
|
||||||
|
// the legacy assertion bracket.
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
registerMockGroup(ps, 'mgc1');
|
registerMockGroup(ps, 'mgc1');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
function loadDashboardFlow() {
|
function loadDashboardFlow() {
|
||||||
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
|
const flowPath = path.join(__dirname, '../../examples/02-Dashboard.json');
|
||||||
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,27 +22,29 @@ function makeContextStub() {
|
|||||||
|
|
||||||
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
|
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
|
||||||
const flow = loadDashboardFlow();
|
const flow = loadDashboardFlow();
|
||||||
const ps = flow.find((n) => n.id === 'ps_node_basic');
|
const ps = flow.find((n) => n.type === 'pumpingStation');
|
||||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
const parser = flow.find((n) => n.id === 'fn_status_split');
|
||||||
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
|
const levelChart = flow.find((n) => n.id === 'ui_chart_level');
|
||||||
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
|
const volumeChart = flow.find((n) => n.id === 'ui_chart_volume');
|
||||||
|
const flowChart = flow.find((n) => n.id === 'ui_chart_flow');
|
||||||
|
|
||||||
assert.ok(ps, 'ps_node_basic should exist');
|
assert.ok(ps, 'pumpingStation node should exist');
|
||||||
assert.equal(ps.type, 'pumpingStation');
|
assert.equal(ps.type, 'pumpingStation');
|
||||||
assert.equal(ps.controlMode, 'levelbased');
|
assert.equal(ps.controlMode, 'levelbased');
|
||||||
assert.equal(ps.levelCurveType, 'linear');
|
assert.equal(ps.levelCurveType, 'linear');
|
||||||
assert.equal(ps.inletPipeDiameter, 0.4);
|
assert.equal(ps.inletPipeDiameter, 0.3);
|
||||||
assert.equal(ps.outletPipeDiameter, 0.3);
|
assert.equal(ps.outletPipeDiameter, 0.3);
|
||||||
assert.ok(parser, 'ps_parse_output should exist');
|
assert.ok(parser, 'fn_status_split should exist');
|
||||||
assert.equal(parser.outputs, 6);
|
assert.equal(parser.outputs, 14);
|
||||||
assert.equal(levelChart.type, 'ui-chart');
|
assert.equal(levelChart.type, 'ui-chart');
|
||||||
assert.equal(demandChart.type, 'ui-chart');
|
assert.equal(volumeChart.type, 'ui-chart');
|
||||||
|
assert.equal(flowChart.type, 'ui-chart');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('basic dashboard parser routes process fields to charts and state text', () => {
|
test('basic dashboard parser routes process fields to charts and state text', () => {
|
||||||
const flow = loadDashboardFlow();
|
const flow = loadDashboardFlow();
|
||||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
const parser = flow.find((n) => n.id === 'fn_status_split');
|
||||||
assert.ok(parser, 'ps_parse_output should exist');
|
assert.ok(parser, 'fn_status_split should exist');
|
||||||
|
|
||||||
const func = new Function('msg', 'context', 'node', parser.func);
|
const func = new Function('msg', 'context', 'node', parser.func);
|
||||||
const context = makeContextStub();
|
const context = makeContextStub();
|
||||||
@@ -56,8 +58,12 @@ test('basic dashboard parser routes process fields to charts and state text', ()
|
|||||||
payload: {
|
payload: {
|
||||||
'level.predicted.atequipment.default': 3.25,
|
'level.predicted.atequipment.default': 3.25,
|
||||||
'volume.predicted.atequipment.default': 32.5,
|
'volume.predicted.atequipment.default': 32.5,
|
||||||
|
'volumePercent.predicted.atequipment.default': 65,
|
||||||
|
'flow.predicted.in.default': 0.005,
|
||||||
|
'flow.predicted.out.default': 0.002,
|
||||||
'netFlowRate.predicted.atequipment.default': 0.003,
|
'netFlowRate.predicted.atequipment.default': 0.003,
|
||||||
percControl: 25,
|
percControl: 25,
|
||||||
|
mode: 'levelbased',
|
||||||
direction: 'filling',
|
direction: 'filling',
|
||||||
safetyState: 'normal',
|
safetyState: 'normal',
|
||||||
isOverflowing: false,
|
isOverflowing: false,
|
||||||
@@ -66,22 +72,25 @@ test('basic dashboard parser routes process fields to charts and state text', ()
|
|||||||
}, context, node);
|
}, context, node);
|
||||||
|
|
||||||
assert.ok(Array.isArray(out));
|
assert.ok(Array.isArray(out));
|
||||||
assert.equal(out.length, 6);
|
assert.equal(out.length, 14);
|
||||||
assert.equal(out[0].topic, 'level');
|
assert.equal(out[0].payload, 'levelbased');
|
||||||
assert.equal(out[0].payload, 3.25);
|
assert.equal(out[1].payload, 'filling');
|
||||||
assert.equal(out[1].topic, 'volume');
|
assert.equal(out[2].payload, '3.25 m');
|
||||||
assert.equal(out[1].payload, 32.5);
|
assert.equal(out[3].payload, '32.50 m³');
|
||||||
assert.equal(out[2].topic, 'demand');
|
assert.equal(out[4].payload, '65.00 %');
|
||||||
assert.equal(out[2].payload, 25);
|
assert.equal(out[5].payload, '25.0 %');
|
||||||
assert.equal(out[3].topic, 'net_flow');
|
assert.deepEqual(out[7], { topic: 'Level', payload: 3.25 });
|
||||||
assert.equal(out[3].payload, 0.003);
|
assert.deepEqual(out[8], { topic: 'Volume', payload: 32.5 });
|
||||||
assert.match(out[4].payload, /normal/);
|
assert.deepEqual(out[9], { topic: 'Volume %', payload: 65 });
|
||||||
assert.match(out[5].payload, /level=3.25 m/);
|
assert.deepEqual(out[10], { topic: 'Inflow', payload: 18 });
|
||||||
|
assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 });
|
||||||
|
assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 });
|
||||||
|
assert.ok(Array.isArray(out[13].payload));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
||||||
const flow = loadDashboardFlow();
|
const flow = loadDashboardFlow();
|
||||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
const parser = flow.find((n) => n.id === 'fn_status_split');
|
||||||
const func = new Function('msg', 'context', 'node', parser.func);
|
const func = new Function('msg', 'context', 'node', parser.func);
|
||||||
const context = makeContextStub();
|
const context = makeContextStub();
|
||||||
const node = { send() {} };
|
const node = { send() {} };
|
||||||
@@ -89,6 +98,6 @@ test('basic dashboard parser keeps previous values when process output sends onl
|
|||||||
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
|
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
|
||||||
const out = func({ payload: { percControl: 20 } }, context, node);
|
const out = func({ payload: { percControl: 20 } }, context, node);
|
||||||
|
|
||||||
assert.equal(out[0].payload, 3.1);
|
assert.equal(out[2].payload, '3.10 m');
|
||||||
assert.equal(out[2].payload, 20);
|
assert.equal(out[5].payload, '20.0 %');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ function makeConfig() {
|
|||||||
mode: 'levelbased',
|
mode: 'levelbased',
|
||||||
allowedModes: new Set(['levelbased', 'manual']),
|
allowedModes: new Set(['levelbased', 'manual']),
|
||||||
levelbased: {
|
levelbased: {
|
||||||
minLevel: 1, startLevel: 2, maxLevel: 4,
|
// holdLevel pins the ramp foot at 3 to preserve the original geometry
|
||||||
|
// (up curve 0 %@3 → 100 %@4). New default would put the foot at
|
||||||
|
// startLevel=2; this test specifically exercises shifted-ramp arming
|
||||||
|
// behaviour, not the ramp-foot semantic itself.
|
||||||
|
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4,
|
||||||
curveType: 'linear', logCurveFactor: 9,
|
curveType: 'linear', logCurveFactor: 9,
|
||||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ What to click in the dashboard after deploy:
|
|||||||
3. `cmd.calibrate.level = 1.5 m` → the volume integrator syncs to a known level.
|
3. `cmd.calibrate.level = 1.5 m` → the volume integrator syncs to a known level.
|
||||||
4. Watch Port 0 in the debug pane: level rises, predicted volume integrates, demand follows the curve.
|
4. Watch Port 0 in the debug pane: level rises, predicted volume integrates, demand follows the curve.
|
||||||
|
|
||||||

|
> [!IMPORTANT]
|
||||||
|
> **GIF needed.** Demo recording of the basic flow reacting to mode + inflow clicks. Save as `wiki/_partial-gifs/pumpingStation/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ The **Unit** column reflects each descriptor's `units: { measure, default }` dec
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
|
| `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. |
|
| `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.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. |
|
| `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.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.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. |
|
| `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
|
||||||
|
|
||||||
<!-- END AUTOGEN: topic-contract -->
|
<!-- END AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ Driver injects are wrapped in four numbered groups: **1. Control mode**, **2. Fl
|
|||||||
3. In manual mode: click `set.demand = 40` — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.
|
3. In manual mode: click `set.demand = 40` — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.
|
||||||
4. Click `cmd.calibrate.volume = 25 m³` (or `cmd.calibrate.level = 1.5 m`) to snap the predicted-volume integrator.
|
4. Click `cmd.calibrate.volume = 25 m³` (or `cmd.calibrate.level = 1.5 m`) to snap the predicted-volume integrator.
|
||||||
|
|
||||||

|
> [!IMPORTANT]
|
||||||
|
> **GIF needed.** Demo recording of steps 1–4. Save as `wiki/_partial-gifs/pumpingStation/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 50 MiB |
Reference in New Issue
Block a user