11 Commits

Author SHA1 Message Date
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
znetsixe
2d68a4f504 test: rewire integration test to renamed 02-Dashboard.json
Example flows were renamed to the numbered-tier convention
(02-Dashboard.json). The integration test still loaded the old
basic-dashboard.flow.json and asserted the old 6-output parser shape
+ raw-number payloads. Update both the filename and the assertions
to match the current 14-output fn_status_split (topic labels like
'Level', payload strings like '3.25 m').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:30:02 +02:00
znetsixe
a3536b7b7f fix(level): pass timestamp on level samples for level-rate fallback
MeasurementRouter.onLevelMeasurement was writing level samples via
.value(value).unit(context.unit), which dropped the timestamp. The
level-rate fallback in FlowAggregator derives netFlow from dlevel/dt,
so without a timestamp on each sample it had nothing to differentiate.

Switch to the positional .value(value, timestamp, unit) form so the
fallback works. Add a basic test that drives two level samples 2 s
apart and asserts the aggregator produces direction=filling with a
finite dlevel/dt-derived netFlow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:29:56 +02:00
znetsixe
f5c6282478 refactor(units): use UnitPolicy.convert instead of hardcoded m3/h<->m3/s scalars
Replace the M3H_TO_M3S constant in control/manual.js and the `* 3600`
inline conversion in the status badge with this.unitPolicy.convert
calls. Expose unitPolicy on the frozen control context so manual
strategies pick it up without reaching into host. Matches the
contract direction in .claude/refactor/CONTRACTS.md §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:43:35 +02:00
16 changed files with 607 additions and 91 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
`'influxdb'` formatter.
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'registerChild', payload: <node.id>, positionVsParent, distance }`
to the upstream parent.
`{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
to the upstream parent (`child.register` is canonical; `registerChild` is the
deprecated *input* alias, not what this node emits).
## Events emitted by `source.measurements.emitter`

View File

@@ -166,8 +166,8 @@
"id": "b30af582f935bcb7",
"type": "comment",
"z": "77f00aef1c966167",
"name": "PumpingStation Dashboard (Tier 2)",
"info": "Same command surface as the Basic example, driven by a FlowFuse dashboard.\n\nOpen /dashboard/pumpingstation-basic after deploy.\n\nCONTROLS panel\n- Mode buttons set.mode (manual / levelbased)\n- Inflow / Outflow buttons set.inflow / set.outflow (60 / 80 m³/h)\n- Demand button set.demand (40 m³/h, manual mode only)\n- Calibrate buttons cmd.calibrate.volume / cmd.calibrate.level\n\nSTATUS panel\n- 7 text rows: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand\n\nTRENDS panel\n- 4 charts: Level (m), Volume (m³), Volume %, Flow (in/out/net m³/h)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest Port 0 cache (sorted). Shows every field the node emits including basin geometry, safety thresholds, predicted overflow/underflow.\n\nThe fan-out function caches last-known values so delta-only Port 0 updates never blank a row.",
"name": "PumpingStation \u2014 Dashboard (Tier 2)",
"info": "Same command surface as the Basic example, driven by a FlowFuse dashboard.\n\nOpen /dashboard/pumpingstation-basic after deploy.\n\nCONTROLS panel\n- Mode buttons \u2192 set.mode (manual / levelbased)\n- Inflow / Outflow buttons \u2192 set.inflow / set.outflow (60 / 80 m\u00b3/h)\n- Demand button \u2192 set.demand (40 m\u00b3/h, manual mode only)\n- Calibrate buttons \u2192 cmd.calibrate.volume / cmd.calibrate.level\n\nSTATUS panel\n- 7 text rows: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand\n\nTRENDS panel\n- 4 charts: Level (m), Volume (m\u00b3), Volume %, Flow (in/out/net m\u00b3/h)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest Port 0 cache (sorted). Shows every field the node emits including basin geometry, safety thresholds, predicted overflow/underflow.\n\nThe fan-out function caches last-known values so delta-only Port 0 updates never blank a row.",
"x": 660,
"y": 320,
"wires": []
@@ -332,13 +332,13 @@
"z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7",
"group": "ui_group_ctrl",
"name": "Inflow 60 m³/h",
"label": "Inflow 60 m³/h",
"name": "Inflow 60 m\u00b3/h",
"label": "Inflow 60 m\u00b3/h",
"order": 3,
"width": "3",
"height": "1",
"emulateClick": false,
"tooltip": "Push a measured inflow of 60 m³/h into the basin balance",
"tooltip": "Push a measured inflow of 60 m\u00b3/h into the basin balance",
"color": "",
"bgcolor": "",
"icon": "south",
@@ -360,13 +360,13 @@
"z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7",
"group": "ui_group_ctrl",
"name": "Outflow 80 m³/h",
"label": "Outflow 80 m³/h",
"name": "Outflow 80 m\u00b3/h",
"label": "Outflow 80 m\u00b3/h",
"order": 4,
"width": "3",
"height": "1",
"emulateClick": false,
"tooltip": "Push a measured outflow of 80 m³/h into the basin balance",
"tooltip": "Push a measured outflow of 80 m\u00b3/h into the basin balance",
"color": "",
"bgcolor": "",
"icon": "north",
@@ -388,13 +388,13 @@
"z": "77f00aef1c966167",
"g": "42bf82c87d05f498",
"group": "ui_group_ctrl",
"name": "Demand 40 m³/h",
"label": "Demand 40 m³/h (manual)",
"name": "Demand 40 m\u00b3/h",
"label": "Demand 40 m\u00b3/h (manual)",
"order": 5,
"width": "6",
"height": "1",
"emulateClick": false,
"tooltip": "Operator outflow demand only forwarded when mode = manual",
"tooltip": "Operator outflow demand \u2014 only forwarded when mode = manual",
"color": "",
"bgcolor": "",
"icon": "speed",
@@ -416,13 +416,13 @@
"z": "77f00aef1c966167",
"g": "234bdce20170061a",
"group": "ui_group_ctrl",
"name": "Calibrate V=25 m³",
"label": "Calibrate V = 25 m³",
"name": "Calibrate V=25 m\u00b3",
"label": "Calibrate V = 25 m\u00b3",
"order": 6,
"width": "3",
"height": "1",
"emulateClick": false,
"tooltip": "Snap the predicted-volume integrator to 25 m³",
"tooltip": "Snap the predicted-volume integrator to 25 m\u00b3",
"color": "",
"bgcolor": "",
"icon": "tune",
@@ -472,8 +472,8 @@
"z": "77f00aef1c966167",
"g": "grp_status_panel",
"name": "fan-out Port 0 (status + charts + raw)",
"func": "// Port 0 emits delta-only cache last-known so deltas never blank a row.\n// Keys with dots use the runtime childId (= node id), so we pattern-match\n// by prefix rather than hardcoding.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\nconst findByPrefix = (prefix) => {\n for (const k of Object.keys(cache)) if (k.startsWith(prefix)) return cache[k];\n return null;\n};\nconst num = (v, dp, unit) => {\n const n = +v;\n if (!Number.isFinite(n)) return '';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\n\nconst level = findByPrefix('level.predicted.atequipment.');\nconst volume = findByPrefix('volume.predicted.atequipment.');\nconst volPct = findByPrefix('volumePercent.predicted.atequipment.');\nconst qInS = findByPrefix('flow.predicted.in.');\nconst qOutS = findByPrefix('flow.predicted.out.');\nconst qNetS = findByPrefix('netFlowRate.predicted.atequipment.');\nconst qInH = Number.isFinite(+qInS) ? +qInS * 3600 : null;\nconst qOutH = Number.isFinite(+qOutS) ? +qOutS * 3600 : null;\nconst qNetH = Number.isFinite(+qNetS) ? +qNetS * 3600 : null;\nconst pct = cache.percControl;\nconst dem = cache.manualDemand;\nconst mode = cache.mode || '';\nconst dir = cache.direction || '';\n\nconst chart = (topic, v) => Number.isFinite(+v) ? { topic, payload: +v } : null;\n\n// Raw view: every cached key, sorted, with values prettified for display.\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 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",
"outputs": 14,
"func": "// Port 0 emits delta-only \u2014 cache last-known so deltas never blank a row.\n// Keys with dots use the runtime childId (= node id), so we pattern-match\n// by prefix rather than hardcoding.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\nconst findByPrefix = (prefix) => {\n for (const k of Object.keys(cache)) if (k.startsWith(prefix)) return cache[k];\n return null;\n};\nconst num = (v, dp, unit) => {\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\n\nconst level = findByPrefix('level.predicted.atequipment.');\nconst volume = findByPrefix('volume.predicted.atequipment.');\nconst volPct = findByPrefix('volumePercent.predicted.atequipment.');\nconst qInS = findByPrefix('flow.predicted.in.');\nconst qOutS = findByPrefix('flow.predicted.out.');\nconst qNetS = findByPrefix('netFlowRate.predicted.atequipment.');\nconst qInH = Number.isFinite(+qInS) ? +qInS * 3600 : null;\nconst qOutH = Number.isFinite(+qOutS) ? +qOutS * 3600 : null;\nconst qNetH = Number.isFinite(+qNetS) ? +qNetS * 3600 : null;\nconst pct = cache.percControl;\nconst dem = cache.manualDemand;\nconst mode = cache.mode || '\u2014';\nconst dir = cache.direction || '\u2014';\n\nconst chart = (topic, v) => Number.isFinite(+v) ? { topic, payload: +v } : null;\n\n// Raw view: every cached key, sorted, with values prettified for display.\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '\u2014';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0\u20136: status text widgets\n { payload: mode },\n { payload: dir },\n { payload: num(level, 2, 'm') },\n { payload: num(volume, 2, 'm\u00b3') },\n { payload: num(volPct, 2, '%') },\n { payload: num(pct, 1, '%') },\n { payload: mode === 'manual'\n ? (Number.isFinite(+dem) ? num(dem, 1, 'm\u00b3/h') : 'not set')\n : '\u2014' },\n // 7\u20139: single-series charts\n chart('Level', level),\n chart('Volume', volume),\n chart('Volume %', volPct),\n // 10\u201312: flow chart (three series share the same chart node)\n chart('Inflow', qInH),\n chart('Outflow', qOutH),\n chart('Net', qNetH),\n // 13: raw key/value rows for the ui-template\n { payload: rawRows },\n // 14: percControl chart\n chart('percControl', pct),\n];\n",
"outputs": 15,
"timeout": 0,
"noerr": 0,
"initialize": "",
@@ -523,6 +523,9 @@
],
[
"ui_tpl_raw"
],
[
"ui_chart_pumping_perccontrol"
]
]
},
@@ -740,8 +743,8 @@
"z": "77f00aef1c966167",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Volume (m³)",
"label": "Volume (m³)",
"name": "Volume (m\u00b3)",
"label": "Volume (m\u00b3)",
"order": 2,
"width": 6,
"height": 4,
@@ -754,7 +757,7 @@
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisLabel": "m³",
"yAxisLabel": "m\u00b3",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
@@ -862,8 +865,8 @@
"z": "77f00aef1c966167",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Flow (m³/h)",
"label": "Flow (m³/h) — Inflow / Outflow / Net",
"name": "Flow (m\u00b3/h)",
"label": "Flow (m\u00b3/h) \u2014 Inflow / Outflow / Net",
"order": 4,
"width": 6,
"height": 4,
@@ -876,7 +879,7 @@
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisLabel": "m³/h",
"yAxisLabel": "m\u00b3/h",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
@@ -1029,7 +1032,7 @@
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": "",
"controlMode": "levelbased",
@@ -1066,5 +1069,68 @@
"modules": {
"EVOLV": "1.0.29"
}
},
{
"id": "ui_chart_pumping_perccontrol",
"type": "ui-chart",
"z": "77f00aef1c966167",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "percControl",
"label": "percControl (%) \u2014 pumping-station demand",
"order": 5,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisLabel": "%",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "0",
"ymax": "100",
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"showLegend": false,
"className": "",
"colors": [
"#A347E1",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#0095FF",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"x": 1240,
"y": 560,
"wires": [
[]
]
}
]
]

