Compare commits
12 Commits
df18e97b8b
...
fc6491dc23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6491dc23 | ||
|
|
2fb083da63 | ||
|
|
4889fdaaf0 | ||
|
|
a83a85e958 | ||
|
|
e041877ae4 | ||
|
|
8216480950 | ||
|
|
dfaa0c3ae8 | ||
|
|
6e727d929b | ||
| ef07f2a5b2 | |||
|
|
2d68a4f504 | ||
|
|
a3536b7b7f | ||
|
|
f5c6282478 |
@@ -25,8 +25,9 @@ Aliases log a one-time deprecation warning the first time they fire.
|
|||||||
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
|
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
|
||||||
`'influxdb'` formatter.
|
`'influxdb'` formatter.
|
||||||
- **Port 2 (registration):** at startup the node sends one
|
- **Port 2 (registration):** at startup the node sends one
|
||||||
`{ topic: 'registerChild', payload: <node.id>, positionVsParent, distance }`
|
`{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
|
||||||
to the upstream parent.
|
to the upstream parent (`child.register` is canonical; `registerChild` is the
|
||||||
|
deprecated *input* alias, not what this node emits).
|
||||||
|
|
||||||
## Events emitted by `source.measurements.emitter`
|
## Events emitted by `source.measurements.emitter`
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
[]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -253,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 = {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -68,11 +68,14 @@
|
|||||||
ns.setNumberField('node-input-stopLevel', node.stopLevel);
|
ns.setNumberField('node-input-stopLevel', node.stopLevel);
|
||||||
// holdLevel defaults to startLevel when omitted (no hold band). Show
|
// holdLevel defaults to startLevel when omitted (no hold band). Show
|
||||||
// the saved value if there is one; otherwise mirror startLevel so the
|
// the saved value if there is one; otherwise mirror startLevel so the
|
||||||
// user immediately sees the "no hold band" baseline.
|
// 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',
|
ns.setNumberField('node-input-holdLevel',
|
||||||
Number.isFinite(node.holdLevel) ? node.holdLevel : node.startLevel);
|
Number.isFinite(holdNum) ? holdNum : node.startLevel);
|
||||||
|
const deadZoneNum = parseFloat(node.deadZoneKeepAlivePercent);
|
||||||
ns.setNumberField('node-input-deadZoneKeepAlivePercent',
|
ns.setNumberField('node-input-deadZoneKeepAlivePercent',
|
||||||
Number.isFinite(node.deadZoneKeepAlivePercent) ? node.deadZoneKeepAlivePercent : 1);
|
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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ 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).
|
||||||
@@ -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) {
|
||||||
|
|||||||
101
test/_output-manifest.md
Normal file
101
test/_output-manifest.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# pumpingStation output manifest
|
||||||
|
|
||||||
|
> Single source of truth for **what this node emits and where it is tested**, per
|
||||||
|
> [`.claude/rules/output-coverage.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/output-coverage.md).
|
||||||
|
> Generated against code-ref `a83a85e`. Regenerate the wiki contract with
|
||||||
|
> `npm run wiki:all` and re-check this table whenever `getOutput()`,
|
||||||
|
> `src/commands/index.js`, or an `examples/*.json` fan-out changes.
|
||||||
|
|
||||||
|
**Null convention for this node:** a Port-0 key whose source is not yet
|
||||||
|
available is emitted as **explicit `null`** (e.g. `timeleft`, `flowSource`,
|
||||||
|
`manualDemand` outside manual mode), never silently absent. Delta-compression on
|
||||||
|
Port 0 then drops keys whose value is unchanged since the previous tick.
|
||||||
|
|
||||||
|
## Port 0 (process data) — `specificClass.getOutput()` → `outputUtils.formatMsg(..., 'process')`
|
||||||
|
|
||||||
|
`msg.topic = config.general.name`. Keys below are the full pre-delta-compression set.
|
||||||
|
|
||||||
|
| Key | Source | Type | States tested | Test file |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `mode` | `getOutput` ← `this.mode` | string (`levelbased`/`manual`/`flowbased`/`none`) | populated (`manual`) | test/basic/specificClass.test.js |
|
||||||
|
| `manualDemand` | `getOutput` ← `_manualDemand` | number m³/h, `null` outside manual | populated, null | test/basic/specificClass.test.js |
|
||||||
|
| `direction` | `getOutput` ← `state.direction` | string (`filling`/`draining`/`steady`) | present | test/basic/specificClass.test.js |
|
||||||
|
| `flowSource` | `getOutput` ← `state.flowSource` | string, `null` when no source | null (pre-child) | test/basic/specificClass.test.js |
|
||||||
|
| `timeleft` | `getOutput` ← `state.seconds` | number s, `null` when steady | present, null | test/basic/specificClass.test.js |
|
||||||
|
| `percControl` | `getOutput` ← `controlState.percControl` | number % 0..100 | 0, 25, 50, 75, 85, 100 | test/basic/specificClass.test.js |
|
||||||
|
| `dryRunLevel` | `_computeSafetyPoints` | number m | populated | test/basic/specificClass.test.js |
|
||||||
|
| `dryRunSafetyVol` | `_computeSafetyPoints` | number m³ | populated | test/basic/specificClass.test.js |
|
||||||
|
| `highVolumeSafetyLevel` | `_computeSafetyPoints` | number m | populated | test/basic/specificClass.test.js |
|
||||||
|
| `highVolumeSafetyVol` | `_computeSafetyPoints` | number m³ | populated | test/basic/specificClass.test.js |
|
||||||
|
| `predictedOverflowVolume` | `measurements` overflowVolume | number m³ | populated, 0 | test/basic/specificClass.test.js |
|
||||||
|
| `predictedOverflowRate` | `measurements` flow.overflow | number m³/s | populated, 0 | test/basic/specificClass.test.js |
|
||||||
|
| `predictedUnderflowVolume` | `measurements` underflowVolume | number m³ | 0 | test/basic/specificClass.test.js |
|
||||||
|
| `volume.predicted.atequipment.<childId>` | `measurements.getFlattenedOutput` | number m³ | populated | test/basic/specificClass.test.js |
|
||||||
|
| basin geometry: `heightBasin`, `surfaceArea`, `maxVol`, `minVol`, `maxVolAtOverflow`, `minVolAtInflow`, `minVolAtOutflow`, `volEmptyBasin`, `inflowLevel`, `outflowLevel`, `overflowLevel`, `inletPipeDiameter`, `outletPipeDiameter`, `minHeightBasedOn` | `basin.snapshot()` | number (m/m²/m³) / string | populated | test/basic/specificClass.test.js, test/basic/BasinGeometry.basic.test.js |
|
||||||
|
|
||||||
|
## Port 1 (InfluxDB telemetry) — `formatMsg(..., 'influxdb')`
|
||||||
|
|
||||||
|
Same key set as Port 0 (formatted via the `influxdb` formatter rather than
|
||||||
|
`process`). Field names == Port-0 keys; `config.general.name` is the measurement
|
||||||
|
tag. No Port-1-only fields. Covered transitively by the Port-0 tests above; a
|
||||||
|
dedicated Port-1 line-protocol assertion is a **gap** (see below).
|
||||||
|
|
||||||
|
## Port 2 (registration / control plumbing) — `BaseNodeAdapter._scheduleRegistration`
|
||||||
|
|
||||||
|
| Topic | Source | Payload shape | States tested | Test file |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `child.register` | `BaseNodeAdapter.js:122` | `{ topic:'child.register', payload:<node.id>, positionVsParent, distance }` | — | _(gap — see below)_ |
|
||||||
|
|
||||||
|
> Note: the canonical outgoing topic is **`child.register`** (matching the input
|
||||||
|
> registry). Earlier docs said `registerChild`; that is the deprecated input
|
||||||
|
> alias, not what this node emits.
|
||||||
|
|
||||||
|
## Child-facing events — `measurements.emitter`
|
||||||
|
|
||||||
|
Fired as `<type>.<variant>.<position>` when a series receives a value. Parents
|
||||||
|
subscribe by event name (data-driven, not a fixed catalogue):
|
||||||
|
|
||||||
|
| Event | When | Test file |
|
||||||
|
|---|---|---|
|
||||||
|
| `volume.predicted.atequipment` | each integrator tick | test/basic/flowAggregator.basic.test.js |
|
||||||
|
| `level.predicted.atequipment` | recomputed from volume | test/basic/specificClass.test.js |
|
||||||
|
| `flow.predicted.in` (child `manual-qin`) | `set.inflow` handler | test/basic/measurementRouter.basic.test.js |
|
||||||
|
| `overflowVolume`/`underflowVolume`/`flow.predicted.overflow` | integrator hits a physical bound | test/basic/flowAggregator.basic.test.js |
|
||||||
|
|
||||||
|
## Example-flow function-node fan-out
|
||||||
|
|
||||||
|
### examples/02-Dashboard.json :: `fn_status_split` (outputs: 15)
|
||||||
|
|
||||||
|
| # | Target widget | Payload | Populated | Degraded/null |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 0 | ui-text "Mode" | string | ✔ structure | gap |
|
||||||
|
| 1 | ui-text "Direction" | string | ✔ | gap |
|
||||||
|
| 2 | ui-text "Level" | number m | ✔ | gap |
|
||||||
|
| 3 | ui-text "Volume" | number m³ | ✔ | gap |
|
||||||
|
| 4 | ui-text "Volume %" | number % | ✔ | gap |
|
||||||
|
| 5 | ui-text "percControl" | number % | ✔ | gap |
|
||||||
|
| 6 | ui-text "Manual demand" | number m³/h or — | gap | gap |
|
||||||
|
| 7 | ui-chart "Level (m)" | `{topic,payload:number}` or no-msg | ✔ | gap |
|
||||||
|
| 8 | ui-chart "Volume (m³)" | ″ | ✔ | gap |
|
||||||
|
| 9 | ui-chart "Volume %" | ″ | ✔ | gap |
|
||||||
|
| 10 | ui-chart "Flow (m³/h)" — Inflow | ″ | ✔ | gap |
|
||||||
|
| 11 | ui-chart "Flow (m³/h)" — Outflow | ″ | ✔ | gap |
|
||||||
|
| 12 | ui-chart "Flow (m³/h)" — Net | ″ | ✔ | gap |
|
||||||
|
| 13 | ui-template "Raw output table" | whole object (array) | ✔ | gap |
|
||||||
|
| 14 | ui-chart "percControl" | `{topic:'percControl',payload:number}` | ✔ | gap |
|
||||||
|
|
||||||
|
Populated/structure coverage: test/integration/basic-dashboard-flow.test.js
|
||||||
|
(asserts output count = 15 and routes outputs 0–14). **Degraded/empty-input**
|
||||||
|
coverage (no `payload:null` reaching any `ui-chart`) is still a gap — see below.
|
||||||
|
|
||||||
|
## Known coverage gaps (tracked, prospective per the rule)
|
||||||
|
|
||||||
|
The output-coverage rule applies prospectively. Outstanding items for this node:
|
||||||
|
|
||||||
|
- [ ] Dedicated `test/basic/output-port0.test.js` exercising **every** key above
|
||||||
|
in both populated and degraded (pre-tick / null) states.
|
||||||
|
- [ ] Port-1 line-protocol assertion (field names + tag).
|
||||||
|
- [ ] Port-2 `child.register` payload-shape test.
|
||||||
|
- [ ] `fn_status_split` degraded/empty-input fan-out test (no `payload:null` to
|
||||||
|
any `ui-chart`) — the failure mode the rule was written for. The structure
|
||||||
|
test in `basic-dashboard-flow.test.js` covers the populated path only.
|
||||||
@@ -182,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);
|
||||||
});
|
});
|
||||||
|
|||||||
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,6 +4,7 @@
|
|||||||
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
|
||||||
@@ -84,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());
|
||||||
|
|
||||||
|
|||||||
@@ -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, 15);
|
||||||
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,26 @@ 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, 15);
|
||||||
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));
|
||||||
|
assert.deepEqual(out[14], { topic: 'percControl', payload: 25 });
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +99,6 @@ test('basic dashboard parser keeps previous values when process output sends onl
|
|||||||
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
|
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 %');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Reference — Contracts
|
# Reference — Contracts
|
||||||
|
|
||||||
 
|
 
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Full topic contract, configuration schema, and child-registration filters for `pumpingStation`. The topic-contract and data-model sections are **regenerated by `npm run wiki:all`** — do not hand-edit between the `BEGIN AUTOGEN` / `END AUTOGEN` markers. Source of truth for everything on this page: the node's `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/pumpingStation.json`.
|
> Full topic contract, configuration schema, and child-registration filters for `pumpingStation`. The topic-contract and data-model sections are **regenerated by `npm run wiki:all`** — do not hand-edit between the `BEGIN AUTOGEN` / `END AUTOGEN` markers. Source of truth for everything on this page: the node's `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/pumpingStation.json`.
|
||||||
@@ -19,14 +19,56 @@ 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 -->
|
||||||
|
|
||||||
|
### Input message examples
|
||||||
|
|
||||||
|
One worked `msg` per accepted topic. Send these into **Port 0**. For unit-bearing
|
||||||
|
topics the commandRegistry converts `msg.unit` (or a `{ value, unit }` payload) to
|
||||||
|
the default unit *before* the handler runs — so the unit is optional and any
|
||||||
|
[compatible unit](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) is accepted.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 1. set.mode — switch control strategy
|
||||||
|
msg = { topic: 'set.mode', payload: 'manual' }; // manual | levelbased | flowbased | none
|
||||||
|
|
||||||
|
// 2. child.register — register a child (usually arrives on Port 2 from the child;
|
||||||
|
// this is the manual form). payload = the child node's Node-RED id.
|
||||||
|
msg = { topic: 'child.register', payload: 'a1b2c3d4.ef567', positionVsParent: 'upstream' };
|
||||||
|
// positionVsParent: upstream | downstream | atequipment (or in | out for predicted-flow children)
|
||||||
|
|
||||||
|
// 3. cmd.calibrate.volume — seed the predicted-volume integrator (default m³)
|
||||||
|
msg = { topic: 'cmd.calibrate.volume', payload: 12.5 }; // 12.5 m³
|
||||||
|
msg = { topic: 'cmd.calibrate.volume', payload: 12500, unit: 'L' }; // 12 500 L → auto-converted to 12.5 m³
|
||||||
|
|
||||||
|
// 4. cmd.calibrate.level — seed the predicted level (default m)
|
||||||
|
msg = { topic: 'cmd.calibrate.level', payload: 1.8 }; // 1.8 m
|
||||||
|
|
||||||
|
// 5. set.inflow — push a measured inflow (default m³/h)
|
||||||
|
msg = { topic: 'set.inflow', payload: 45 }; // 45 m³/h
|
||||||
|
msg = { topic: 'set.inflow', payload: 12.5, unit: 'L/s' }; // 12.5 L/s → 45 m³/h
|
||||||
|
msg = { topic: 'set.inflow', payload: { value: 45, unit: 'm3/h' }, timestamp: 1716998400000 };
|
||||||
|
|
||||||
|
// 6. set.outflow — push a measured/forced outflow (default m³/h)
|
||||||
|
msg = { topic: 'set.outflow', payload: 30 }; // 30 m³/h drawn from the basin
|
||||||
|
|
||||||
|
// 7. set.demand — operator outflow setpoint (default m³/h); ignored unless mode === 'manual'
|
||||||
|
msg = { topic: 'set.demand', payload: 120 }; // 120 m³/h
|
||||||
|
|
||||||
|
// Built-in (every EVOLV node): query.units — ask which units each topic accepts.
|
||||||
|
// Replies on Port 0 with { topic:'query.units', payload:{ node, units } }.
|
||||||
|
msg = { topic: 'query.units', payload: null };
|
||||||
|
```
|
||||||
|
|
||||||
|
> Deprecated aliases behave identically and log a one-time warning, e.g.
|
||||||
|
> `{ topic: 'q_in', payload: 45 }` ≡ `set.inflow`, `{ topic: 'Qd', payload: 120 }` ≡ `set.demand`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data model — `getOutput()` shape
|
## Data model — `getOutput()` shape
|
||||||
@@ -39,35 +81,88 @@ Keys composed each tick by `specificClass.getOutput()` and emitted via `outputUt
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `direction` | string | — | `"steady"` |
|
| `direction` | string | — | `"steady"` |
|
||||||
| `dryRunLevel` | number | — | `0.20400000000000001` |
|
| `dryRunLevel` | number | — | `0.20400000000000001` |
|
||||||
| `dryRunSafetyVol` | number | — | `0.20400000000000001` |
|
| `dryRunSafetyVol` | number | — | `2.55` |
|
||||||
| `flowSource` | null | — | `null` |
|
| `flowSource` | null | — | `null` |
|
||||||
| `heightBasin` | number | m | `1` |
|
| `heightBasin` | number | m | `4` |
|
||||||
| `highVolumeSafetyLevel` | number | — | `2.45` |
|
| `highVolumeSafetyLevel` | number | — | `3.7239999999999998` |
|
||||||
| `highVolumeSafetyVol` | number | — | `2.45` |
|
| `highVolumeSafetyVol` | number | — | `46.55` |
|
||||||
| `inflowLevel` | number | m | `2` |
|
| `inflowLevel` | number | m | `1.5` |
|
||||||
| `inletPipeDiameter` | number | — | `0.4` |
|
| `inletPipeDiameter` | number | — | `0.4` |
|
||||||
| `maxVol` | number | m3 | `1` |
|
| `manualDemand` | null | — | `null` |
|
||||||
| `maxVolAtOverflow` | number | m3 | `2.5` |
|
| `maxVol` | number | m3 | `50` |
|
||||||
|
| `maxVolAtOverflow` | number | m3 | `47.5` |
|
||||||
| `minHeightBasedOn` | string | — | `"outlet"` |
|
| `minHeightBasedOn` | string | — | `"outlet"` |
|
||||||
| `minVol` | number | m3 | `0.2` |
|
| `minVol` | number | m3 | `2.5` |
|
||||||
| `minVolAtInflow` | number | m3 | `2` |
|
| `minVolAtInflow` | number | m3 | `18.75` |
|
||||||
| `minVolAtOutflow` | number | m3 | `0.2` |
|
| `minVolAtOutflow` | number | m3 | `2.5` |
|
||||||
|
| `mode` | string | — | `"levelbased"` |
|
||||||
| `outflowLevel` | number | m | `0.2` |
|
| `outflowLevel` | number | m | `0.2` |
|
||||||
| `outletPipeDiameter` | number | — | `0.4` |
|
| `outletPipeDiameter` | number | — | `0.4` |
|
||||||
| `overflowLevel` | number | m | `2.5` |
|
| `overflowLevel` | number | m | `3.8` |
|
||||||
| `percControl` | number | % | `0` |
|
| `percControl` | number | % | `0` |
|
||||||
| `predictedOverflowRate` | number | — | `0` |
|
| `predictedOverflowRate` | number | — | `0` |
|
||||||
| `predictedOverflowVolume` | number | — | `0` |
|
| `predictedOverflowVolume` | number | — | `0` |
|
||||||
| `predictedUnderflowVolume` | number | — | `0` |
|
| `predictedUnderflowVolume` | number | — | `0` |
|
||||||
| `surfaceArea` | number | m2 | `1` |
|
| `surfaceArea` | number | m2 | `12.5` |
|
||||||
| `timeleft` | null | s | `null` |
|
| `timeleft` | null | s | `null` |
|
||||||
| `volEmptyBasin` | number | m3 | `1` |
|
| `volEmptyBasin` | number | m3 | `50` |
|
||||||
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` |
|
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `2.5` |
|
||||||
|
|
||||||
<!-- END AUTOGEN: data-model -->
|
<!-- END AUTOGEN: data-model -->
|
||||||
|
|
||||||
Sample values come from a stub instantiation in `wikiGen` — in a live deployment the volume key is shaped `volume.<variant>.<position>.<childId>` per the standard [Measurement Key Shape](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions#measurement-key-shape).
|
Sample values come from a stub instantiation in `wikiGen` — in a live deployment the volume key is shaped `volume.<variant>.<position>.<childId>` per the standard [Measurement Key Shape](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions#measurement-key-shape).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Two control-state keys carry the live operating mode rather than a measurement:
|
||||||
|
> - `mode` — string, the active control strategy (`levelbased` / `manual` / `flowbased` / `none`). Echoes the most recent `set.mode` input.
|
||||||
|
> - `manualDemand` — number (m³/h) or `null`. The operator outflow setpoint last accepted via `set.demand`; `null` outside `manual` mode.
|
||||||
|
|
||||||
|
### Output message examples
|
||||||
|
|
||||||
|
The node emits on three ports every tick (`outputUtils.formatMsg`). Port 0 / Port 1
|
||||||
|
fire only when at least one field changed (delta-compression); Port 2 fires once at
|
||||||
|
startup. `topic` is the station's configured name (here `"PS-Influent-01"`).
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Port 0 — process data. payload = only the keys that changed this tick.
|
||||||
|
msg = {
|
||||||
|
topic: 'PS-Influent-01',
|
||||||
|
payload: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
direction: 'filling',
|
||||||
|
percControl: 25,
|
||||||
|
'level.predicted.atequipment.default': 3.25, // m
|
||||||
|
'volume.predicted.atequipment.default': 32.5, // m³
|
||||||
|
timeleft: 400, // s, or null when steady
|
||||||
|
manualDemand: null // m³/h, or null outside manual mode
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Port 1 — InfluxDB telemetry. Same changed fields, wrapped for the InfluxDB node.
|
||||||
|
msg = {
|
||||||
|
topic: 'PS-Influent-01',
|
||||||
|
payload: {
|
||||||
|
measurement: 'PS-Influent-01',
|
||||||
|
fields: { percControl: 25, 'volume.predicted.atequipment.default': 32.5 },
|
||||||
|
tags: { id: 'a1b2c3d4.ef567', softwareType: 'pumpingstation', type: 'pumpingStation' },
|
||||||
|
timestamp: '2026-05-29T10:00:00.000Z' // Date
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Port 2 — registration handshake, sent once at startup to the upstream parent.
|
||||||
|
msg = {
|
||||||
|
topic: 'child.register',
|
||||||
|
payload: 'a1b2c3d4.ef567', // this node's id
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
distance: null
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Child-facing events** are not Port messages — they fire on
|
||||||
|
> `source.measurements.emitter` as `<type>.<variant>.<position>`, e.g. event
|
||||||
|
> `volume.predicted.atequipment` with payload `{ value: 32.5, unit: 'm3', timestamp }`.
|
||||||
|
> Parents subscribe by event name.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration schema — editor form to config keys
|
## Configuration schema — editor form to config keys
|
||||||
|
|||||||
Reference in New Issue
Block a user