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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user