Compare commits

12 Commits

Author SHA1 Message Date
znetsixe
177a37e15c chore(examples): remove build-examples.js generator — examples are hand-authored one-offs
The generator rotted (emitted 02-Integration/03-Dashboard while the repo kept
01-Basic/02-Dashboard), produced lint-failing flows, and was the only one of 11
nodes with such a tool. Per-node example flows are illustrative one-offs: the
JSON is the source of truth, validated by flow-lint. README updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:04:50 +02:00
znetsixe
089a7fa2c4 docs(examples): 01-Basic demonstrates the command envelope explicitly
- inject() helper gains optional unit/origin props (msg.unit / msg.origin).
- Value injects (set.inflow, set.demand, cmd.calibrate.volume/level) now carry
  an explicit msg.unit so the example documents the unit the value is in;
  fixed the set.demand label (m3/h, not %). Lint-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:47:19 +02:00
znetsixe
e47de87adb feat(commands): unit shorthand + collapse duplicated value/unit parsing; wiki sync
- 5 descriptors -> unit: shorthand (cmd.calibrate.volume/level, set.inflow/
  outflow/demand).
- setInflow/setOutflow: drop the hand-rolled scalar-vs-object parsing — the
  registry now normalises every shape to a number in the descriptor unit; the
  handlers become guarded one-liners (matching setDemand).
- Regenerate wiki topic-contract + command-envelope note (msg.origin).

143/143 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:41:32 +02:00
znetsixe
fc6491dc23 change(ps): emit output flow in m³/s
Set UnitPolicy output flow/netFlowRate to m³/s (was m³/h).

