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) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-27 16:09:29 +02:00
parent 990a8c09ea
commit 5533293647
3 changed files with 382 additions and 3 deletions

View File

@@ -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` \|\| `<softwareType>_<id>`) 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` \|\| `<softwareType>_<id>`) 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\<string\> | 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\<string\> | populated dashboard | empty set for `null`/`{}`/`{panels:[]}` | `test/basic/slice37-emitted-fields.basic.test.js` |

View File

@@ -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.<pumpId>` 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 <softwareType>_<id> 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');
});