View File

@@ -253,6 +253,26 @@ async function run(ctx, controlState, direction) {
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
);
// We are past every off-gate, so the station is engaged and the computed
// demand is meant to drive pumps. If no machine group is registered the
// demand has nowhere to go and the pumps stay silent — the signature of a
// dropped Port 2 parent↔group registration (e.g. after a partial redeploy
// that recreated this node). Warn once until a group reappears so the
// failure isn't invisible.
const groupCount = machineGroups ? Object.keys(machineGroups).length : 0;
if (groupCount === 0) {
if (host && !host._warnedNoMachineGroup) {
logger?.warn?.(
`Level-based control engaged (demand ${percControl.toFixed(1)} %) but no machine group is registered — `
+ `pumps cannot be driven. The parent↔group registration was likely lost on a partial redeploy; `
+ `redeploy/restart fully to re-run the Port 2 registration handshake.`
);
host._warnedNoMachineGroup = true;
}
} else if (host) {
host._warnedNoMachineGroup = false;
}
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
}

View File

@@ -4,13 +4,14 @@ async function run() {
}
async function forwardDemand(ctx, demand) {
const { machineGroups, machines, logger } = ctx;
const { machineGroups, machines, unitPolicy, logger } = ctx;
logger?.info?.(`Manual demand forwarded: ${demand}`);
if (machineGroups && Object.keys(machineGroups).length > 0) {
const groupDemand = unitPolicy.convert(demand, 'm3/h', 'm3/s', 'manual demand to machineGroups');
await Promise.all(
Object.values(machineGroups).map((group) =>
group.handleInput('parent', demand).catch((err) => {
group.handleInput('parent', groupDemand).catch((err) => {
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
})
)
@@ -27,6 +28,18 @@ async function forwardDemand(ctx, demand) {
}
}
}
// Neither a group nor a direct machine is registered, so the operator's
// demand silently goes nowhere. Surface it — the usual cause is a dropped
// Port 2 parent↔child registration after a partial redeploy.
const noGroups = !machineGroups || Object.keys(machineGroups).length === 0;
const noMachines = !machines || Object.keys(machines).length === 0;
if (noGroups && noMachines) {
logger?.warn?.(
`Manual demand ${demand} not forwarded — no machine group or machine is registered to this pumping station. `
+ `Check the parent↔child Port 2 registration (redeploy/restart fully to restore it).`
);
}
}
module.exports = {

View File

@@ -11,10 +11,13 @@
return Number.isFinite(v) ? v : null;
};
// Set a numeric input's value, or blank if not finite.
// Set a numeric input's value, or blank if not finite. Accepts numeric
// strings (Node-RED's auto-form-binding stores form values as strings).
ns.setNumberField = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = Number.isFinite(val) ? val : '';
if (!el) return;
const num = typeof val === 'number' ? val : parseFloat(val);
el.value = Number.isFinite(num) ? num : '';
};
// Add input + change listeners to a list of node-input-* ids.

View File

@@ -68,11 +68,14 @@
ns.setNumberField('node-input-stopLevel', node.stopLevel);
// holdLevel defaults to startLevel when omitted (no hold band). Show
// the saved value if there is one; otherwise mirror startLevel so the
// user immediately sees the "no hold band" baseline.
// user immediately sees the "no hold band" baseline. Coerce to Number
// because Node-RED form-bind stores numeric inputs as strings.
const holdNum = parseFloat(node.holdLevel);
ns.setNumberField('node-input-holdLevel',
Number.isFinite(node.holdLevel) ? node.holdLevel : node.startLevel);
Number.isFinite(holdNum) ? holdNum : node.startLevel);
const deadZoneNum = parseFloat(node.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-logCurveFactor', node.logCurveFactor);
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);