NOTE: this reverts the output-unit half of e041877 ("keep canonical
flow in m³/s, emit output in m³/h"). Committed at user direction during
dev-lzm → development integration; flagged for review against the
documented PS units decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:37:14 +02:00
Rene De Ren
2fb083da63 docs(contract): add worked msg examples for every input + output port
Reference-Contracts.md now carries a concrete `msg = { topic, payload, ... }`
example for each of the 7 input topics (plus the built-in query.units) and for
all three output ports (Port 0 process, Port 1 InfluxDB, Port 2 registration),
plus the emitter-event shape. Shapes verified against commandRegistry unit
normalisation and the process/influxdb formatters. Examples sit outside the
AUTOGEN markers so `npm run wiki:all` stays a no-op on them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:20:04 +02:00
Rene De Ren
4889fdaaf0 docs(contract): close output-contract gaps — mode/manualDemand, Port-2 topic, output manifest
- wiki/Reference-Contracts.md: regenerate data-model (npm run wiki:all) so the
  two live getOutput() keys `mode` and `manualDemand` are documented; refresh
  stale sample values; bump code-ref badge -> a83a85e; add human note describing
  the two control-state keys.
- CONTRACT.md: fix Port-2 outgoing topic registerChild -> child.register
  (registerChild is the deprecated *input* alias, not what the node emits).
- test/_output-manifest.md: add the mandatory output manifest (Port 0/1/2 +
  emitter events + the 15-way fn_status_split fan-out) with honest coverage gaps.
- test/integration/basic-dashboard-flow.test.js: fix stale fan-out count 14->15
  (output 14 = percControl chart added upstream); assert out[14].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:13:11 +02:00
znetsixe
a83a85e958 fix(ps): persist stopLevel/holdLevel as numbers across editor save
Node-RED's auto-form-binding writes <input type="number"> values into the
node object as strings. The editor's setNumberField helper used strict
Number.isFinite(val) which rejects "0.5" and blanked the input on reopen,
so users saw their stopLevel/holdLevel values disappear after clicking Done.

- oneditsave: explicitly parseFloat stopLevel, holdLevel, and
  deadZoneKeepAlivePercent so they land in the node as numbers (matches the
  treatment of startLevel/maxLevel).
- oneditprepare: parseFloat node.holdLevel / node.deadZoneKeepAlivePercent
  before the Number.isFinite check so existing string-typed flows still
  render their saved values.
- index.js setNumberField: defensively coerce stringy numbers so this
  gotcha can't bite a future field.

Verified end-to-end in headless Chromium: type new values, click Done,
reopen — values persist and the stopLevel/holdLevel marker lines render
at the correct x in the level-based mode preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:21:59 +02:00
znetsixe
e041877ae4 fix(ps): keep canonical flow in m³/s, emit output in m³/h
Reverts the canonical half of 8216480 (which set BOTH canonical and output
to m³/h) back to the platform-wide m³/s convention. Canonical m³/s is what
every cross-node consumer assumes — MGC percent→flow demand interpolation,
the volume integrator (flow × dt), and physics-sanity balances. Changing the
canonical basis to m³/h silently scaled those by 3600×.

Output flow / netFlowRate stay m³/h so telemetry and dashboard series remain
on the same axis as the rest of the pump group (verified slice #47). The
m³/s→m³/h conversion now happens at the output boundary only, never on the
internal integrator basis.

No smoothing/hysteresis added for the PS→MGC demand hunting: per design
review that belongs in a dedicated intermediate node (e.g. a PID), not in
the pumpingStation or machineGroupControl control path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:31:39 +02:00
znetsixe
8216480950 change(ps): emit flow in m³/h (canonical + output)
Switch pumpingStation flow unit from m³/s to m³/h for canonical and output
so telemetry/dashboard series land on the same axis as the rest of the
group. NOTE: diverges from the platform-wide m³/s canonical convention —
flagged for review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:09:27 +02:00
znetsixe
dfaa0c3ae8 feat(pumpingstation): warn when control engages with no machine group registered
A station engaged above startLevel computes a real demand, but if no machine
group is registered (e.g. the Port 2 parent↔group registration was dropped by a
partial redeploy) the demand is silently forwarded nowhere and the pumps never
react — invisible to the operator. levelBased now warns once when engaged with
an empty machineGroups map (throttled via host._warnedNoMachineGroup, re-arms
when a group reappears); manual.forwardDemand warns when neither a group nor a
direct machine is registered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:58:34 +02:00
znetsixe
6e727d929b fix(pumpingstation): replay child measurement value on subscribe
A measurement child that already holds a value when the pumpingStation
registers it (e.g. a once:true inject that fired during startup before the
parent subscribed) was never surfaced — the emitter only delivers future
updates. _subscribeMeasurement now seeds from the child's current sample via
getLaggedSample(0), so late subscribers pick up present state. This is what
makes a measured upstream inflow register as inflow on a clean startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:45:44 +02:00
ef07f2a5b2 wip: pre-ship-it state — example dashboard tweaks 2026-05-26 17:31:44 +02:00
18 changed files with 909 additions and 1522 deletions

View File

@@ -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`

View File

@@ -1,479 +1,360 @@
[ [
{ {
"id": "77f00aef1c966167", "id": "ps_basic_tab",
"type": "tab", "type": "tab",
"label": "PumpingStation - Basic", "label": "PumpingStation - Basic",
"disabled": false, "disabled": false,
"info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand." "info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
},
{
"id": "ps_basic_title",
"type": "comment",
"z": "ps_basic_tab",
"name": "PumpingStation - Basic\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nA 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\noverflow at 3.2 m). controlMode = levelbased, manual demand allowed\nonly when set.mode = manual.\n\nHOW TO USE:\n 1. Deploy the flow.\n 2. Click \"set.mode = manual\" so set.demand is honoured.\n 3. Click \"set.inflow = 60 m3/h\" to push wastewater into the basin.\n 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n 5. Click \"calibrate volume 25 m3\" to jump straight to half-full.\n\nAliases (changemode, q_in, Qd, …) still work but log a deprecation\nwarning - fresh flows use the canonical names.",
"info": "",
"x": 600,
"y": 40,
"wires": []
},
{
"id": "ps_basic_inj_mode",
"type": "inject",
"z": "ps_basic_tab",
"name": "set.mode = manual",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "manual",
"vt": "str"
}
],
"topic": "set.mode",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 200,
"y": 160,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_mode_lvl",
"type": "inject",
"z": "ps_basic_tab",
"name": "set.mode = levelbased",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "levelbased",
"vt": "str"
}
],
"topic": "set.mode",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 220,
"y": 200,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_inflow",
"type": "inject",
"z": "ps_basic_tab",
"name": "set.inflow = 60 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "60",
"vt": "num"
},
{
"p": "unit",
"v": "m3/h",
"vt": "str"
}
],
"topic": "set.inflow",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 200,
"y": 260,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_demand",
"type": "inject",
"z": "ps_basic_tab",
"name": "set.demand = 40 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "40",
"vt": "num"
},
{
"p": "unit",
"v": "m3/h",
"vt": "str"
}
],
"topic": "set.demand",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 200,
"y": 300,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_calvol",
"type": "inject",
"z": "ps_basic_tab",
"name": "calibrate volume 25 m3",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "25",
"vt": "num"
},
{
"p": "unit",
"v": "m3",
"vt": "str"
}
],
"topic": "cmd.calibrate.volume",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 220,
"y": 360,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_callvl",
"type": "inject",
"z": "ps_basic_tab",
"name": "calibrate level 1.5 m",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "1.5",
"vt": "num"
},
{
"p": "unit",
"v": "m",
"vt": "str"
}
],
"topic": "cmd.calibrate.level",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 220,
"y": 400,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_node",
"type": "pumpingStation",
"z": "ps_basic_tab",
"name": "Pumping Station",
"simulator": false,
"basinVolume": 50,
"basinHeight": 3.5,
"inflowLevel": 3,
"outflowLevel": 0.2,
"overflowLevel": 3.2,
"defaultFluid": "wastewater",
"inletPipeDiameter": 0.3,
"outletPipeDiameter": 0.3,
"pipelineLength": 80,
"maxDischargeHead": 24,
"staticHead": 12,
"maxInflowRate": 200,
"temperatureReferenceDegC": 15,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"enableDryRunProtection": true,
"enableOverfillProtection": true,
"dryRunThresholdPercent": 2,
"overfillThresholdPercent": 98,
"minHeightBasedOn": "outlet",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"refHeight": "NAP",
"basinBottomRef": 1,
"uuid": "example-ps-001",
"supplier": "WBD-RD",
"category": "station",
"assetType": "pumpingstation",
"model": "demo-50m3",
"unit": "m3/h",
"enableLog": true,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"controlMode": "levelbased",
"startLevel": 1.2,
"minLevel": 0.4,
"maxLevel": 2.8,
"flowSetpoint": null,
"flowDeadband": null,
"x": 1320,
"y": 300,
"wires": [
[
"ps_basic_format"
],
[
"ps_basic_dbg_influx"
],
[
"ps_basic_dbg_parent"
]
]
},
{
"id": "ps_basic_format",
"type": "function",
"z": "ps_basic_tab",
"name": "Merge deltas + format",
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction pick(prefix) {\n for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n } return null;\n}\nconst vol = pick('volume.predicted.atequipment');\nconst lvl = pick('level.predicted.atequipment');\nconst flIn = pick('flow.predicted.in');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1560,
"y": 280,
"wires": [
[
"ps_basic_dbg_process"
]
]
},
{
"id": "ps_basic_dbg_process",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 0: Process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1800,
"y": 240,
"wires": []
},
{
"id": "ps_basic_dbg_influx",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 1: InfluxDB",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1800,
"y": 320,
"wires": []
},
{
"id": "ps_basic_dbg_parent",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 2: Parent reg",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1800,
"y": 380,
"wires": []
},
{
"id": "grp_ps_basic",
"type": "group",
"z": "ps_basic_tab",
"name": "Pumping Station (PC)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#0c99d9",
"fill-opacity": "0.10"
}, },
{ "nodes": [
"id": "aa3381b896eb2cfb", "ps_basic_node",
"type": "group", "ps_basic_format"
"z": "77f00aef1c966167", ],
"name": "Pumping Station (Process Cell)", "x": 1290,
"style": { "y": 230,
"label": true, "w": 500,
"stroke": "#000000", "h": 140
"fill": "#0c99d9", }
"fill-opacity": "0.10"
},
"nodes": [
"8e78b6607deb33a7"
],
"x": 534,
"y": 351.5,
"w": 232,
"h": 97
},
{
"id": "4996420d47442fad",
"type": "group",
"z": "77f00aef1c966167",
"name": "1. Control mode",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"1155bbbde7c65363",
"e9bea0f95b557f5d"
],
"x": 94,
"y": 119,
"w": 272,
"h": 122
},
{
"id": "a9f9b38b0e00c1d7",
"type": "group",
"z": "77f00aef1c966167",
"name": "2. Flow signals (inflow / outflow)",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"7b2b5eb919b1ab15",
"3350187815774b95"
],
"x": 94,
"y": 279,
"w": 262,
"h": 122
},
{
"id": "42bf82c87d05f498",
"type": "group",
"z": "77f00aef1c966167",
"name": "3. Operator demand (manual mode only)",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"48c2262c345c46b9"
],
"x": 94,
"y": 479,
"w": 261,
"h": 82
},
{
"id": "234bdce20170061a",
"type": "group",
"z": "77f00aef1c966167",
"name": "4. Calibration",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"463eefdd54df89a5",
"2e0642275899fc79"
],
"x": 94,
"y": 599,
"w": 272,
"h": 122
},
{
"id": "f4ba4542514ed853",
"type": "group",
"z": "77f00aef1c966167",
"name": "Expected outputs",
"style": {
"stroke": "#666666",
"fill": "#d1d1d1",
"fill-opacity": "0.2",
"label": true,
"color": "#333333"
},
"nodes": [
"b2450e5ee2eebfaa",
"386af1ad8aa8ed12",
"c27c2655f199b530"
],
"x": 874,
"y": 299,
"w": 252,
"h": 202
},
{
"id": "b30af582f935bcb7",
"type": "comment",
"z": "77f00aef1c966167",
"name": "PumpingStation — Basic (Tier 1)",
"info": "Single pumpingStation node driven by inject buttons. Shows the canonical msg.topic command surface.\n\nDefault controlMode = levelbased. Switch to manual to honour set.demand.\n\nHOW TO USE\n1. Deploy the flow.\n2. (optional) Click \"set.mode = manual\" if you want set.demand to forward; otherwise leave it on levelbased and the ramp drives demand from level.\n3. Click \"set.inflow = 60 m³/h\" to push wastewater into the basin.\n4. Watch the basin fill on Port 0 (level, volume rise) and Port 1 (InfluxDB-shaped payload).\n5. In manual mode: click \"set.demand = 40\" — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.\n6. Click \"calibrate volume 25 m³\" or \"calibrate level 1.5 m\" to snap the predicted-volume integrator.\n\nPORTS\n- Port 0: process output (changed fields only)\n- Port 1: InfluxDB-shaped {measurement, fields, tags, timestamp}\n- Port 2: parent registration (child handshake)",
"x": 650,
"y": 300,
"wires": []
},
{
"id": "1155bbbde7c65363",
"type": "inject",
"z": "77f00aef1c966167",
"g": "4996420d47442fad",
"name": "set.mode = manual",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "manual",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.mode",
"x": 230,
"y": 160,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "e9bea0f95b557f5d",
"type": "inject",
"z": "77f00aef1c966167",
"g": "4996420d47442fad",
"name": "set.mode = levelbased",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "levelbased",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.mode",
"x": 240,
"y": 200,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "7b2b5eb919b1ab15",
"type": "inject",
"z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7",
"name": "set.inflow = 60 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "60",
"vt": "num"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.inflow",
"x": 240,
"y": 360,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "48c2262c345c46b9",
"type": "inject",
"z": "77f00aef1c966167",
"g": "42bf82c87d05f498",
"name": "set.demand = 40 %",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "40",
"vt": "num"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.demand",
"x": 230,
"y": 520,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "463eefdd54df89a5",
"type": "inject",
"z": "77f00aef1c966167",
"g": "234bdce20170061a",
"name": "calibrate volume 25 m3",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "25",
"vt": "num"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "cmd.calibrate.volume",
"x": 240,
"y": 640,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "2e0642275899fc79",
"type": "inject",
"z": "77f00aef1c966167",
"g": "234bdce20170061a",
"name": "calibrate level 1.5 m",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "1.5",
"vt": "num"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "cmd.calibrate.level",
"x": 240,
"y": 680,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "b2450e5ee2eebfaa",
"type": "debug",
"z": "77f00aef1c966167",
"g": "f4ba4542514ed853",
"name": "Port 0: Process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 980,
"y": 340,
"wires": []
},
{
"id": "386af1ad8aa8ed12",
"type": "debug",
"z": "77f00aef1c966167",
"g": "f4ba4542514ed853",
"name": "Port 1: InfluxDB",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 980,
"y": 400,
"wires": []
},
{
"id": "c27c2655f199b530",
"type": "debug",
"z": "77f00aef1c966167",
"g": "f4ba4542514ed853",
"name": "Port 2: Parent reg",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 990,
"y": 460,
"wires": []
},
{
"id": "8e78b6607deb33a7",
"type": "pumpingStation",
"z": "77f00aef1c966167",
"g": "aa3381b896eb2cfb",
"name": "",
"simulator": false,
"basinVolume": 50,
"basinHeight": 4,
"inflowLevel": 1.5,
"outflowLevel": 0.2,
"overflowLevel": 3.8,
"defaultFluid": "wastewater",
"inletPipeDiameter": 0.3,
"outletPipeDiameter": 0.3,
"pipelineLength": 80,
"maxDischargeHead": 24,
"staticHead": 12,
"maxInflowRate": 200,
"temperatureReferenceDegC": 15,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"enableDryRunProtection": true,
"enableHighVolumeSafety": true,
"enableOverfillProtection": true,
"dryRunThresholdPercent": 2,
"highVolumeSafetyThresholdPercent": 98,
"overfillThresholdPercent": 98,
"minHeightBasedOn": "outlet",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"refHeight": "NAP",
"basinBottomRef": 1,
"uuid": "",
"supplier": "",
"category": "",
"assetType": "",
"model": "",
"unit": "",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "⊥",
"hasDistance": false,
"distance": "",
"controlMode": "levelbased",
"levelCurveType": "linear",
"logCurveFactor": 9,
"enableShiftedRamp": false,
"shiftLevel": 0,
"shiftArmPercent": 95,
"startLevel": 1,
"stopLevel": 0.5,
"minLevel": 0.20400000000000001,
"maxLevel": 3.8,
"flowSetpoint": null,
"flowDeadband": null,
"x": 650,
"y": 400,
"wires": [
[
"b2450e5ee2eebfaa"
],
[
"386af1ad8aa8ed12"
],
[
"c27c2655f199b530"
]
]
},
{
"id": "3350187815774b95",
"type": "inject",
"z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7",
"name": "set.outflow= 80 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.outflow",
"payload": "80",
"payloadType": "num",
"x": 230,
"y": 320,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "ef77c1819422a098",
"type": "global-config",
"env": [],
"modules": {
"EVOLV": "1.0.29"
}
}
] ]

View File

@@ -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 // 06: 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 // 79: single-series charts\n chart('Level', level),\n chart('Volume', volume),\n chart('Volume %', volPct),\n // 1012: 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": [
[]
]
} }
] ]

View File

@@ -79,8 +79,12 @@ These flows follow the EVOLV layout rule set in
- **Group boxes** wrap each parent + its direct children, coloured by the - **Group boxes** wrap each parent + its direct children, coloured by the
parent's S88 level. parent's S88 level.
## Regenerating ## Maintaining
The current example JSON files are hand-maintained. If you re-introduce a These example flows are **hand-authored one-offs** — edit the JSON directly.
generator, regenerate `01-Basic.json` and `02-Dashboard.json` from it There is intentionally no generator: examples are illustrative, not produced in
rather than editing the JSON directly. bulk. Validate any change with `flow-lint`:
```bash
node ../../../tools/flow-lint/bin/flow-lint.js 01-Basic.json 02-Dashboard.json
```

View File

@@ -48,42 +48,29 @@ exports.calibrateLevel = (source, msg, ctx) => {
source.calibratePredictedLevel(v); source.calibratePredictedLevel(v);
}; };
exports.setInflow = (source, msg) => { // The registry has already normalised any accepted shape (number, numeric
// Payload is either a number (legacy q_in shape) or // string, or { value, unit } object) to a number in the descriptor unit
// { value, unit, timestamp } (richer object form). // (m3/h) and tagged msg.unit. Handlers just read the normalised scalar.
const p = msg.payload; exports.setInflow = (source, msg, ctx) => {
let value; const log = _logger(source, ctx);
let unit; const value = Number(msg.payload);
let timestamp; if (!Number.isFinite(value)) {
if (p !== null && typeof p === 'object') { log?.warn?.(`set.inflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
value = Number(p.value); return;
unit = p.unit;
timestamp = p.timestamp || Date.now();
} else {
value = Number(p);
unit = msg?.unit;
timestamp = msg?.timestamp || Date.now();
} }
source.setManualInflow(value, timestamp, unit); source.setManualInflow(value, msg.timestamp, msg.unit);
}; };
exports.setOutflow = (source, msg) => { exports.setOutflow = (source, msg, ctx) => {
// Manual q_out — basin-docs dashboard injects a drain rate without // Manual q_out — basin-docs dashboard injects a drain rate without wiring a
// wiring a real pump. Same payload shape as q_in. // real pump. Same normalised shape as set.inflow.
const p = msg.payload; const log = _logger(source, ctx);
let value; const value = Number(msg.payload);
let unit; if (!Number.isFinite(value)) {
let timestamp; log?.warn?.(`set.outflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
if (p !== null && typeof p === 'object') { return;
value = Number(p.value);
unit = p.unit;
timestamp = p.timestamp || Date.now();
} else {
value = Number(p);
unit = msg?.unit;
timestamp = msg?.timestamp || Date.now();
} }
source.setManualOutflow(value, timestamp, unit); source.setManualOutflow(value, msg.timestamp, msg.unit);
}; };
exports.setDemand = (source, msg, ctx) => { exports.setDemand = (source, msg, ctx) => {

View File

@@ -26,9 +26,10 @@ module.exports = [
{ {
topic: 'cmd.calibrate.volume', topic: 'cmd.calibrate.volume',
aliases: ['calibratePredictedVolume'], aliases: ['calibratePredictedVolume'],
// any: payload may be a number or numeric string. // any: payload may be a number, numeric string, or { value, unit } object —
// the registry normalises all of them to a number in `unit` before the handler.
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volume', default: 'm3' }, unit: 'm3',
description: 'Calibrate the predicted-volume integrator to a known basin volume.', description: 'Calibrate the predicted-volume integrator to a known basin volume.',
handler: handlers.calibrateVolume, handler: handlers.calibrateVolume,
}, },
@@ -36,16 +37,15 @@ module.exports = [
topic: 'cmd.calibrate.level', topic: 'cmd.calibrate.level',
aliases: ['calibratePredictedLevel'], aliases: ['calibratePredictedLevel'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'length', default: 'm' }, unit: 'm',
description: 'Calibrate the predicted-volume integrator to a known basin level.', description: 'Calibrate the predicted-volume integrator to a known basin level.',
handler: handlers.calibrateLevel, handler: handlers.calibrateLevel,
}, },
{ {
topic: 'set.inflow', topic: 'set.inflow',
aliases: ['q_in'], aliases: ['q_in'],
// any: number, numeric string, or { value, unit, timestamp } object.
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Push a measured inflow value into the basin balance.', description: 'Push a measured inflow value into the basin balance.',
handler: handlers.setInflow, handler: handlers.setInflow,
}, },
@@ -53,7 +53,7 @@ module.exports = [
topic: 'set.outflow', topic: 'set.outflow',
aliases: ['q_out'], aliases: ['q_out'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Push a measured outflow value into the basin balance.', description: 'Push a measured outflow value into the basin balance.',
handler: handlers.setOutflow, handler: handlers.setOutflow,
}, },
@@ -61,7 +61,7 @@ module.exports = [
topic: 'set.demand', topic: 'set.demand',
aliases: ['Qd'], aliases: ['Qd'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Operator outflow demand setpoint for the station.', description: 'Operator outflow demand setpoint for the station.',
handler: handlers.setDemand, handler: handlers.setDemand,
}, },

View File

@@ -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);
} }

View File

@@ -28,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 = {

View File

@@ -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.

View File

@@ -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);

View File

@@ -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

View File

@@ -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).
@@ -286,7 +292,7 @@ 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}`
); );
@@ -297,7 +303,21 @@ class PumpingStation extends BaseDomain {
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
View 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 014). **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.

View File

@@ -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');
});

