From 5533293647dfaef15ccd8f4c0d976b05c4fc6b79 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Wed, 27 May 2026 16:09:29 +0200 Subject: [PATCH] feat(dashboardAPI): slice47 MGC pump panel telemetry + tests - specificClass updates for MGC per-pump panel sources. - Output manifest + slice47 basic test for the pump-panel outputs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/specificClass.js | 224 +++++++++++++++++- test/_output-manifest.md | 5 +- .../slice47-mgc-pump-panels.basic.test.js | 156 ++++++++++++ 3 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 test/basic/slice47-mgc-pump-panels.basic.test.js diff --git a/src/specificClass.js b/src/specificClass.js index 7bbe0f1..f9923b1 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -194,7 +194,7 @@ class DashboardApi { updateTemplatingVar(dashboard, 'measurement', measurementName); updateTemplatingVar(dashboard, 'bucket', bucket); - return { dashboard, uid, title, softwareType, nodeId, measurementName }; + return { dashboard, uid, title, softwareType, nodeId, measurementName, bucket }; } buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) { @@ -299,6 +299,11 @@ class DashboardApi { this._dedupParentPanels(nodeDash, childDashes); this._linkToChildren(nodeDash, children); + // Inject the per-pump fan-out panels AFTER dedup so they survive: these + // panels intentionally aggregate child data onto the parent dashboard + // (the operator wants every pump on one MGC graph), which is exactly what + // the no-duplication rule strips elsewhere. Run last so nothing removes them. + this._injectMachineGroupPumpPanels(nodeDash, children); return nodeDash; } @@ -352,6 +357,223 @@ class DashboardApi { }); } } + + // Software types that count as a "pump" child of a machine group. Mirrors the + // template-alias map: a rotatingMachine reports softwareType 'rotatingmachine' + // in production, 'machine' in tests / shared template. + static _PUMP_SOFTWARE_TYPES = new Set(['rotatingmachine', 'machine']); + + // Replicate the measurement-name convention from outputUtils.formatMsg / + // buildDashboard so the dashboard queries the exact series each pump writes: + // `general.name` when set, else `_`. + _measurementNameForConfig(config) { + const softwareType = config?.functionality?.softwareType || 'measurement'; + return config?.general?.name || `${softwareType}_${config?.general?.id || softwareType}`; + } + + // Datasource block reused for injected panels. Pull it off an existing panel + // so the dashboard keeps a single influxdb datasource uid; fall back to the + // template's known uid if every panel was deduped away. + _datasourceFor(dashboard) { + const withDs = (dashboard.panels || []).find((p) => p?.datasource?.type === 'influxdb'); + return withDs?.datasource || { type: 'influxdb', uid: 'cdzg44tv250jkd' }; + } + + // Build the per-pump + group-aggregate timeseries panels for a machineGroup + // dashboard. The operator asked for one graph each of pump % control, pump + // predicted flow, and pump predicted power, with the group total folded in, + // the resolved demand overlaid on the flow graph, and the flow-capacity + // envelope drawn as dashed min/max lines. + // + // Per-pump series live in each pump's OWN InfluxDB measurement (not the + // MGC's), so the queries are generated at compose time from the known child + // topology. Pump series are kept by `_measurement` (legend = pump name); + // group series are kept by `_field` and renamed via byName overrides. + _injectMachineGroupPumpPanels(parentDash, children) { + if (!parentDash?.dashboard) return; + const st = String(parentDash.softwareType || '').toLowerCase(); + if (st !== 'machinegroupcontrol' && st !== 'machinegroup') return; + + const pumps = (children || []) + .map(({ childSource }) => childSource?.config) + .filter((c) => c && DashboardApi._PUMP_SOFTWARE_TYPES.has( + String(c?.functionality?.softwareType || '').toLowerCase())) + .map((c) => ({ measurement: this._measurementNameForConfig(c), title: c?.general?.name || c?.general?.id })); + + if (pumps.length === 0) return; // No pumps wired → leave the static totals. + + const dashboard = parentDash.dashboard; + const datasource = this._datasourceFor(dashboard); + // The richer flow/power panels below supersede the static group-total + // panels — drop them so the same series isn't drawn twice. + dashboard.panels = (dashboard.panels || []).filter( + (p) => p.title !== 'Total Flow' && p.title !== 'Total Power'); + + const measFilter = pumps.map((p) => `r._measurement == "${p.measurement}"`).join(' or '); + const nextId = Math.max(0, ...dashboard.panels.map((p) => Number(p.id) || 0)) + 1; + + dashboard.panels.push( + this._pumpControlPanel({ datasource, measFilter, id: nextId, y: 6 }), + this._pumpFlowPanel({ datasource, measFilter, id: nextId + 1, y: 14 }), + this._pumpPowerPanel({ datasource, measFilter, id: nextId + 2, y: 22 }), + ); + } + + // ── Injected-panel builders ────────────────────────────────────────────── + // All three use `${bucket}` / `${measurement}` template vars (resolved by + // Grafana from the dashboard's templating list) plus literal pump measurement + // names. v.timeRangeStart/Stop/windowPeriod are Grafana-supplied. + + _baseTsPanel({ datasource, id, y, title, targets, overrides = [], defaults = {} }) { + return { + datasource, + fieldConfig: { + defaults: { custom: { drawStyle: 'line', lineWidth: 2, fillOpacity: 5, showPoints: 'never' }, ...defaults }, + overrides, + }, + gridPos: { h: 8, w: 24, x: 0, y }, + id, + options: { legend: { displayMode: 'list', placement: 'bottom' }, tooltip: { mode: 'multi' } }, + targets, + title, + type: 'timeseries', + // Empty emittedFields: these panels intentionally duplicate child series + // and must never be removed by the no-duplication dedup pass. + meta: { emittedFields: [], dynamic: 'mgc-pump-fanout' }, + }; + } + + // Pump series kept by `_measurement` → one line per pump, legend = pump name. + // `field` is exact-matched by default; pass `regex:true` to match a 4-segment + // MeasurementContainer key whose childId varies per pump. rotatingMachine + // writes its own predictions under childId = node id (e.g. + // `flow.predicted.atequipment.`), NOT a fixed `default`, so the + // flow/power series must match the position prefix, not an exact key. + _perPumpTarget({ measFilter, field, refId, transform = '', regex = false }) { + const fieldFilter = regex ? `r._field =~ /${field}/` : `r._field == "${field}"`; + return { + refId, + query: + `from(bucket: "\${bucket}")\n` + + ` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` + + ` |> filter(fn:(r) => (${measFilter}) and ${fieldFilter})\n` + + ` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` + + transform + + ` |> keep(columns: ["_time", "_value", "_measurement"])`, + }; + } + + // Group series kept by `_field` → legend = field name, renamed via byName + // overrides. `fields` is OR-joined into one query. + _groupFieldsTarget({ fields, refId }) { + const filter = fields.map((f) => `r._field == "${f}"`).join(' or '); + return { + refId, + query: + `from(bucket: "\${bucket}")\n` + + ` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` + + ` |> filter(fn:(r) => r._measurement == "\${measurement}" and (${filter}))\n` + + ` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` + + ` |> keep(columns: ["_time", "_value", "_field"])`, + }; + } + + _byName(name, properties) { + return { matcher: { id: 'byName', options: name }, properties }; + } + + _pumpControlPanel({ datasource, measFilter, id, y }) { + // Two series per pump so an operator can see at a glance whether each pump + // actually moved to where the MGC told it: + // • realized position — the bare `ctrl` field (getCurrentPosition), solid. + // • commanded setpoint — `ctrl.predicted.atequipment.`, the % the + // pump computed from the MGC flow command (calcCtrl reverse curve), + // drawn dashed. childId varies per pump, so match the position prefix. + // Both are already 0..100 %, so they map straight onto a % axis — no scaling. + // Each series' `_measurement` is suffixed so the legend distinguishes the + // two lines per pump ("Pump A (realized)" vs "Pump A (setpoint)"). + const label = (name) => + ` |> map(fn: (r) => ({ r with _measurement: r._measurement + " (${name})" }))\n`; + return this._baseTsPanel({ + datasource, id, y, + title: 'Pump % Control', + defaults: { unit: 'percent', min: 0, max: 100 }, + targets: [ + this._perPumpTarget({ measFilter, field: 'ctrl', refId: 'A', transform: label('realized') }), + this._perPumpTarget({ + measFilter, field: '^ctrl\\.predicted\\.atequipment\\.', refId: 'B', + regex: true, transform: label('setpoint'), + }), + ], + overrides: [{ + matcher: { id: 'byRegexp', options: '.*\\(setpoint\\)' }, + properties: [{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } }], + }], + }); + } + + _pumpFlowPanel({ datasource, measFilter, id, y }) { + return this._baseTsPanel({ + datasource, id, y, + title: 'Pump Predicted Flow vs Demand', + defaults: { unit: 'm3/h' }, + targets: [ + this._perPumpTarget({ measFilter, field: '^flow\\.predicted\\.atequipment\\.', refId: 'A', regex: true }), + this._groupFieldsTarget({ + refId: 'B', + fields: ['atEquipment_predicted_flow', 'demandFlow', 'demandPct', 'flowCapacityMin', 'flowCapacityMax'], + }), + ], + overrides: [ + this._byName('atEquipment_predicted_flow', [ + { id: 'displayName', value: 'Total flow' }, + { id: 'custom.lineWidth', value: 3 }, + ]), + this._byName('demandFlow', [ + { id: 'displayName', value: 'Flow demand (setpoint)' }, + { id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } }, + { id: 'color', value: { mode: 'fixed', fixedColor: 'blue' } }, + ]), + this._byName('demandPct', [ + { id: 'displayName', value: 'Demand %' }, + { id: 'unit', value: 'percent' }, + { id: 'custom.axisPlacement', value: 'right' }, + { id: 'custom.axisLabel', value: '% control' }, + { id: 'color', value: { mode: 'fixed', fixedColor: 'purple' } }, + ]), + this._byName('flowCapacityMin', [ + { id: 'displayName', value: 'Capacity min' }, + { id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } }, + { id: 'custom.fillOpacity', value: 0 }, + { id: 'color', value: { mode: 'fixed', fixedColor: 'orange' } }, + ]), + this._byName('flowCapacityMax', [ + { id: 'displayName', value: 'Capacity max' }, + { id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } }, + { id: 'custom.fillOpacity', value: 0 }, + { id: 'color', value: { mode: 'fixed', fixedColor: 'red' } }, + ]), + ], + }); + } + + _pumpPowerPanel({ datasource, measFilter, id, y }) { + return this._baseTsPanel({ + datasource, id, y, + title: 'Pump Predicted Power', + defaults: { unit: 'kwatt' }, + targets: [ + this._perPumpTarget({ measFilter, field: '^power\\.predicted\\.atequipment\\.', refId: 'A', regex: true }), + this._groupFieldsTarget({ refId: 'B', fields: ['atEquipment_predicted_power'] }), + ], + overrides: [ + this._byName('atEquipment_predicted_power', [ + { id: 'displayName', value: 'Total power' }, + { id: 'custom.lineWidth', value: 3 }, + ]), + ], + }); + } } module.exports = DashboardApi; diff --git a/test/_output-manifest.md b/test/_output-manifest.md index 6b93c79..657e172 100644 --- a/test/_output-manifest.md +++ b/test/_output-manifest.md @@ -48,8 +48,9 @@ dashboardAPI is a **sink** for `child.register` messages, not a source — it do | Method | Return shape | Populated states | Degraded states | Test | |---|---|---|---|---| -| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName }` or `null`; `measurementName` mirrors `outputUtils.formatMsg` (`general.name` \|\| `_`) so the dashboard `_measurement` var matches the telemetry series | success (name set + name empty/fallback) | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js`, `test/basic/slice46-measurement-name-parity.basic.test.js` | -| `generateDashboardsForGraph(root)` | flat pre-order array of `buildDashboard` results (root first, then full descendant subtree); per-parent dedup + links applied at every level | 0..N children, 3-level tree, diamond, cycle | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` | +| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName, bucket }` or `null`; `measurementName` mirrors `outputUtils.formatMsg` (`general.name` \|\| `_`) so the dashboard `_measurement` var matches the telemetry series; `bucket` is the resolved Influx bucket | success (name set + name empty/fallback) | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js`, `test/basic/slice46-measurement-name-parity.basic.test.js` | +| `generateDashboardsForGraph(root)` | flat pre-order array of `buildDashboard` results (root first, then full descendant subtree); per-parent dedup + links applied at every level; machineGroup roots additionally get per-pump fan-out panels injected (see below) | 0..N children, 3-level tree, diamond, cycle | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` | +| `_injectMachineGroupPumpPanels(parentDash, children)` | mutates an MGC dashboard in place: replaces the static Total Flow/Power panels with 3 timeseries panels (Pump % Control, Pump Predicted Flow vs Demand, Pump Predicted Power) whose queries are generated from the child-pump measurement names. Panels carry `meta.emittedFields: []` so they survive the dedup pass | MGC with ≥1 rotatingMachine child | no-op for non-MGC dashboards or MGC with zero pump children (static totals retained) | `test/basic/slice47-mgc-pump-panels.basic.test.js` | | `subtreeChanged(diff, ids)` | boolean | id-in-diff, no-id-in-diff | null diff → true (cold start) | `test/basic/slice36-diff-predicate.basic.test.js` | | `subtreeIdsFor(myId, child)` | Set\ | myId + every id in the child's full subtree (recurses all levels, cycle-safe) | myId only when child has no children | `test/basic/slice36-diff-predicate.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` | | `collectEmittedFields(dashboard)` | Set\ | populated dashboard | empty set for `null`/`{}`/`{panels:[]}` | `test/basic/slice37-emitted-fields.basic.test.js` | diff --git a/test/basic/slice47-mgc-pump-panels.basic.test.js b/test/basic/slice47-mgc-pump-panels.basic.test.js new file mode 100644 index 0000000..2904d1e --- /dev/null +++ b/test/basic/slice47-mgc-pump-panels.basic.test.js @@ -0,0 +1,156 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const DashboardApi = require('../../src/specificClass.js'); + +// Build an MGC root with N rotatingMachine children, compose the graph, and +// return the MGC dashboard (results[0]). +function composeMgcWith(pumpDefs) { + const api = new DashboardApi({}); + const entries = pumpDefs.map((p) => [p.id, { + child: { config: { general: { id: p.id, name: p.name }, functionality: { softwareType: p.softwareType || 'machine', positionVsParent: 'downstream' } } }, + position: 'downstream', + }]); + const root = { + config: { general: { id: 'mgc-1', name: 'MGC' }, functionality: { softwareType: 'machineGroupControl', positionVsParent: 'atequipment' } }, + childRegistrationUtils: { registeredChildren: new Map(entries) }, + }; + return { api, dash: api.generateDashboardsForGraph(root)[0].dashboard }; +} + +const PUMPS = [ + { id: 'pump-a', name: 'Pump A' }, + { id: 'pump-b', name: 'Pump B' }, +]; + +test('MGC dashboard gains the three pump fan-out panels', () => { + const { dash } = composeMgcWith(PUMPS); + const titles = dash.panels.filter((p) => p.type === 'timeseries').map((p) => p.title); + assert.ok(titles.includes('Pump % Control'), 'missing % control panel'); + assert.ok(titles.includes('Pump Predicted Flow vs Demand'), 'missing flow panel'); + assert.ok(titles.includes('Pump Predicted Power'), 'missing power panel'); +}); + +test('static group-total panels are replaced by the richer fan-out panels', () => { + const { dash } = composeMgcWith(PUMPS); + const titles = dash.panels.map((p) => p.title); + assert.ok(!titles.includes('Total Flow'), 'static Total Flow should be removed'); + assert.ok(!titles.includes('Total Power'), 'static Total Power should be removed'); +}); + +test('% control query targets every pump measurement; ctrl is already percent (no scaling)', () => { + const { dash } = composeMgcWith(PUMPS); + const panel = dash.panels.find((p) => p.title === 'Pump % Control'); + const q = panel.targets[0].query; + assert.match(q, /r\._measurement == "Pump A"/); + assert.match(q, /r\._measurement == "Pump B"/); + assert.match(q, /r\._field == "ctrl"/); + assert.ok(!/_value \* 100/.test(q), 'ctrl is 0..100 already — must NOT be ×100 scaled'); + assert.equal(panel.fieldConfig.defaults.unit, 'percent'); +}); + +test('% control plots both realized position and commanded setpoint per pump', () => { + const { dash } = composeMgcWith(PUMPS); + const panel = dash.panels.find((p) => p.title === 'Pump % Control'); + const realized = panel.targets.find((t) => /r\._field == "ctrl"/.test(t.query)); + const setpoint = panel.targets.find((t) => /ctrl\\\.predicted\\\.atequipment/.test(t.query)); + assert.ok(realized, 'missing realized-position (ctrl) series'); + assert.ok(setpoint, 'missing commanded-setpoint (ctrl.predicted.atequipment) series'); + // childId varies per pump → setpoint must be a regex (=~) prefix match. + assert.match(setpoint.query, /r\._field =~ \/\^ctrl\\\.predicted\\\.atequipment\\\.\//); + assert.ok(!/\.default/.test(setpoint.query), 'must not hardcode childId .default'); + // Legend disambiguation: each series suffixes its _measurement. + assert.match(realized.query, /\(realized\)/); + assert.match(setpoint.query, /\(setpoint\)/); +}); + +test('setpoint series is drawn dashed to distinguish it from realized', () => { + const { dash } = composeMgcWith(PUMPS); + const panel = dash.panels.find((p) => p.title === 'Pump % Control'); + const ov = panel.fieldConfig.overrides.find((o) => /setpoint/.test(o.matcher.options)); + assert.ok(ov, 'missing dashed override for setpoint series'); + assert.equal(ov.matcher.id, 'byRegexp'); + const lineStyle = ov.properties.find((p) => p.id === 'custom.lineStyle')?.value; + assert.equal(lineStyle?.fill, 'dash', 'setpoint must be dashed'); +}); + +test('per-pump flow/power match the position prefix (childId varies per pump)', () => { + const { dash } = composeMgcWith(PUMPS); + const flowQ = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand').targets[0].query; + const powerQ = dash.panels.find((p) => p.title === 'Pump Predicted Power').targets[0].query; + // Regex field match (=~), not an exact `.default` key, so it catches + // `flow.predicted.atequipment.` whatever the childId is. + assert.match(flowQ, /r\._field =~ \/\^flow\\\.predicted\\\.atequipment\\\.\//); + assert.match(powerQ, /r\._field =~ \/\^power\\\.predicted\\\.atequipment\\\.\//); + assert.ok(!/\.default/.test(flowQ), 'must not hardcode childId .default'); +}); + +test('measurement name falls back to _ when name is unset', () => { + const { dash } = composeMgcWith([{ id: 'p9', softwareType: 'rotatingmachine' }]); + const panel = dash.panels.find((p) => p.title === 'Pump % Control'); + assert.match(panel.targets[0].query, /r\._measurement == "rotatingmachine_p9"/); +}); + +test('flow panel folds in total flow, demand setpoint, demand %, and per-pump flow', () => { + const { dash } = composeMgcWith(PUMPS); + const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand'); + const queries = panel.targets.map((t) => t.query).join('\n'); + assert.match(queries, /flow\\\.predicted\\\.atequipment/, 'per-pump flow field'); + assert.match(queries, /atEquipment_predicted_flow/, 'group total flow field'); + assert.match(queries, /demandFlow/, 'resolved flow setpoint field'); + assert.match(queries, /demandPct/, 'demand percent field'); +}); + +test('flow capacity envelope is drawn as dashed min/max lines', () => { + const { dash } = composeMgcWith(PUMPS); + const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand'); + const byName = Object.fromEntries( + panel.fieldConfig.overrides.map((o) => [o.matcher.options, o.properties])); + for (const cap of ['flowCapacityMin', 'flowCapacityMax']) { + const props = byName[cap]; + assert.ok(props, `missing override for ${cap}`); + const lineStyle = props.find((p) => p.id === 'custom.lineStyle')?.value; + assert.equal(lineStyle?.fill, 'dash', `${cap} must be dashed`); + } +}); + +test('demand % is placed on a secondary (right) axis in percent', () => { + const { dash } = composeMgcWith(PUMPS); + const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand'); + const props = panel.fieldConfig.overrides.find((o) => o.matcher.options === 'demandPct')?.properties || []; + assert.equal(props.find((p) => p.id === 'unit')?.value, 'percent'); + assert.equal(props.find((p) => p.id === 'custom.axisPlacement')?.value, 'right'); +}); + +test('power panel folds total power in with per-pump power', () => { + const { dash } = composeMgcWith(PUMPS); + const panel = dash.panels.find((p) => p.title === 'Pump Predicted Power'); + const queries = panel.targets.map((t) => t.query).join('\n'); + assert.match(queries, /power\\\.predicted\\\.atequipment/, 'per-pump power field'); + assert.match(queries, /atEquipment_predicted_power/, 'group total power field'); +}); + +test('injected panels are exempt from the no-duplication dedup (empty emittedFields)', () => { + const { dash } = composeMgcWith(PUMPS); + const dynamic = dash.panels.filter((p) => p?.meta?.dynamic === 'mgc-pump-fanout'); + assert.equal(dynamic.length, 3); + for (const p of dynamic) assert.deepEqual(p.meta.emittedFields, []); +}); + +test('a machineGroup with no pump children keeps the static template panels', () => { + const { dash } = composeMgcWith([ + { id: 'm1', name: 'Meter', softwareType: 'measurement' }, + ]); + const titles = dash.panels.map((p) => p.title); + assert.ok(titles.includes('Total Flow'), 'static totals must remain when no pumps'); + assert.ok(!titles.includes('Pump % Control'), 'no fan-out panels without pumps'); +}); + +test('injected panels reuse the dashboard influxdb datasource uid', () => { + const { dash } = composeMgcWith(PUMPS); + const panel = dash.panels.find((p) => p.title === 'Pump % Control'); + assert.equal(panel.datasource.type, 'influxdb'); + assert.equal(panel.datasource.uid, 'cdzg44tv250jkd'); +});