diff --git a/examples/02-Dashboard.json b/examples/02-Dashboard.json index 054f02e..10f2543 100644 --- a/examples/02-Dashboard.json +++ b/examples/02-Dashboard.json @@ -366,8 +366,13 @@ "g": "grp_setup", "name": "auto-init on deploy", "props": [ - { "p": "payload" }, - { "p": "topic", "vt": "str" } + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } ], "repeat": "", "crontab": "", @@ -445,8 +450,8 @@ "z": "tab_mgc_dash", "g": "grp_status_panel", "name": "fan-out Port 0 (status + charts + raw)", - "func": "// MGC Port 0 emits delta-only. Cache last-known so deltas never blank a\n// row, then fan out one msg per ui-text / ui-chart / ui-template slot.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\n// num/pct treat null AND undefined as \"no data\" (display em-dash). Without\n// the explicit null check, `+null === 0` would silently render as \"0.0 %\" \u2014\n// the bug class we hit twice today (\u03b7-null and Ncog/bepRel degenerate).\nconst num = (v, dp, unit) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\nconst pct = (v, dp) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return (n * 100).toFixed(dp) + ' %';\n};\n\nconst mode = cache.mode || '\u2014';\nconst flow = cache['atEquipment_predicted_flow'];\nconst power = cache['atEquipment_predicted_power'];\nconst qMin = cache.flowCapacityMin;\nconst qMax = cache.flowCapacityMax;\nconst nAct = cache.machineCountActive;\nconst nTot = cache.machineCount;\nconst bepRel = cache.relDistFromPeak; // 0..1\nconst bepAbs = cache.absDistFromPeak; // \u03b7 points (dimensionless)\nconst eta = cache['atEquipment_predicted_efficiency']; // 0..1\n// MGC emits atEquipment_predicted_Ncog as the SUM of per-pump NCog values from\n// the BEP-Gravitation optimizer (bepGravitation.js:162 totalCog). Range is\n// 0..N where N=active pumps, NOT 0..1. Normalize here so the dashboard shows\n// a per-pump average position on the BEP envelope.\nconst ncogSum = +cache['atEquipment_predicted_Ncog'];\n// undefined (not null) for the degraded case — pct() does `+v` and `+null === 0`,\n// which would silently display \"0.0 %\" instead of the em-dash that means \"no data\".\nconst ncog = (Number.isFinite(ncogSum) && Number.isFinite(+nAct) && +nAct > 0)\n ? ncogSum / +nAct\n : undefined;\n// Peak \u03b7 isn't emitted directly; derive: peak = eta + absDistFromPeak.\nconst etaPeak = (Number.isFinite(+eta) && Number.isFinite(+bepAbs)) ? (+eta + +bepAbs) : null;\n\nconst chart = (topic, v, scale = 1) =>\n Number.isFinite(+v) ? { topic, payload: +v * scale } : null;\n\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-5: original status texts (mode, flow, power, capacity, machines, BEP rel%)\n { payload: mode },\n { payload: num(flow, 1, 'm\u00b3/h') },\n { payload: num(power, 2, 'kW') },\n { payload: Number.isFinite(+qMax) ? (num(qMin, 1) + ' \u2013 ' + num(qMax, 1, 'm\u00b3/h')) : '\u2014' },\n { payload: (Number.isFinite(+nAct) && Number.isFinite(+nTot)) ? (nAct + ' / ' + nTot) : '\u2014' },\n { payload: pct(bepRel, 1) }, // BEP rel% \u2014 was buggy: now \u00d7100 then format\n\n // 6-9: new status texts (\u03b7, \u03b7 peak, BEP abs gap, NCog)\n { payload: pct(eta, 1) }, // \u03b7 (hydraulic)\n { payload: pct(etaPeak, 1) }, // \u03b7 peak\n { payload: num(bepAbs, 3) }, // BEP abs gap (\u03b7 points)\n { payload: pct(ncog, 1) }, // NCog (BEP flow position)\n\n // 10-13: charts (flow predicted, capacity max, power, BEP rel% scaled to 0..100)\n chart('Flow', flow),\n chart('Capacity', qMax),\n chart('Power', power),\n chart('BEP rel %', bepRel, 100), // chart also fixed: scale 0..1 \u2192 0..100\n\n // 14: efficiency chart \u2014 emit only when eta is finite (null msg = no output,\n // which avoids ui-chart crashing on { payload: null })\n chart('\u03b7 (%)', eta, 100),\n\n // 15: raw rows for the ui-template\n { payload: rawRows },\n { payload: msg.payload }, // 16: raw passthrough for Q-H chart\n];\n", - "outputs": 17, + "func": "// MGC Port 0 emits delta-only. Cache last-known so deltas never blank a\n// row, then fan out one msg per ui-text / ui-chart / ui-template slot.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\n// num/pct treat null AND undefined as \"no data\" (display em-dash). Without\n// the explicit null check, `+null === 0` would silently render as \"0.0 %\" \u2014\n// the bug class we hit twice today (\u03b7-null and Ncog/bepRel degenerate).\nconst num = (v, dp, unit) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\nconst pct = (v, dp) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return (n * 100).toFixed(dp) + ' %';\n};\n\nconst mode = cache.mode || '\u2014';\nconst flow = cache['atEquipment_predicted_flow'];\nconst power = cache['atEquipment_predicted_power'];\nconst qMin = cache.flowCapacityMin;\nconst qMax = cache.flowCapacityMax;\nconst nAct = cache.machineCountActive;\nconst nTot = cache.machineCount;\nconst bepRel = cache.relDistFromPeak; // 0..1\nconst bepAbs = cache.absDistFromPeak; // \u03b7 points (dimensionless)\nconst eta = cache['atEquipment_predicted_efficiency']; // 0..1\n// MGC emits atEquipment_predicted_Ncog as the SUM of per-pump NCog values from\n// the BEP-Gravitation optimizer (bepGravitation.js:162 totalCog). Range is\n// 0..N where N=active pumps, NOT 0..1. Normalize here so the dashboard shows\n// a per-pump average position on the BEP envelope.\nconst ncogSum = +cache['atEquipment_predicted_Ncog'];\n// undefined (not null) for the degraded case \u2014 pct() does `+v` and `+null === 0`,\n// which would silently display \"0.0 %\" instead of the em-dash that means \"no data\".\nconst ncog = (Number.isFinite(ncogSum) && Number.isFinite(+nAct) && +nAct > 0)\n ? ncogSum / +nAct\n : undefined;\n// Peak \u03b7 isn't emitted directly; derive: peak = eta + absDistFromPeak.\nconst etaPeak = (Number.isFinite(+eta) && Number.isFinite(+bepAbs)) ? (+eta + +bepAbs) : null;\n\n// % of capacity \u2014 realized predicted flow / max capacity. Both already coerced safely above.\nconst pctCap = (Number.isFinite(+flow) && Number.isFinite(+qMax) && +qMax > 0)\n ? (+flow / +qMax) * 100\n : undefined;\n\nconst chart = (topic, v, scale = 1) =>\n Number.isFinite(+v) ? { topic, payload: +v * scale } : null;\n\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-5: original status texts (mode, flow, power, capacity, machines, BEP rel%)\n { payload: mode },\n { payload: num(flow, 1, 'm\u00b3/h') },\n { payload: num(power, 2, 'kW') },\n { payload: Number.isFinite(+qMax) ? (num(qMin, 1) + ' \u2013 ' + num(qMax, 1, 'm\u00b3/h')) : '\u2014' },\n { payload: (Number.isFinite(+nAct) && Number.isFinite(+nTot)) ? (nAct + ' / ' + nTot) : '\u2014' },\n { payload: pct(bepRel, 1) }, // BEP rel% \u2014 was buggy: now \u00d7100 then format\n\n // 6-9: new status texts (\u03b7, \u03b7 peak, BEP abs gap, NCog)\n { payload: pct(eta, 1) }, // \u03b7 (hydraulic)\n { payload: pct(etaPeak, 1) }, // \u03b7 peak\n { payload: num(bepAbs, 3) }, // BEP abs gap (\u03b7 points)\n { payload: pct(ncog, 1) }, // NCog (BEP flow position)\n\n // 10-13: charts (flow predicted, capacity max, power, BEP rel% scaled to 0..100)\n chart('Flow', flow),\n chart('Capacity', qMax),\n chart('Power', power),\n chart('BEP rel %', bepRel, 100), // chart also fixed: scale 0..1 \u2192 0..100\n\n // 14: efficiency chart \u2014 emit only when eta is finite (null msg = no output,\n // which avoids ui-chart crashing on { payload: null })\n chart('\u03b7 (%)', eta, 100),\n\n // 15: raw rows for the ui-template\n { payload: rawRows },\n { payload: msg.payload }, // 16: raw passthrough for Q-H chart\n // 17: % of capacity chart\n chart('% of capacity', pctCap),\n];\n", + "outputs": 18, "timeout": 0, "noerr": 0, "initialize": "", @@ -505,6 +510,9 @@ ], [ "fn_qh_point" + ], + [ + "ui_chart_mgc_pctcap" ] ] }, @@ -1231,8 +1239,8 @@ "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.');\nif (flow == null) return null;\nreturn { topic: 'Pump A', payload: Number(flow) };\n", - "outputs": 1, + "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", + "outputs": 2, "timeout": 0, "noerr": 0, "initialize": "", @@ -1243,6 +1251,9 @@ "wires": [ [ "ui_chart_per_pump_flow" + ], + [ + "ui_chart_pumps_ctrl" ] ] }, @@ -1252,8 +1263,8 @@ "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.');\nif (flow == null) return null;\nreturn { topic: 'Pump B', payload: Number(flow) };\n", - "outputs": 1, + "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", + "outputs": 2, "timeout": 0, "noerr": 0, "initialize": "", @@ -1264,6 +1275,9 @@ "wires": [ [ "ui_chart_per_pump_flow" + ], + [ + "ui_chart_pumps_ctrl" ] ] }, @@ -1273,8 +1287,8 @@ "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.');\nif (flow == null) return null;\nreturn { topic: 'Pump C', payload: Number(flow) };\n", - "outputs": 1, + "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", + "outputs": 2, "timeout": 0, "noerr": 0, "initialize": "", @@ -1285,6 +1299,9 @@ "wires": [ [ "ui_chart_per_pump_flow" + ], + [ + "ui_chart_pumps_ctrl" ] ] }, @@ -1713,7 +1730,7 @@ "z": "tab_mgc_dash", "g": "grp_pump_a", "name": "Q-H curve points \u2192 chart", - "func": "// Emit one chart msg per point. Topic='Curve' makes the chart treat\n// it as a second series next to the 'Operating point' scatter.\n// Action 'replace' so each new sample sweeps the curve fresh (no\n// trail buildup).\nconst r = msg.payload || {};\nif (r.error || !Array.isArray(r.points) || r.points.length === 0) return null;\n\n// Trim the trailing flat-Q tail. buildQHCurve returns ~33 points across the\n// full pressure envelope, but at low ctrl% the last ~10 points clamp to the\n// pump's minimum-flow envelope (constant Q across rising H). Plotting those\n// stretches the chart's H axis to ~40 m even though the operating point sits\n// near H=11 m — making the curve look like a vertical line with the\n// operating point lost at the bottom. Keep one entry-point of the tail so\n// the curve still terminates visually, drop the rest.\nconst FLAT_Q_EPS = 0.5; // m³/h — pump-curve resolution; below this is noise.\nlet trimTo = r.points.length;\nfor (let i = r.points.length - 1; i > 0; i--) {\n if (Math.abs(r.points[i].Q - r.points[i-1].Q) >= FLAT_Q_EPS) { trimTo = i + 1; break; }\n}\nconst trimmed = r.points.slice(0, trimTo);\n\nconst out = trimmed.map((pt) => ({ topic: 'Curve', payload: { x: pt.Q, y: pt.H } }));\n// Send a reset to clear the previous curve before appending the new one.\nout.unshift({ topic: 'Curve', action: 'clear' });\nreturn [out];\n", + "func": "// Emit one chart msg per point. Topic='Curve' makes the chart treat\n// it as a second series next to the 'Operating point' scatter.\n// Action 'replace' so each new sample sweeps the curve fresh (no\n// trail buildup).\nconst r = msg.payload || {};\nif (r.error || !Array.isArray(r.points) || r.points.length === 0) return null;\n\n// Trim the trailing flat-Q tail. buildQHCurve returns ~33 points across the\n// full pressure envelope, but at low ctrl% the last ~10 points clamp to the\n// pump's minimum-flow envelope (constant Q across rising H). Plotting those\n// stretches the chart's H axis to ~40 m even though the operating point sits\n// near H=11 m \u2014 making the curve look like a vertical line with the\n// operating point lost at the bottom. Keep one entry-point of the tail so\n// the curve still terminates visually, drop the rest.\nconst FLAT_Q_EPS = 0.5; // m\u00b3/h \u2014 pump-curve resolution; below this is noise.\nlet trimTo = r.points.length;\nfor (let i = r.points.length - 1; i > 0; i--) {\n if (Math.abs(r.points[i].Q - r.points[i-1].Q) >= FLAT_Q_EPS) { trimTo = i + 1; break; }\n}\nconst trimmed = r.points.slice(0, trimTo);\n\nconst out = trimmed.map((pt) => ({ topic: 'Curve', payload: { x: pt.Q, y: pt.H } }));\n// Send a reset to clear the previous curve before appending the new one.\nout.unshift({ topic: 'Curve', action: 'clear' });\nreturn [out];\n", "outputs": 1, "noerr": 0, "initialize": "", @@ -1746,5 +1763,131 @@ "fn_qh_curve_fetcher" ] ] + }, + { + "id": "ui_chart_mgc_pctcap", + "type": "ui-chart", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_trends", + "name": "% of capacity", + "label": "Group % of capacity \u2014 flow \u00f7 Qmax", + "order": 5, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisType": "time", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "%", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "0", + "ymax": "120", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": false, + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "", + "colors": [ + "#A347E1", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#0095FF", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 6, + "height": 4, + "className": "", + "interpolation": "linear", + "x": 1770, + "y": 500, + "wires": [ + [] + ] + }, + { + "id": "ui_chart_pumps_ctrl", + "type": "ui-chart", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_trends", + "name": "Per-pump % control", + "label": "Per-pump % control \u2014 A / B / C", + "order": 6, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisType": "time", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "%", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "0", + "ymax": "100", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": true, + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "", + "colors": [ + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#0095FF", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5", + "#cccccc" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 6, + "height": 4, + "className": "", + "interpolation": "linear", + "x": 1750, + "y": 700, + "wires": [ + [] + ] } -] +] \ No newline at end of file