View 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');
});

View File

@@ -35,7 +35,7 @@ test('basic dashboard flow contains the pumpingStation node and trend widgets',
assert.equal(ps.inletPipeDiameter, 0.3); assert.equal(ps.inletPipeDiameter, 0.3);
assert.equal(ps.outletPipeDiameter, 0.3); assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'fn_status_split should exist'); assert.ok(parser, 'fn_status_split should exist');
assert.equal(parser.outputs, 14); assert.equal(parser.outputs, 15);
assert.equal(levelChart.type, 'ui-chart'); assert.equal(levelChart.type, 'ui-chart');
assert.equal(volumeChart.type, 'ui-chart'); assert.equal(volumeChart.type, 'ui-chart');
assert.equal(flowChart.type, 'ui-chart'); assert.equal(flowChart.type, 'ui-chart');
@@ -72,7 +72,7 @@ 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, 14); assert.equal(out.length, 15);
assert.equal(out[0].payload, 'levelbased'); assert.equal(out[0].payload, 'levelbased');
assert.equal(out[1].payload, 'filling'); assert.equal(out[1].payload, 'filling');
assert.equal(out[2].payload, '3.25 m'); assert.equal(out[2].payload, '3.25 m');
@@ -86,6 +86,7 @@ test('basic dashboard parser routes process fields to charts and state text', ()
assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 }); assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 });
assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 }); assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 });
assert.ok(Array.isArray(out[13].payload)); 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', () => {

View File