View File

@@ -50,6 +50,15 @@
node.logCurveFactor = parseNum('node-input-logCurveFactor');
node.startLevel = parseNum('node-input-startLevel');
node.maxLevel = parseNum('node-input-maxLevel');
// Persist as numbers — Node-RED's auto-form-binding would store these as
// strings, and oneditprepare's setNumberField rejects non-Number values,
// so the input would blank out on reopen.
const stopLevelVal = parseNum('node-input-stopLevel');
node.stopLevel = Number.isFinite(stopLevelVal) ? stopLevelVal : null;
const holdLevelVal = parseNum('node-input-holdLevel');
if (Number.isFinite(holdLevelVal)) node.holdLevel = holdLevelVal;
const deadZoneVal = parseNum('node-input-deadZoneKeepAlivePercent');
if (Number.isFinite(deadZoneVal)) node.deadZoneKeepAlivePercent = deadZoneVal;
// minLevel is no longer a user input — it's the derived dryRunLevel
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
// uses node.minLevel as the unconditional STOP threshold; we set it

View File

@@ -34,7 +34,7 @@ class MeasurementRouter {
onLevelMeasurement(position, value, context = {}) {
this.measurements.type('level').variant('measured').position(position)
.value(value).unit(context.unit);
.value(value, context.timestamp, context.unit);
const series = this.measurements.type('level').variant('measured').position(position);
const levelMeters = series.getCurrentValue('m');

View File

@@ -18,15 +18,21 @@ class PumpingStation extends BaseDomain {
static name = 'pumpingStation';
// Internal math runs in m3/s for flow and m for level so the volume
// integrator (flow × dt) is unit-consistent. Strict canonicals make
// unit drift in child-fed measurements an explicit error.
// integrator (flow × dt) is unit-consistent canonical stays m3/s, the
// platform-wide convention every cross-node consumer (MGC demand math,
// physics-sanity) assumes. Strict canonicals make unit drift in child-fed
// measurements an explicit error.
// Output flow / netFlowRate are emitted in m3/h so telemetry/dashboard
// series land on the same axis as the rest of the pump group (verified
// slice #47); the m3/s→m3/h presentation conversion happens at the output
// boundary only — it never touches the canonical integrator basis.
// overflowVolume / underflowVolume are listed in output so the
// MeasurementContainer keeps the integrator's m³ unit on those streams
// (FlowAggregator writes spill / underflow per tick).
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: {
flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3',
flow: 'm3/h', netFlowRate: 'm3/h', level: 'm', volume: 'm3',
overflowVolume: 'm3', underflowVolume: 'm3',
},
requireUnitForTypes: [],
@@ -146,6 +152,7 @@ class PumpingStation extends BaseDomain {
levelVariants: this.levelVariants,
volVariants: this.volVariants,
flowThreshold: this.flowThreshold,
unitPolicy: this.unitPolicy,
host: this,
};
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
@@ -262,7 +269,7 @@ class PumpingStation extends BaseDomain {
};
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
const netFlowM3h = (this.state?.netFlow ?? 0) * 3600;
const netFlowM3h = this.unitPolicy.convert(this.state?.netFlow ?? 0, 'm3/s', 'm3/h', 'status badge netFlow');
const mode = this.mode || '?';
const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
@@ -285,14 +292,32 @@ class PumpingStation extends BaseDomain {
const measurementType = child.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
child.measurements.emitter.on(eventName, (eventData = {}) => {
const handle = (eventData = {}) => {
this.logger.debug(
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
);
if (measurementType === 'level') {
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
return;
}
this.measurements.type(measurementType).variant('measured').position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
});
};
child.measurements.emitter.on(eventName, handle);
// Seed from the child's current value. The emitter only delivers FUTURE
// updates, so a parent that registers after the child already emitted
// (e.g. a once-only inject that fired during startup before this
// subscription existed) would otherwise never see that value. Replaying
// the last sample makes a late subscriber pick up the present state.
const series = child.measurements
.type(measurementType).variant('measured').position(position).get?.();
const sample = series?.getLaggedSample?.(0);
if (sample && sample.value != null) {
handle({ ...sample, childName: child.config.general.name });
}
}
_subscribePredictedFlow(child) {

101
test/_output-manifest.md Normal file
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);
}
});
// 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

