diff --git a/examples/02-Dashboard.json b/examples/02-Dashboard.json index 10f2543..8239327 100644 --- a/examples/02-Dashboard.json +++ b/examples/02-Dashboard.json @@ -1239,7 +1239,7 @@ "z": "tab_mgc_dash", "g": "grp_status_panel", "name": "chart: Pump A", - "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\nconst flowMsg = (flow == null) ? null : { topic: 'Pump A', payload: Number(flow) };\nconst ctrlMsg = (ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump A', payload: +ctrl };\nreturn [flowMsg, ctrlMsg];\n", + "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump A', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump A', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump A', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n", "outputs": 2, "timeout": 0, "noerr": 0, @@ -1263,7 +1263,7 @@ "z": "tab_mgc_dash", "g": "grp_status_panel", "name": "chart: Pump B", - "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\nconst flowMsg = (flow == null) ? null : { topic: 'Pump B', payload: Number(flow) };\nconst ctrlMsg = (ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump B', payload: +ctrl };\nreturn [flowMsg, ctrlMsg];\n", + "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump B', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump B', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump B', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n", "outputs": 2, "timeout": 0, "noerr": 0, @@ -1287,7 +1287,7 @@ "z": "tab_mgc_dash", "g": "grp_status_panel", "name": "chart: Pump C", - "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\nconst flowMsg = (flow == null) ? null : { topic: 'Pump C', payload: Number(flow) };\nconst ctrlMsg = (ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump C', payload: +ctrl };\nreturn [flowMsg, ctrlMsg];\n", + "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump C', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump C', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump C', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n", "outputs": 2, "timeout": 0, "noerr": 0, @@ -1850,7 +1850,7 @@ "yAxisLabel": "%", "yAxisProperty": "payload", "yAxisPropertyType": "msg", - "ymin": "0", + "ymin": "-5", "ymax": "100", "bins": 10, "action": "append", diff --git a/test/_output-manifest.md b/test/_output-manifest.md index 59729b4..735ac61 100644 --- a/test/_output-manifest.md +++ b/test/_output-manifest.md @@ -139,6 +139,26 @@ whole msg as `null` (drop the output) when their source is missing — never | 16 | ui_chart_qh (passthrough) | raw `msg.payload` | ✔ | ✔ | | 17 | ui_chart_mgc_pctcap | `{topic:'% of capacity', payload:flow/capMax×100}` | ✔ State C | ✔ State A → null (drop) | +## Example flow fan-out — `examples/02-Dashboard.json :: fn_chart_pump_a/b/c` (outputs: 2 each) + +Each per-pump fan-out delta-caches the pump's Port 0 then emits two chart msgs. +The ctrl output carries a **-1 OFF sentinel**: when the cached pump `state` is +`off` / `idle` / `maintenance` the pump is not running, so it plots `-1` (below +the 0–100 band) — a clear OFF rail distinct from a pump genuinely running at 0%. +`ui_chart_pumps_ctrl` has `ymin: "-5"` so the sentinel is visible. Charts return +the whole msg as `null` (drop the output) when their source is missing — never +`{ payload: null }`. All ports covered by +`test/integration/per-pump-ctrl-fanout.integration.test.js`. + +| # | Target chart | Topic / payload | Populated | Degraded | +|---|---|---|---|---| +| 0 | ui_chart_per_pump_flow | `{topic:'Pump A/B/C', payload:flow m³/h}` | ✔ running state | ✔ no `flow.predicted.downstream.*` key → null (drop) | +| 1 | ui_chart_pumps_ctrl | `{topic:'Pump A/B/C', payload:ctrl%}`, or `payload:-1` when state ∈ {off,idle,maintenance} | ✔ running → +ctrl; ✔ off/idle/maintenance → -1 | ✔ no state + ctrl missing/NaN/null → null (drop); ✔ ctrl-only delta keeps cached OFF state | + +`fn_chart_total` (outputs: 1) feeds the same flow chart with the group total +(`downstream_predicted_flow ?? atEquipment_predicted_flow`); returns `null` when +both are absent. + ## Coverage gaps (open items) These are known holes flagged during the 2026-05-14 governance review; not yet diff --git a/test/integration/per-pump-ctrl-fanout.integration.test.js b/test/integration/per-pump-ctrl-fanout.integration.test.js new file mode 100644 index 0000000..386616e --- /dev/null +++ b/test/integration/per-pump-ctrl-fanout.integration.test.js @@ -0,0 +1,117 @@ +// Output-coverage tests for examples/02-Dashboard.json :: fn_chart_pump_a/b/c. +// These per-pump fan-out functions feed two charts: +// output 0 → ui_chart_per_pump_flow (topic = 'Pump A/B/C', payload = flow m³/h) +// output 1 → ui_chart_pumps_ctrl (topic = 'Pump A/B/C', payload = ctrl %) +// The ctrl output carries a -1 OFF sentinel: when the pump is off / idle / +// maintenance it is not running, so we plot -1 (below the 0–100 band) to give +// the chart a clear OFF rail distinct from a pump genuinely running at 0%. +// Every output is exercised in populated AND degraded states per +// .claude/rules/output-coverage.md. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const flow = JSON.parse(fs.readFileSync( + path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8')); + +const PUMPS = [ + { id: 'fn_chart_pump_a', topic: 'Pump A' }, + { id: 'fn_chart_pump_b', topic: 'Pump B' }, + { id: 'fn_chart_pump_c', topic: 'Pump C' }, +]; + +const FLOW = 0; // output index → ui_chart_per_pump_flow +const CTRL = 1; // output index → ui_chart_pumps_ctrl + +// Each fan-out caches Port 0 deltas in context('c'). Build a fresh runner per +// test so state never leaks between cases. +function makeRunner(node) { + let store = {}; + const context = { get: (k) => store[k], set: (k, v) => { store[k] = v; } }; + const body = new Function('msg', 'context', node.func); + return (payload) => body({ payload }, context); +} + +// A populated downstream-flow key uses the 4-segment MeasurementContainer +// convention the function matches with find('flow.predicted.downstream.'). +const flowKey = (id) => `flow.predicted.downstream.${id}`; + +test('every per-pump fan-out has exactly 2 outputs wired to flow + ctrl charts', () => { + for (const { id } of PUMPS) { + const node = flow.find(n => n.id === id); + assert.ok(node, `${id} present in flow`); + assert.equal(node.outputs, 2, `${id} outputs`); + assert.equal(node.wires.length, 2, `${id} wires`); + assert.deepEqual(node.wires[FLOW], ['ui_chart_per_pump_flow'], `${id} flow wire`); + assert.deepEqual(node.wires[CTRL], ['ui_chart_pumps_ctrl'], `${id} ctrl wire`); + } +}); + +test('ui_chart_pumps_ctrl ymin is -5 so the OFF sentinel (-1) is visible', () => { + const chart = flow.find(n => n.id === 'ui_chart_pumps_ctrl'); + assert.ok(chart, 'ui_chart_pumps_ctrl present'); + assert.equal(chart.ymin, '-5'); + assert.equal(chart.ymax, '100'); +}); + +for (const { id, topic } of PUMPS) { + test(`${id}: populated running state → flow + ctrl carry real numbers`, () => { + const run = makeRunner(flow.find(n => n.id === id)); + const out = run({ [flowKey(id)]: 478 / 3, ctrl: 72, state: 'operational' }); + assert.deepEqual(out[FLOW], { topic, payload: 478 / 3 }); + assert.deepEqual(out[CTRL], { topic, payload: 72 }); + }); + + for (const offState of ['off', 'idle', 'maintenance']) { + test(`${id}: state '${offState}' → ctrl emits -1 sentinel (even if ctrl% is 0/stale)`, () => { + const run = makeRunner(flow.find(n => n.id === id)); + // ctrl stale at 0 (or any residual) must be overridden by the sentinel. + const out = run({ [flowKey(id)]: 0, ctrl: 0, state: offState }); + assert.deepEqual(out[CTRL], { topic, payload: -1 }); + }); + } + + test(`${id}: degraded — no state, ctrl missing → ctrl output is null (drop, never payload:null)`, () => { + const run = makeRunner(flow.find(n => n.id === id)); + const out = run({ [flowKey(id)]: 50 }); + assert.equal(out[CTRL], null, 'ctrl must drop when no state and no ctrl'); + // flow still present. + assert.deepEqual(out[FLOW], { topic, payload: 50 }); + }); + + test(`${id}: degraded — no flow key → flow output is null (drop)`, () => { + const run = makeRunner(flow.find(n => n.id === id)); + const out = run({ ctrl: 40, state: 'operational' }); + assert.equal(out[FLOW], null, 'flow must drop when source key missing'); + assert.deepEqual(out[CTRL], { topic, payload: 40 }); + }); + + test(`${id}: pre-first-tick — empty payload → both outputs null, no payload:null`, () => { + const run = makeRunner(flow.find(n => n.id === id)); + const out = run({}); + assert.equal(out[FLOW], null); + assert.equal(out[CTRL], null); + for (const m of out) { + if (m && Object.prototype.hasOwnProperty.call(m, 'payload')) { + assert.notEqual(m.payload, null, `${id} emitted { payload: null }`); + } + } + }); + + test(`${id}: running ctrl with NaN/null ctrl value → ctrl drops (no payload:null)`, () => { + const run = makeRunner(flow.find(n => n.id === id)); + assert.equal(run({ [flowKey(id)]: 10, ctrl: null, state: 'operational' })[CTRL], null); + assert.equal(run({ [flowKey(id)]: 10, ctrl: NaN, state: 'operational' })[CTRL], null); + }); + + test(`${id}: delta-cache holds last state so a ctrl-only delta still rails OFF`, () => { + // Realistic: pump first reports state:'off', then a later tick carries only + // a ctrl delta (no state). The cached 'off' must keep the sentinel engaged. + const run = makeRunner(flow.find(n => n.id === id)); + run({ state: 'off', ctrl: 0 }); + const out = run({ ctrl: 5 }); // ctrl-only delta; cached state still 'off' + assert.deepEqual(out[CTRL], { topic, payload: -1 }); + }); +}