@@ -1,949 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* build-examples.js — regenerate the three example flows for pumpingStation.
*
* Source of truth for the Tier 1/2/3 example flows under examples/.
* Follows EVOLV/.claude/rules/node-red-flow-layout.md:
* - Lane positions L0..L7 = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
* - S88 colours per Node-RED group (Process Cell = #0c99d9, Unit = #50a8d9,
* Equipment Module = #86bbdd, Control Module = #a9daee, neutral = #dddddd)
* - Cross-tab wiring via named link out/link in channels (cmd:* / evt:* / setup:*)
* - ui-chart objects carry every mandatory key (interpolation, yAxisProperty,
* xAxisPropertyType, action, removeOlder*, colors, etc.) — omitting any
* causes FlowFuse to render the chart blank with no error.
*
* Only canonical pumpingStation topic names are used (per CONTRACT.md):
* set.mode, set.inflow, set.demand, cmd.calibrate.volume, cmd.calibrate.level.
*
* Run from repo root or any cwd:
* node nodes/pumpingStation/tools/build-examples.js
*/
const fs = require('fs');
const path = require('path');
const OUT_DIR = path.join(__dirname, '..', 'examples');
/* ------------------------------------------------------------------ */
/* Layout constants */
/* ------------------------------------------------------------------ */
const LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800];
const S88 = {
AR: '#0f52a5',
PC: '#0c99d9',
UN: '#50a8d9',
EM: '#86bbdd',
CM: '#a9daee',
neutral: '#dddddd',
};
const CHART_COLORS = [
'#0095FF', '#FF0000', '#FF7F0E', '#2CA02C', '#A347E1',
'#D62728', '#FF9896', '#9467BD', '#C5B0D5',
];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function tab(id, label, info) {
return { id, type: 'tab', label, disabled: false, info: info || '' };
}
function comment(id, z, name, x, y) {
return { id, type: 'comment', z, name, info: '', x, y, wires: [] };
}
function linkOut(id, z, name, x, y, links) {
return { id, type: 'link out', z, name, mode: 'link', links: links || [], x, y, wires: [] };
}
function linkIn(id, z, name, x, y, links, downstream) {
return { id, type: 'link in', z, name, links: links || [], x, y, wires: [downstream || []] };
}
function inject(id, z, name, topic, payload, payloadType, x, y, wires, opts) {
const o = opts || {};
return {
id, type: 'inject', z, name,
props: [
{ p: 'topic', vt: 'str' },
{ p: 'payload', v: String(payload), vt: payloadType },
],
topic,
repeat: o.repeat || '',
crontab: '',
once: !!o.once,
onceDelay: o.onceDelay || '',
x, y,
wires: [wires || []],
};
}
function fn(id, z, name, code, x, y, wires, outputs) {
return {
id, type: 'function', z, name,
func: code,
outputs: outputs || 1,
noerr: 0,
initialize: '',
finalize: '',
libs: [],
x, y,
wires: wires || [[]],
};
}
function debugNode(id, z, name, x, y, complete, targetType, active) {
return {
id, type: 'debug', z, name,
active: active !== false,
tosidebar: true,
console: false,
tostatus: false,
complete: complete || 'payload',
targetType: targetType || 'msg',
x, y, wires: [],
};
}
function group(id, z, name, color, nodes, bbox) {
return {
id, type: 'group', z, name,
style: { label: true, stroke: '#000000', fill: color, 'fill-opacity': '0.10' },
nodes,
x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
};
}
function bboxOf(nodeList, ids, pad) {
const p = pad == null ? 20 : pad;
const ns = nodeList.filter((n) => ids.includes(n.id));
const xs = ns.map((n) => n.x || 0);
const ys = ns.map((n) => n.y || 0);
const minX = Math.min(...xs) - p;
const minY = Math.min(...ys) - p - 20;
const w = Math.max(...xs) - Math.min(...xs) + 200 + 2 * p;
const h = Math.max(...ys) - Math.min(...ys) + 60 + 2 * p;
return { x: minX, y: minY, w, h };
}
/* Build a fully-specified pumpingStation node. Every config field is set
* explicitly per rule §9 (no schema-default reliance for operational
* parameters). 50 m³ basin, 3.5 m height, inflow at 3 m, outflow at 0.2 m,
* overflow at 3.2 m. Level thresholds chosen so levelbased control activates
* mid-tank and saturates near overflow.
*/
function pumpingStationNode(id, z, name, x, y, wires) {
return {
id, type: 'pumpingStation', z, name,
simulator: false,
basinVolume: 50,
basinHeight: 3.5,
inflowLevel: 3.0,
outflowLevel: 0.2,
overflowLevel: 3.2,
defaultFluid: 'wastewater',
inletPipeDiameter: 0.3,
outletPipeDiameter: 0.3,
pipelineLength: 80,
maxDischargeHead: 24,
staticHead: 12,
maxInflowRate: 200,
temperatureReferenceDegC: 15,
timeleftToFullOrEmptyThresholdSeconds: 0,
enableDryRunProtection: true,
enableOverfillProtection: true,
dryRunThresholdPercent: 2,
overfillThresholdPercent: 98,
minHeightBasedOn: 'outlet',
processOutputFormat: 'process',
dbaseOutputFormat: 'influxdb',
refHeight: 'NAP',
basinBottomRef: 1,
uuid: 'example-ps-001',
supplier: 'WBD-RD',
category: 'station',
assetType: 'pumpingstation',
model: 'demo-50m3',
unit: 'm3/h',
enableLog: true,
logLevel: 'info',
positionVsParent: 'atEquipment',
positionIcon: '',
hasDistance: false,
distance: '',
distanceUnit: 'm',
distanceDescription: '',
controlMode: 'levelbased',
startLevel: 1.2,
minLevel: 0.4,
maxLevel: 2.8,
flowSetpoint: null,
flowDeadband: null,
x, y,
wires: wires || [[], [], []],
};
}
function measurementLevelNode(id, z, name, x, y, wires) {
return {
id, type: 'measurement', z, name,
mode: 'analog',
channels: '[]',
scaling: false,
i_min: 0, i_max: 0, i_offset: 0,
o_min: 0, o_max: 1,
simulator: true,
smooth_method: 'mean',
count: 5,
processOutputFormat: 'process',
dbaseOutputFormat: 'influxdb',
uuid: 'example-level-001',
supplier: 'vega',
category: 'sensor',
assetType: 'level',
model: 'VEGAPULS-31',
unit: 'm',
assetTagNumber: 'LT-001',
enableLog: false,
logLevel: 'error',
positionVsParent: 'atEquipment',
positionIcon: '',
hasDistance: false,
distance: 0,
distanceUnit: 'm',
distanceDescription: '',
x, y,
wires: wires || [[], [], []],
};
}
function machineGroupControlNode(id, z, name, x, y, wires) {
return {
id, type: 'machineGroupControl', z, name,
enableLog: true,
logLevel: 'info',
positionVsParent: 'atEquipment',
positionIcon: '',
hasDistance: false,
distance: '',
distanceUnit: 'm',
x, y,
wires: wires || [[], [], []],
};
}
function rotatingMachineNode(id, z, name, uuid, x, y, wires) {
return {
id, type: 'rotatingMachine', z, name,
speed: '1',
startup: '2', warmup: '1', shutdown: '2', cooldown: '1',
movementMode: 'staticspeed',
machineCurve: '',
uuid,
supplier: 'hidrostal',
category: 'pump',
assetType: 'pump-centrifugal',
model: 'hidrostal-H05K-S03R',
unit: 'm3/h',
curvePressureUnit: 'mbar',
curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW',
curveControlUnit: '%',
enableLog: false,
logLevel: 'error',
positionVsParent: 'atEquipment',
positionIcon: '',
hasDistance: false,
distance: '',
distanceUnit: 'm',
distanceDescription: '',
x, y,
wires: wires || [[], [], []],
};
}
/* FlowFuse ui-chart with every required key (per layout rule §4). */
function uiChart(id, z, group, name, label, order, yAxisLabel, x, y, color) {
return {
id, type: 'ui-chart', z, group, name, label,
order, width: 12, height: 6,
chartType: 'line',
category: 'topic',
categoryType: 'msg',
xAxisLabel: 'time',
xAxisType: 'time',
xAxisProperty: '',
xAxisPropertyType: 'timestamp',
xAxisFormat: '',
xAxisFormatType: 'auto',
yAxisLabel,
yAxisProperty: 'payload',
yAxisPropertyType: 'msg',
xmin: '', xmax: '', ymin: '', ymax: '',
bins: 10,
action: 'append',
stackSeries: false,
pointShape: 'circle',
pointRadius: 4,
interpolation: 'linear',
showLegend: true,
className: '',
removeOlder: '15',
removeOlderUnit: '60',
removeOlderPoints: '200',
colors: color ? [color, ...CHART_COLORS.slice(1)] : CHART_COLORS,
textColor: ['#666666'],
textColorDefault: true,
gridColor: ['#e5e5e5'],
gridColorDefault: true,
x, y, wires: [],
};
}
function uiText(id, z, group, name, label, order, x, y, format) {
return {
id, type: 'ui-text', z, group, name, label,
order, width: 4, height: 1,
format: format || '{{msg.payload}}',
layout: 'row-spread',
x, y, wires: [],
};
}
function uiSlider(id, z, group, name, label, order, x, y, topic, min, max, step) {
return {
id, type: 'ui-slider', z, group, name, label,
order, width: 6, height: 1,
passthru: true,
outs: 'end',
topic,
topicType: 'str',
min, max, step,
icon: '',
thumbLabel: 'always',
showValue: true,
className: '',
x, y, wires: [[]],
};
}
function uiDropdown(id, z, group, name, label, order, x, y, topic, options, wires) {
return {
id, type: 'ui-dropdown', z, group, name, label,
order, width: 6, height: 1,
passthru: true,
multiple: false,
options: options.map((o) => ({ label: o, value: o, type: 'str' })),
payload: '',
topic,
topicType: 'str',
x, y,
wires: [wires || []],
};
}
function uiBase(id) {
return {
id, type: 'ui-base',
name: 'EVOLV Demo',
path: '/dashboard',
appIcon: '',
includeClientData: true,
acceptsClientConfig: ['ui-notification', 'ui-control'],
showPathInSidebar: false,
headerContent: 'page',
navigationStyle: 'default',
titleBarStyle: 'default',
};
}
function uiTheme(id) {
return {
id, type: 'ui-theme',
name: 'EVOLV Theme',
colors: {
surface: '#ffffff', primary: '#0c99d9', bgPage: '#eeeeee',
groupBg: '#ffffff', groupOutline: '#cccccc',
},
sizes: {
density: 'default', pagePadding: '14px', groupGap: '14px',
groupBorderRadius: '6px', widgetGap: '12px',
},
};
}
function uiPage(id, base, theme, name, path, order) {
return {
id, type: 'ui-page', name, ui: base, path,
icon: 'water',
layout: 'grid', theme,
breakpoints: [{ name: 'Default', px: '0', cols: '12' }],
order, className: '',
};
}
function uiGroup(id, page, name, width, height, order) {
return {
id, type: 'ui-group', name, page, width, height, order,
showTitle: true, className: '',
};
}
/* ------------------------------------------------------------------ */
/* Tier 1 — 01-Basic.json */
/* ------------------------------------------------------------------ */
function buildBasic() {
const Z = 'ps_basic_tab';
const nodes = [];
nodes.push(tab(Z, 'PumpingStation - Basic',
'Tier 1: single pumpingStation node driven by inject nodes only. ' +
'Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand.'));
nodes.push(comment('ps_basic_title', Z,
'PumpingStation - Basic\n' +
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
'A 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\n' +
'overflow at 3.2 m). controlMode = levelbased, manual demand allowed\n' +
'only when set.mode = manual.\n\n' +
'HOW TO USE:\n' +
' 1. Deploy the flow.\n' +
' 2. Click "set.mode = manual" so set.demand is honoured.\n' +
' 3. Click "set.inflow = 60 m3/h" to push wastewater into the basin.\n' +
' 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n' +
' 5. Click "calibrate volume 25 m3" to jump straight to half-full.\n\n' +
'Aliases (changemode, q_in, Qd, …) still work but log a deprecation\n' +
'warning - fresh flows use the canonical names.', 600, 40));
// Lane 0: link-in placeholders (none for Tier 1 - all inputs are local).
// Lane 2..3: inject nodes (we keep them in lane 1 for proximity).
const injectMode = inject('ps_basic_inj_mode', Z, 'set.mode = manual', 'set.mode', 'manual', 'str', 200, 160, ['ps_basic_node']);
const injectModeLvl = inject('ps_basic_inj_mode_lvl',Z, 'set.mode = levelbased','set.mode', 'levelbased', 'str', 220, 200, ['ps_basic_node']);
const injectInflow = inject('ps_basic_inj_inflow', Z, 'set.inflow = 60 m3/h', 'set.inflow', '60', 'num', 200, 260, ['ps_basic_node']);
const injectDemand = inject('ps_basic_inj_demand', Z, 'set.demand = 40 %', 'set.demand', '40', 'num', 200, 300, ['ps_basic_node']);
const injectCalVol = inject('ps_basic_inj_calvol', Z, 'calibrate volume 25 m3','cmd.calibrate.volume','25','num', 220, 360, ['ps_basic_node']);
const injectCalLvl = inject('ps_basic_inj_callvl', Z, 'calibrate level 1.5 m','cmd.calibrate.level','1.5','num', 220, 400, ['ps_basic_node']);
nodes.push(injectMode, injectModeLvl, injectInflow, injectDemand, injectCalVol, injectCalLvl);
// Lane 5 (PC): the pumpingStation itself.
const ps = pumpingStationNode('ps_basic_node', Z, 'Pumping Station', LANE_X[5], 300,
[['ps_basic_format'], ['ps_basic_dbg_influx'], ['ps_basic_dbg_parent']]);
nodes.push(ps);
// Lane 6: format/merge function for Port 0.
const formatFn = fn('ps_basic_format', Z, 'Merge deltas + format',
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
"const cache = context.get('c') || {};\n" +
"Object.assign(cache, p);\n" +
"context.set('c', cache);\n" +
"function pick(prefix) {\n" +
" for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n" +
" const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n" +
" } return null;\n" +
"}\n" +
"const vol = pick('volume.predicted.atequipment');\n" +
"const lvl = pick('level.predicted.atequipment');\n" +
"const flIn = pick('flow.predicted.in');\n" +
"msg.payload = {\n" +
" state: cache.state || 'unknown',\n" +
" controlMode: cache.controlMode || cache.mode || 'n/a',\n" +
" direction: cache.direction || 'n/a',\n" +
" percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n" +
" volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n" +
" volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n" +
" level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n" +
" inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n" +
" timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n" +
" timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n" +
"};\nreturn msg;",
LANE_X[6], 280, [['ps_basic_dbg_process']]);
nodes.push(formatFn);
// Lane 7: debug taps.
nodes.push(debugNode('ps_basic_dbg_process', Z, 'Port 0: Process', LANE_X[7], 240, 'payload', 'msg', true));
nodes.push(debugNode('ps_basic_dbg_influx', Z, 'Port 1: InfluxDB', LANE_X[7], 320, 'true', 'full', false));
nodes.push(debugNode('ps_basic_dbg_parent', Z, 'Port 2: Parent reg', LANE_X[7], 380, 'true', 'full', true));
// Wrap the station + its formatter in a Process Cell group box.
const psGroupIds = ['ps_basic_node', 'ps_basic_format'];
nodes.push(group('grp_ps_basic', Z, 'Pumping Station (PC)', S88.PC, psGroupIds,
bboxOf(nodes, psGroupIds, 30)));
return nodes;
}
/* ------------------------------------------------------------------ */
/* Tier 2 — 02-Integration.json */
/* ------------------------------------------------------------------ */
function buildIntegration() {
const TAB_PROC = 'ps_int_proc';
const TAB_SETUP = 'ps_int_setup';
const nodes = [];
nodes.push(tab(TAB_PROC, 'Process Plant',
'Tier 2: pumpingStation + measurement child + machineGroupControl parent with two rotatingMachine pumps. ' +
'Demonstrates Phase-2 parent/child handshakes and the canonical set.mode/set.inflow/set.demand topics.'));
nodes.push(tab(TAB_SETUP, 'Setup',
'Deploy-time once-true injects that initialise control modes on the EVOLV nodes.'));
/* ---------- Process Plant tab ---------------------------------- */
nodes.push(comment('ps_int_title', TAB_PROC,
'PumpingStation - Integration\n' +
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
'L0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\n' +
'Pumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\n' +
'Cross-tab channels: setup:* drive once-true initialisation from the Setup tab.', 600, 40));
/* Link-ins on L0 receive from the Setup tab. */
const linInMode = linkIn('lin_setup_mode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 500, [], ['ps_int_station']);
const linInInflow = linkIn('lin_setup_inflow', TAB_PROC, 'setup:to-ps-inflow', LANE_X[0], 560, [], ['ps_int_station']);
const linInMgcMode = linkIn('lin_setup_mgcmode', TAB_PROC, 'setup:to-mgc-mode', LANE_X[0], 360, [], ['ps_int_mgc']);
nodes.push(linInMode, linInInflow, linInMgcMode);
/* L2: level measurement (Control Module). */
const levelMeas = measurementLevelNode('meas_level', TAB_PROC, 'Basin level sensor',
LANE_X[2], 700, [['ps_int_dbg_level'], [], ['ps_int_station']]);
nodes.push(levelMeas);
// Simulator measurement injector for the level sensor (push a varying level so PS sees something).
const levelInj = inject('ps_int_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num', LANE_X[0], 700, ['meas_level']);
nodes.push(levelInj);
/* L3: two rotatingMachine pumps (Equipment Module). */
const pumpA = rotatingMachineNode('pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
LANE_X[3], 320, [['ps_int_dbg_pa'], [], ['ps_int_mgc']]);
const pumpB = rotatingMachineNode('pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
LANE_X[3], 400, [['ps_int_dbg_pb'], [], ['ps_int_mgc']]);
nodes.push(pumpA, pumpB);
/* L4: MGC (Unit). */
const mgc = machineGroupControlNode('ps_int_mgc', TAB_PROC, 'Pump Group',
LANE_X[4], 360, [['ps_int_dbg_mgc'], [], ['ps_int_station']]);
nodes.push(mgc);
/* L5: pumpingStation (Process Cell). */
const station = pumpingStationNode('ps_int_station', TAB_PROC, 'Pumping Station',
LANE_X[5], 520, [['ps_int_format'], ['ps_int_dbg_influx'], []]);
nodes.push(station);
/* L6: formatter for the station's Port 0. */
const formatFn = fn('ps_int_format', TAB_PROC, 'Merge deltas + format',
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
"function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" +
"const vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\n" +
"msg.payload = {\n" +
" state: cache.state || 'unknown',\n" +
" controlMode: cache.controlMode || cache.mode || 'n/a',\n" +
" direction: cache.direction || 'n/a',\n" +
" percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n" +
" volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n" +
" volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n" +
" level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n" +
" inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n" +
" outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n" +
" childCount: cache.childCount != null ? cache.childCount : 'n/a'\n" +
"};\nreturn msg;",
LANE_X[6], 520, [['ps_int_dbg_process']]);
nodes.push(formatFn);
/* L7: debug taps for the various ports. */
nodes.push(debugNode('ps_int_dbg_process', TAB_PROC, 'PS Port 0: Process', LANE_X[7], 480, 'payload', 'msg', true));
nodes.push(debugNode('ps_int_dbg_influx', TAB_PROC, 'PS Port 1: InfluxDB', LANE_X[7], 540, 'true', 'full', false));
nodes.push(debugNode('ps_int_dbg_mgc', TAB_PROC, 'MGC Port 0', LANE_X[7], 360, 'payload', 'msg', true));
nodes.push(debugNode('ps_int_dbg_pa', TAB_PROC, 'Pump A Port 0', LANE_X[7], 320, 'payload', 'msg', false));
nodes.push(debugNode('ps_int_dbg_pb', TAB_PROC, 'Pump B Port 0', LANE_X[7], 400, 'payload', 'msg', false));
nodes.push(debugNode('ps_int_dbg_level', TAB_PROC, 'Level Port 0', LANE_X[7], 700, 'payload', 'msg', false));
/* Group boxes. */
const pumpAIds = ['pump_a', 'ps_int_dbg_pa'];
const pumpBIds = ['pump_b', 'ps_int_dbg_pb'];
const mgcIds = ['ps_int_mgc', 'ps_int_dbg_mgc', 'lin_setup_mgcmode'];
const stationIds = ['ps_int_station', 'ps_int_format', 'ps_int_dbg_process', 'ps_int_dbg_influx', 'lin_setup_mode', 'lin_setup_inflow'];
const levelIds = ['meas_level', 'ps_int_inj_level', 'ps_int_dbg_level'];
nodes.push(group('grp_pumpa', TAB_PROC, 'Pump A (EM)', S88.EM, pumpAIds, bboxOf(nodes, pumpAIds, 25)));
nodes.push(group('grp_pumpb', TAB_PROC, 'Pump B (EM)', S88.EM, pumpBIds, bboxOf(nodes, pumpBIds, 25)));
nodes.push(group('grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, mgcIds, bboxOf(nodes, mgcIds, 25)));
nodes.push(group('grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, stationIds, bboxOf(nodes, stationIds, 25)));
nodes.push(group('grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, levelIds, bboxOf(nodes, levelIds, 25)));
/* ---------- Setup tab ----------------------------------------- */
nodes.push(comment('setup_title', TAB_SETUP,
'Deploy-time setup\n' +
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
'Fires once after each deploy: pushes the canonical set.mode / set.inflow /\n' +
'set.demand topics across cross-tab channels into the Process Plant tab.',
LANE_X[2], 40));
const setMode = inject('setup_inj_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str', LANE_X[0], 160, ['lout_setup_mode'], { once: true, onceDelay: '0.5' });
const setMgc = inject('setup_inj_mgcmode', TAB_SETUP, 'MGC set.mode = auto', 'set.mode', 'auto', 'str', LANE_X[0], 220, ['lout_setup_mgcmode'],{ once: true, onceDelay: '0.5' });
const setInflow = inject('setup_inj_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num', LANE_X[0], 280, ['lout_setup_inflow'], { once: true, onceDelay: '1.0' });
nodes.push(setMode, setMgc, setInflow);
const loutMode = linkOut('lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_setup_mode']);
const loutMgcMode = linkOut('lout_setup_mgcmode', TAB_SETUP, 'setup:to-mgc-mode', LANE_X[7], 220, ['lin_setup_mgcmode']);
const loutInflow = linkOut('lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 280, ['lin_setup_inflow']);
nodes.push(loutMode, loutMgcMode, loutInflow);
// Setup tab group.
const setupIds = ['setup_inj_mode', 'setup_inj_mgcmode', 'setup_inj_inflow',
'lout_setup_mode', 'lout_setup_mgcmode', 'lout_setup_inflow'];
nodes.push(group('grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
return nodes;
}
/* ------------------------------------------------------------------ */
/* Tier 3 — 03-Dashboard.json */
/* ------------------------------------------------------------------ */
function buildDashboard() {
const TAB_PROC = 'ps_dash_proc';
const TAB_UI = 'ps_dash_ui';
const TAB_SETUP = 'ps_dash_setup';
const nodes = [];
nodes.push(tab(TAB_PROC, 'Process Plant',
'Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard.'));
nodes.push(tab(TAB_UI, 'Dashboard UI',
'FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders.'));
nodes.push(tab(TAB_SETUP, 'Setup',
'Once-true injects: initial mode + initial inflow seed.'));
/* ---------- FlowFuse dashboard scaffolding -------------------- */
nodes.push(uiBase('ps_dash_base'));
nodes.push(uiTheme('ps_dash_theme'));
nodes.push(uiPage('ps_dash_page', 'ps_dash_base', 'ps_dash_theme', 'PumpingStation Demo', '/pumping-station', 1));
nodes.push(uiGroup('ps_dash_grp_ctrl', 'ps_dash_page', 'Controls', 6, 1, 1));
nodes.push(uiGroup('ps_dash_grp_status', 'ps_dash_page', 'Operator Status', 6, 1, 2));
nodes.push(uiGroup('ps_dash_grp_trend', 'ps_dash_page', 'Live Trends', 12, 1, 3));
/* ---------- Process Plant tab --------------------------------- */
nodes.push(comment('ps_dash_proc_title', TAB_PROC,
'Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\n' +
'Events go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.',
600, 40));
/* L0 link-ins: setup + dashboard commands. */
const linModeProc = linkIn('lin_proc_mode', TAB_PROC, 'cmd:ps-mode', LANE_X[0], 480, [], ['ps_dash_station']);
const linDemandProc = linkIn('lin_proc_demand', TAB_PROC, 'cmd:ps-demand', LANE_X[0], 540, [], ['ps_dash_station']);
const linSetupMode = linkIn('lin_proc_setupmode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 420, [], ['ps_dash_station']);
const linSetupInflow= linkIn('lin_proc_setupinflow', TAB_PROC, 'setup:to-ps-inflow',LANE_X[0], 600, [], ['ps_dash_station']);
nodes.push(linModeProc, linDemandProc, linSetupMode, linSetupInflow);
/* L2 level sensor with simulator. */
const levelMeas = measurementLevelNode('ps_dash_meas_level', TAB_PROC, 'Basin level sensor',
LANE_X[2], 700, [[], [], ['ps_dash_station']]);
nodes.push(levelMeas);
nodes.push(inject('ps_dash_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num',
LANE_X[0], 700, ['ps_dash_meas_level']));
/* L3 pumps. */
const pumpA = rotatingMachineNode('ps_dash_pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
LANE_X[3], 320, [[], [], ['ps_dash_mgc']]);
const pumpB = rotatingMachineNode('ps_dash_pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
LANE_X[3], 400, [[], [], ['ps_dash_mgc']]);
nodes.push(pumpA, pumpB);
/* L4 MGC. */
const mgc = machineGroupControlNode('ps_dash_mgc', TAB_PROC, 'Pump Group',
LANE_X[4], 360, [[], [], ['ps_dash_station']]);
nodes.push(mgc);
/* L5 pumpingStation. */
const station = pumpingStationNode('ps_dash_station', TAB_PROC, 'Pumping Station',
LANE_X[5], 520, [['ps_dash_trend_split'], [], []]);
nodes.push(station);
/* L6 trend-split fn: one output per chart + one output for the status text widgets.
* Outputs:
* 0 -> chart_flow ({topic: 'Inflow', payload: m3/h}, {topic: 'Outflow', payload: m3/h})
* 1 -> chart_level ({topic: 'Level', payload: m})
* 2 -> chart_volpct ({topic: 'Volume%', payload: %})
* 3 -> text_status (compact state string)
* 4 -> text_perc (percControl)
* 5 -> text_direction (direction)
* 6 -> text_timetoempty(timeToEmpty)
*/
const trendCode =
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
"function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" +
"const flowIn = pick('flow.predicted.in');\n" +
"const flowOut = pick('flow.predicted.out');\n" +
"const level = pick('level.predicted.atequipment');\n" +
"const volPct = Number(cache.volumePercent);\n" +
"const ts = Date.now();\n" +
"const flowMsgs = [];\n" +
"if (flowIn != null) flowMsgs.push({ topic: 'Inflow', payload: flowIn * 3600, timestamp: ts });\n" +
"if (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\n" +
"const flowOut1 = flowMsgs.length ? flowMsgs : null;\n" +
"const levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\n" +
"const volOut = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\n" +
"const stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\n" +
"const percStr = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\n" +
"const dirStr = cache.direction || 'n/a';\n" +
"const tEmpty = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\n" +
"return [\n" +
" flowOut1,\n" +
" levelOut,\n" +
" volOut,\n" +
" { payload: stateStr },\n" +
" { payload: percStr },\n" +
" { payload: dirStr },\n" +
" { payload: tEmpty }\n" +
"];";
const trendSplit = fn('ps_dash_trend_split', TAB_PROC, 'Trend split + status', trendCode,
LANE_X[6], 520,
[
['lout_evt_flow'],
['lout_evt_level'],
['lout_evt_volpct'],
['lout_evt_state'],
['lout_evt_perc'],
['lout_evt_dir'],
['lout_evt_tempty'],
], 7);
nodes.push(trendSplit);
/* L7 link-outs into the Dashboard UI tab. */
const loutFlow = linkOut('lout_evt_flow', TAB_PROC, 'evt:flow', LANE_X[7], 420, ['lin_ui_flow']);
const loutLevel = linkOut('lout_evt_level', TAB_PROC, 'evt:level', LANE_X[7], 460, ['lin_ui_level']);
const loutVolPct = linkOut('lout_evt_volpct', TAB_PROC, 'evt:volpct', LANE_X[7], 500, ['lin_ui_volpct']);
const loutState = linkOut('lout_evt_state', TAB_PROC, 'evt:state', LANE_X[7], 540, ['lin_ui_state']);
const loutPerc = linkOut('lout_evt_perc', TAB_PROC, 'evt:perc', LANE_X[7], 580, ['lin_ui_perc']);
const loutDir = linkOut('lout_evt_dir', TAB_PROC, 'evt:dir', LANE_X[7], 620, ['lin_ui_dir']);
const loutTempty = linkOut('lout_evt_tempty', TAB_PROC, 'evt:tempty', LANE_X[7], 660, ['lin_ui_tempty']);
nodes.push(loutFlow, loutLevel, loutVolPct, loutState, loutPerc, loutDir, loutTempty);
/* Process tab groups. */
const procStationIds = ['ps_dash_station', 'ps_dash_trend_split',
'lin_proc_mode', 'lin_proc_demand', 'lin_proc_setupmode', 'lin_proc_setupinflow',
'lout_evt_flow', 'lout_evt_level', 'lout_evt_volpct', 'lout_evt_state', 'lout_evt_perc', 'lout_evt_dir', 'lout_evt_tempty'];
const procPumpAIds = ['ps_dash_pump_a'];
const procPumpBIds = ['ps_dash_pump_b'];
const procMgcIds = ['ps_dash_mgc'];
const procLevelIds = ['ps_dash_meas_level', 'ps_dash_inj_level'];
nodes.push(group('ps_dash_grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, procStationIds, bboxOf(nodes, procStationIds, 25)));
nodes.push(group('ps_dash_grp_pa', TAB_PROC, 'Pump A (EM)', S88.EM, procPumpAIds, bboxOf(nodes, procPumpAIds, 25)));
nodes.push(group('ps_dash_grp_pb', TAB_PROC, 'Pump B (EM)', S88.EM, procPumpBIds, bboxOf(nodes, procPumpBIds, 25)));
nodes.push(group('ps_dash_grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, procMgcIds, bboxOf(nodes, procMgcIds, 25)));
nodes.push(group('ps_dash_grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, procLevelIds, bboxOf(nodes, procLevelIds, 25)));
/* ---------- Dashboard UI tab ---------------------------------- */
nodes.push(comment('ps_dash_ui_title', TAB_UI,
'Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\n' +
'Sliders on L2 emit cmd:* back to Process Plant.\n' +
'Charts use the trend-split pattern: one chart per metric, series labelled by msg.topic.',
600, 40));
/* L0 link-ins from the process side. */
nodes.push(linkIn('lin_ui_flow', TAB_UI, 'evt:flow', LANE_X[0], 220, [], ['ui_chart_flow']));
nodes.push(linkIn('lin_ui_level', TAB_UI, 'evt:level', LANE_X[0], 320, [], ['ui_chart_level']));
nodes.push(linkIn('lin_ui_volpct', TAB_UI, 'evt:volpct', LANE_X[0], 420, [], ['ui_chart_volpct']));
nodes.push(linkIn('lin_ui_state', TAB_UI, 'evt:state', LANE_X[0], 520, [], ['ui_text_state']));
nodes.push(linkIn('lin_ui_perc', TAB_UI, 'evt:perc', LANE_X[0], 560, [], ['ui_text_perc']));
nodes.push(linkIn('lin_ui_dir', TAB_UI, 'evt:dir', LANE_X[0], 600, [], ['ui_text_dir']));
nodes.push(linkIn('lin_ui_tempty', TAB_UI, 'evt:tempty', LANE_X[0], 640, [], ['ui_text_tempty']));
/* L4 charts and text widgets. */
nodes.push(uiChart('ui_chart_flow', TAB_UI, 'ps_dash_grp_trend', 'Flow trend', 'Flow (m³/h)', 1, 'm³/h', LANE_X[4], 220));
nodes.push(uiChart('ui_chart_level', TAB_UI, 'ps_dash_grp_trend', 'Level trend', 'Level (m)', 2, 'm', LANE_X[4], 320));
nodes.push(uiChart('ui_chart_volpct', TAB_UI, 'ps_dash_grp_trend', 'Volume %', 'Volume (%)', 3, '%', LANE_X[4], 420));
nodes.push(uiText( 'ui_text_state', TAB_UI, 'ps_dash_grp_status','State', 'Station state',1, LANE_X[4], 520));
nodes.push(uiText( 'ui_text_perc', TAB_UI, 'ps_dash_grp_status','percControl', 'Control %', 2, LANE_X[4], 560));
nodes.push(uiText( 'ui_text_dir', TAB_UI, 'ps_dash_grp_status','direction', 'Direction', 3, LANE_X[4], 600));
nodes.push(uiText( 'ui_text_tempty', TAB_UI, 'ps_dash_grp_status','timeToEmpty', 'Time to empty',4, LANE_X[4], 640));
/* L2 controls: dropdown for mode + slider for demand. */
const modeDropdown = uiDropdown('ui_dd_mode', TAB_UI, 'ps_dash_grp_ctrl',
'Mode', 'Control mode', 1, LANE_X[2], 160, 'set.mode',
['manual', 'levelbased', 'flowbased', 'none'], ['ui_wrap_mode']);
const demandSlider = uiSlider('ui_sl_demand', TAB_UI, 'ps_dash_grp_ctrl',
'Demand', 'Manual demand (m³/h)', 2, LANE_X[2], 220, 'set.demand', 0, 200, 5);
nodes.push(modeDropdown, demandSlider);
// Slider wires need explicit wiring (uiSlider helper leaves wires empty so we set them post-creation).
demandSlider.wires = [['ui_wrap_demand']];
/* L4 wrappers: enforce the canonical topic on the outgoing msg. */
const wrapMode = fn('ui_wrap_mode', TAB_UI, 'topic=set.mode',
"msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;",
LANE_X[4], 160, [['lout_cmd_mode']]);
const wrapDemand = fn('ui_wrap_demand', TAB_UI, 'topic=set.demand',
"msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
LANE_X[4], 220, [['lout_cmd_demand']]);
nodes.push(wrapMode, wrapDemand);
/* L7 link-outs to the process plant. */
nodes.push(linkOut('lout_cmd_mode', TAB_UI, 'cmd:ps-mode', LANE_X[7], 160, ['lin_proc_mode']));
nodes.push(linkOut('lout_cmd_demand', TAB_UI, 'cmd:ps-demand', LANE_X[7], 220, ['lin_proc_demand']));
/* UI tab groups (mirror the dashboard groups). */
const uiCtrlIds = ['ui_dd_mode', 'ui_sl_demand', 'ui_wrap_mode', 'ui_wrap_demand',
'lout_cmd_mode', 'lout_cmd_demand'];
const uiStatusIds = ['ui_text_state', 'ui_text_perc', 'ui_text_dir', 'ui_text_tempty',
'lin_ui_state', 'lin_ui_perc', 'lin_ui_dir', 'lin_ui_tempty'];
const uiTrendIds = ['ui_chart_flow', 'ui_chart_level', 'ui_chart_volpct',
'lin_ui_flow', 'lin_ui_level', 'lin_ui_volpct'];
nodes.push(group('grp_ui_ctrl', TAB_UI, 'Controls (PC)', S88.PC, uiCtrlIds, bboxOf(nodes, uiCtrlIds, 25)));
nodes.push(group('grp_ui_status', TAB_UI, 'Operator status (PC)', S88.PC, uiStatusIds, bboxOf(nodes, uiStatusIds, 25)));
nodes.push(group('grp_ui_trend', TAB_UI, 'Live trends (PC)', S88.PC, uiTrendIds, bboxOf(nodes, uiTrendIds, 25)));
/* ---------- Setup tab ----------------------------------------- */
nodes.push(comment('ps_dash_setup_title', TAB_SETUP, 'Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\n' +
'Initialises set.mode = levelbased and seeds an inflow at deploy time.',
LANE_X[2], 40));
nodes.push(inject('ps_dash_setup_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str',
LANE_X[0], 160, ['ps_dash_lout_setup_mode'], { once: true, onceDelay: '0.5' }));
nodes.push(inject('ps_dash_setup_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num',
LANE_X[0], 220, ['ps_dash_lout_setup_inflow'], { once: true, onceDelay: '1.0' }));
nodes.push(linkOut('ps_dash_lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_proc_setupmode']));
nodes.push(linkOut('ps_dash_lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 220, ['lin_proc_setupinflow']));
const setupIds = ['ps_dash_setup_mode', 'ps_dash_setup_inflow',
'ps_dash_lout_setup_mode', 'ps_dash_lout_setup_inflow'];
nodes.push(group('ps_dash_grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
return nodes;
}
/* ------------------------------------------------------------------ */
/* README */
/* ------------------------------------------------------------------ */
const README = `# pumpingStation - Example Flows
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
canonical topic API (\`set.mode\`, \`set.inflow\`, \`set.demand\`,
\`cmd.calibrate.volume\`, \`cmd.calibrate.level\`). Legacy aliases
(\`changemode\`, \`q_in\`, \`Qd\`, \`calibratePredictedVolume\`,
\`calibratePredictedLevel\`, \`registerChild\`) still work but log a
one-time deprecation warning; these fresh flows use the canonical names only.
## Files
| File | Tier | Tabs | Purpose |
|---|---|---|---|
| \`01-Basic.json\` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
| \`02-Integration.json\` | 2 | Process Plant + Setup | Adds a \`measurement\` level child and a \`machineGroupControl\` parent with two \`rotatingMachine\` pumps. Demonstrates the Phase-2 parent/child handshake. |
| \`03-Dashboard.json\` | 3 | Process Plant + Dashboard UI + Setup | Tier 2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). |
## Prerequisites
- Node-RED with the EVOLV package installed (so the \`pumpingStation\`,
\`measurement\`, \`machineGroupControl\`, and \`rotatingMachine\` node
types are registered).
- For \`03-Dashboard.json\`: \`@flowfuse/node-red-dashboard\` (Dashboard 2.0).
## How to load
\`\`\`bash
# Drop a file into a running Node-RED instance using its Admin API.
curl -X POST -H 'Content-Type: application/json' \\
--data @nodes/pumpingStation/examples/01-Basic.json \\
http://localhost:1880/flows
\`\`\`
Or in the editor: **Menu -> Import -> select file -> Import**. The flows
import into their own tabs and can be deployed immediately.
## 01-Basic - what to try
1. Deploy.
2. Inject \`set.mode = manual\`.
3. Inject \`set.inflow = 60 m3/h\` - the basin starts filling. Watch the
formatted Port 0 payload in the debug sidebar.
4. Inject \`set.demand = 40 %\` - in manual mode this would feed any
registered children; here there are no pump children so it is logged
and shown on Port 0.
5. Inject \`cmd.calibrate.volume = 25 m3\` to jump the predicted-volume
integrator to half-full.
## 02-Integration - what to try
1. Deploy. The Setup tab fires \`set.mode = levelbased\` to the station
and \`set.mode = auto\` to the MGC.
2. The two pumps register with the MGC via Port 2; the MGC and the level
sensor register with the station via Port 2. Watch the registration
debug taps to confirm.
3. The level inject pushes a 1.6 m measurement so the station sees a
non-zero starting level. Setup also seeds \`set.inflow = 60 m3/h\`.
4. The station's \`controlMode = levelbased\` then drives the MGC, which
dispatches to Pump A / Pump B.
## 03-Dashboard - what to try
1. Deploy.
2. Open the dashboard at \`http://localhost:1880/dashboard/page/pumping-station\`.
3. Use the **Control mode** dropdown to switch between \`manual\`,
\`levelbased\`, \`flowbased\`, \`none\`.
4. In manual mode, drag the **Manual demand** slider - the demand cascades
to the MGC and on to the pumps.
5. The three charts (flow, level, volume %) plot live data; the four text
widgets show state, percControl, direction, and time-to-empty.
## Layout conventions
These flows follow the EVOLV layout rule set in
\`.claude/rules/node-red-flow-layout.md\`:
- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
(\`ui-*\` widgets) / Setup (once-true injects).
- Cross-tab wiring via **named link out / link in channels**:
\`setup:to-ps-mode\`, \`setup:to-ps-inflow\`, \`setup:to-mgc-mode\`,
\`cmd:ps-mode\`, \`cmd:ps-demand\`, \`evt:flow\`, \`evt:level\`,
\`evt:volpct\`, \`evt:state\`, \`evt:perc\`, \`evt:dir\`, \`evt:tempty\`.
- **Lane positions** L0-L7 = \`[120, 360, 600, 840, 1080, 1320, 1560, 1800]\`,
driven by each node's S88 level (Process Cell on L5, Unit on L4,
Equipment on L3, Control Module on L2).
- **Group boxes** wrap each parent + its direct children, coloured by the
parent's S88 level.
## Regenerating
These flows are generated from \`tools/build-examples.js\`. Edit the
generator, never the JSON, then:
\`\`\`bash
node nodes/pumpingStation/tools/build-examples.js
\`\`\`
The script writes \`01-Basic.json\`, \`02-Integration.json\`, and
\`03-Dashboard.json\` into this directory.
`;
/* ------------------------------------------------------------------ */
/* Main */
/* ------------------------------------------------------------------ */
function writeFlow(filename, builder) {
const flow = builder();
const dest = path.join(OUT_DIR, filename);
fs.writeFileSync(dest, JSON.stringify(flow, null, 2) + '\n', 'utf8');
console.log(`wrote ${dest} (${flow.length} nodes)`);
}
function main() {
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
writeFlow('01-Basic.json', buildBasic);
writeFlow('02-Integration.json', buildIntegration);
writeFlow('03-Dashboard.json', buildDashboard);
fs.writeFileSync(path.join(OUT_DIR, 'README.md'), README, 'utf8');
console.log(`wrote ${path.join(OUT_DIR, 'README.md')}`);
}
main();

View File

@@ -1,6 +1,6 @@
# Reference &mdash; Contracts # Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-b825ac1-blue) ![autogen](https://img.shields.io/badge/sections-autogenerated-orange) ![code-ref](https://img.shields.io/badge/code--ref-a83a85e-blue) ![autogen](https://img.shields.io/badge/sections-autogenerated-orange)
> [!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`** &mdash; 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`** &mdash; do not hand-edit between the `BEGIN AUTOGEN` / `END AUTOGEN` markers. Source of truth for everything on this page: the node's `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/pumpingStation.json`.
@@ -11,7 +11,11 @@
## Topic contract ## Topic contract
The **Unit** column reflects each descriptor's `units: { measure, default }` declaration. The default unit is what the commandRegistry coerces incoming `msg.unit` values to before the handler runs. The **Unit** column reflects each descriptor's declared unit (via the `unit: 'm3/h'` shorthand or the legacy `units: { measure, default }`; the measure is derived from the unit). The default unit is what the commandRegistry coerces incoming values to before the handler runs.
**Command envelope (all EVOLV nodes).** Every command shares one envelope on top of `msg.topic`:
- **Value + unit** — send `msg.payload` as a number (with optional sibling `msg.unit`) **or** as `{ value, unit }`. The registry always converts the value to the descriptor's unit before the handler; numeric strings are converted too. A missing unit assumes the descriptor default.
- **`msg.origin`** — the control authority that issued the command: `parent` (automation/parent controller, the default), `GUI` (SCADA/HMI operator), or `fysical` (physical buttons). On nodes with a control mode, the mode's `allowedSources` decides which origins are accepted; releasing control is done by changing the mode.
<!-- BEGIN AUTOGEN: topic-contract --> <!-- BEGIN AUTOGEN: topic-contract -->
@@ -27,6 +31,48 @@ The **Unit** column reflects each descriptor's `units: { measure, default }` dec
<!-- 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 &mdash; 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 &mdash; `getOutput()` shape ## Data model &mdash; `getOutput()` shape
@@ -39,35 +85,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` &mdash; 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` &mdash; 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` &mdash; string, the active control strategy (`levelbased` / `manual` / `flowbased` / `none`). Echoes the most recent `set.mode` input.
> - `manualDemand` &mdash; 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 &mdash; 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 &mdash; editor form to config keys ## Configuration schema &mdash; editor form to config keys