@@ -4,8 +4,15 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { UnitPolicy } = require('generalFunctions');
const manual = require('../../src/control/manual');
const unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s' },
output: { flow: 'm3/s' },
requireUnitForTypes: [],
});
function makeGroup(name) {
const calls = { handleInput: [] };
return {
@@ -28,15 +35,15 @@ function makeLogger() {
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
}
test('forwardDemand calls handleInput("parent", demand) on every machine group', async () => {
test('forwardDemand calls handleInput("parent", canonical m3/s demand) on every machine group', async () => {
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
await manual.forwardDemand(ctx, 50);
await manual.forwardDemand(ctx, 360);
for (const g of Object.values(groups)) {
assert.equal(g._calls.handleInput.length, 1);
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
assert.deepEqual(g._calls.handleInput[0], ['parent', 0.1]);
}
});
@@ -54,7 +61,7 @@ test('forwardDemand with no machineGroups but direct machines splits demand even
test('run() is a no-op (manual mode is event-driven)', async () => {
const groups = { a: makeGroup('A') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
await manual.run(ctx, { percControl: 0 });
assert.equal(groups.a._calls.handleInput.length, 0);
});

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

@@ -4,6 +4,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer } = require('generalFunctions');
const PumpingStation = require('../../src/specificClass');
// machineGroups is a registry-backed getter (declareChildGetter) — direct
@@ -84,6 +85,39 @@ function makeConfig(overrides = {}) {
return base;
}
function makeMeasurementChild({ type = 'level', position = 'atequipment', name = 'child-level' } = {}) {
return {
config: {
general: { id: name, name },
functionality: { positionVsParent: position },
asset: { type },
},
measurements: new MeasurementContainer({
autoConvert: true,
preferredUnits: { level: 'm', flow: 'm3/s', pressure: 'Pa' },
}),
};
}
test('level child subscription records one sample per event for level-rate fallback', async () => {
const ps = new PumpingStation(makeConfig());
const child = makeMeasurementChild();
ps._subscribeMeasurement(child);
child.measurements.type('level').variant('measured').position('atequipment')
.value(1.0, 1000, 'm');
child.measurements.type('level').variant('measured').position('atequipment')
.value(1.1, 3000, 'm');
const series = ps.measurements.type('level').variant('measured').position('atequipment').get();
assert.deepEqual(series.values, [1.0, 1.1]);
const net = ps.flowAggregator.selectBestNetFlow();
assert.equal(net.source, 'level:measured');
assert.equal(net.direction, 'filling');
assert.ok(Math.abs(net.value - 0.5) < 1e-9, `net flow was ${net.value}`);
});
test('Basin geometry — derived values', async (t) => {
const ps = new PumpingStation(makeConfig());

View File

@@ -4,7 +4,7 @@ const fs = require('node:fs');
const path = require('node:path');
function loadDashboardFlow() {
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
const flowPath = path.join(__dirname, '../../examples/02-Dashboard.json');
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
}
@@ -22,27 +22,29 @@ function makeContextStub() {
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
const flow = loadDashboardFlow();
const ps = flow.find((n) => n.id === 'ps_node_basic');
const parser = flow.find((n) => n.id === 'ps_parse_output');
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
const ps = flow.find((n) => n.type === 'pumpingStation');
const parser = flow.find((n) => n.id === 'fn_status_split');
const levelChart = flow.find((n) => n.id === 'ui_chart_level');
const volumeChart = flow.find((n) => n.id === 'ui_chart_volume');
const flowChart = flow.find((n) => n.id === 'ui_chart_flow');
assert.ok(ps, 'ps_node_basic should exist');
assert.ok(ps, 'pumpingStation node should exist');
assert.equal(ps.type, 'pumpingStation');
assert.equal(ps.controlMode, 'levelbased');
assert.equal(ps.levelCurveType, 'linear');
assert.equal(ps.inletPipeDiameter, 0.4);
assert.equal(ps.inletPipeDiameter, 0.3);
assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'ps_parse_output should exist');
assert.equal(parser.outputs, 6);
assert.ok(parser, 'fn_status_split should exist');
assert.equal(parser.outputs, 15);
assert.equal(levelChart.type, 'ui-chart');
assert.equal(demandChart.type, 'ui-chart');
assert.equal(volumeChart.type, 'ui-chart');
assert.equal(flowChart.type, 'ui-chart');
});
test('basic dashboard parser routes process fields to charts and state text', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
assert.ok(parser, 'ps_parse_output should exist');
const parser = flow.find((n) => n.id === 'fn_status_split');
assert.ok(parser, 'fn_status_split should exist');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
@@ -56,8 +58,12 @@ test('basic dashboard parser routes process fields to charts and state text', ()
payload: {
'level.predicted.atequipment.default': 3.25,
'volume.predicted.atequipment.default': 32.5,
'volumePercent.predicted.atequipment.default': 65,
'flow.predicted.in.default': 0.005,
'flow.predicted.out.default': 0.002,
'netFlowRate.predicted.atequipment.default': 0.003,
percControl: 25,
mode: 'levelbased',
direction: 'filling',
safetyState: 'normal',
isOverflowing: false,
@@ -66,22 +72,26 @@ test('basic dashboard parser routes process fields to charts and state text', ()
}, context, node);
assert.ok(Array.isArray(out));
assert.equal(out.length, 6);
assert.equal(out[0].topic, 'level');
assert.equal(out[0].payload, 3.25);
assert.equal(out[1].topic, 'volume');
assert.equal(out[1].payload, 32.5);
assert.equal(out[2].topic, 'demand');
assert.equal(out[2].payload, 25);
assert.equal(out[3].topic, 'net_flow');
assert.equal(out[3].payload, 0.003);
assert.match(out[4].payload, /normal/);
assert.match(out[5].payload, /level=3.25 m/);
assert.equal(out.length, 15);
assert.equal(out[0].payload, 'levelbased');
assert.equal(out[1].payload, 'filling');
assert.equal(out[2].payload, '3.25 m');
assert.equal(out[3].payload, '32.50 m³');
assert.equal(out[4].payload, '65.00 %');
assert.equal(out[5].payload, '25.0 %');
assert.deepEqual(out[7], { topic: 'Level', payload: 3.25 });
assert.deepEqual(out[8], { topic: 'Volume', payload: 32.5 });
assert.deepEqual(out[9], { topic: 'Volume %', payload: 65 });
assert.deepEqual(out[10], { topic: 'Inflow', payload: 18 });
assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 });
assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 });
assert.ok(Array.isArray(out[13].payload));
assert.deepEqual(out[14], { topic: 'percControl', payload: 25 });
});
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
const parser = flow.find((n) => n.id === 'fn_status_split');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
@@ -89,6 +99,6 @@ test('basic dashboard parser keeps previous values when process output sends onl
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
const out = func({ payload: { percControl: 20 } }, context, node);
assert.equal(out[0].payload, 3.1);
assert.equal(out[2].payload, 20);
assert.equal(out[2].payload, '3.10 m');
assert.equal(out[5].payload, '20.0 %');
});

