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

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