View File

@@ -1,6 +1,6 @@
# 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]
> 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`.
@@ -19,14 +19,56 @@ The **Unit** column reflects each descriptor's `units: { measure, default }` dec
|---|---|---|---|---|
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
| `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. |
| `cmd.calibrate.volume` | `calibratePredictedVolume` | any | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
| `cmd.calibrate.level` | `calibratePredictedLevel` | any | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
| `set.inflow` | `q_in` | any | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
| `set.outflow` | `q_out` | any | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
| `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
| `cmd.calibrate.volume` | `calibratePredictedVolume` | `any` | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
| `cmd.calibrate.level` | `calibratePredictedLevel` | `any` | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
| `set.inflow` | `q_in` | `any` | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
| `set.outflow` | `q_out` | `any` | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
| `set.demand` | `Qd` | `any` | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
<!-- END AUTOGEN: topic-contract -->
### Input message examples
One worked `msg` per accepted topic. Send these into **Port 0**. For unit-bearing
topics the commandRegistry converts `msg.unit` (or a `{ value, unit }` payload) to
the default unit *before* the handler runs &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
@@ -39,35 +81,88 @@ Keys composed each tick by `specificClass.getOutput()` and emitted via `outputUt
|---|---|---|---|
| `direction` | string | — | `"steady"` |
| `dryRunLevel` | number | — | `0.20400000000000001` |
| `dryRunSafetyVol` | number | — | `0.20400000000000001` |
| `dryRunSafetyVol` | number | — | `2.55` |
| `flowSource` | null | — | `null` |
| `heightBasin` | number | m | `1` |
| `highVolumeSafetyLevel` | number | — | `2.45` |
| `highVolumeSafetyVol` | number | — | `2.45` |
| `inflowLevel` | number | m | `2` |
| `heightBasin` | number | m | `4` |
| `highVolumeSafetyLevel` | number | — | `3.7239999999999998` |
| `highVolumeSafetyVol` | number | — | `46.55` |
| `inflowLevel` | number | m | `1.5` |
| `inletPipeDiameter` | number | — | `0.4` |
| `maxVol` | number | m3 | `1` |
| `maxVolAtOverflow` | number | m3 | `2.5` |
| `manualDemand` | null | | `null` |
| `maxVol` | number | m3 | `50` |
| `maxVolAtOverflow` | number | m3 | `47.5` |
| `minHeightBasedOn` | string | — | `"outlet"` |
| `minVol` | number | m3 | `0.2` |
| `minVolAtInflow` | number | m3 | `2` |
| `minVolAtOutflow` | number | m3 | `0.2` |
| `minVol` | number | m3 | `2.5` |
| `minVolAtInflow` | number | m3 | `18.75` |
| `minVolAtOutflow` | number | m3 | `2.5` |
| `mode` | string | — | `"levelbased"` |
| `outflowLevel` | number | m | `0.2` |
| `outletPipeDiameter` | number | — | `0.4` |
| `overflowLevel` | number | m | `2.5` |
| `overflowLevel` | number | m | `3.8` |
| `percControl` | number | % | `0` |
| `predictedOverflowRate` | number | — | `0` |
| `predictedOverflowVolume` | number | — | `0` |
| `predictedUnderflowVolume` | number | — | `0` |
| `surfaceArea` | number | m2 | `1` |
| `surfaceArea` | number | m2 | `12.5` |
| `timeleft` | null | s | `null` |
| `volEmptyBasin` | number | m3 | `1` |
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` |
| `volEmptyBasin` | number | m3 | `50` |
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `2.5` |
<!-- END AUTOGEN: data-model -->
Sample values come from a stub instantiation in `wikiGen` &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