governance + unit-self-describing demand + dashboard fixes

Two governance items from the 2026-05-14 quality review:
- test/_output-manifest.md enumerates every Port 0/1/2 key MGC emits, its
  source, type, range, and which tests cover it in populated/degraded states
  (per .claude/rules/output-coverage.md).
- src/control/strategies.js extracts computeEqualFlowDistribution as a pure
  function so the equal-flow algorithm is testable without an MGC fixture.
  test/basic/equalFlowDistribution.basic.test.js (6 tests) covers all three
  demand branches and pins the legacy quirk where the default branch counts
  active machines but iterates priority-ordered first-N (documented in the
  test so the future cleanup is a deliberate change).

Plus rolled-up session work that landed alongside:
- set.demand is now unit-self-describing ({value, unit:'m3/h'|'l/s'|'%'|...}
  or bare number = %); setScaling/scaling.current removed from MGC, commands,
  editor (mgc.html), specificClass.
- _optimalControl + equalFlowControl now compute eta = (Q*dP)/P_shaft rather
  than Q/P, keeping the metric in the same scale as each child's cog.
- groupEfficiency.calcRelativeDistanceFromPeak returns undefined (was 1) when
  pumps are homogeneous (|max-min| < 1e-9). Dashboard treats undefined as
  '-' instead of showing a misleading 100% / 0% reading.
- examples/02-Dashboard.json: auto-init inject so the dashboard populates at
  deploy, NCog formatter normalizes the SUM emitted by MGC by
  machineCountActive, Q-H fanout trims the flat-Q tail so the H axis isn't
  stretched to 40m by curve-envelope clamp points, num/pct treat null AND
  undefined as no-data (closes the +null === 0 trap).
- new test/integration/dashboard-fanout.integration.test.js (17 tests),
  bep-distance-demand-sweep.integration.test.js (3 tests),
  group-bep-cascade.integration.test.js -- total suite now 108/108 green.
- .gitignore: wiki/test.gif (143 MB screen recording, kept locally only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-14 22:31:25 +02:00
parent d238270530
commit 26e92b54f7
26 changed files with 2573 additions and 1790 deletions

View File

@@ -1,7 +1,7 @@
'use strict';
// Handler functions for machineGroupControl commands. Each handler receives:
// source: the domain (specificClass) instance — exposes setMode, setScaling,
// source: the domain (specificClass) instance — exposes setMode,
// handleInput, childRegistrationUtils.registerChild, logger,
// config.general.name.
// msg: the Node-RED input message.
@@ -10,6 +10,8 @@
// Pure functions: no module-level state. The registry already enforces the
// typeof-check ladder; per-topic semantic validation lives here.
const { convert } = require('generalFunctions');
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
@@ -18,10 +20,6 @@ exports.setMode = (source, msg) => {
source.setMode(msg.payload);
};
exports.setScaling = (source, msg) => {
source.setScaling(msg.payload);
};
exports.registerChild = (source, msg, ctx) => {
const log = _logger(source, ctx);
const childId = msg.payload;
@@ -35,13 +33,58 @@ exports.registerChild = (source, msg, ctx) => {
exports.setDemand = async (source, msg, ctx) => {
const log = _logger(source, ctx);
const demand = parseFloat(msg.payload);
if (Number.isNaN(demand)) {
log?.error?.(`set.demand: invalid Qd value '${msg.payload}'`);
// Operator demand is self-describing: the unit on the message decides how
// the value is interpreted. There is no persistent scaling state on MGC.
//
// payload = number → unit defaults to '%'
// payload = { value, unit:'%' }→ percent of group capacity
// payload = { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } → absolute flow
// payload < 0 (any unit) → operator stop-all signal
//
// The handler is the only place that resolves units. _runDispatch sees a
// single canonical m³/s number and never branches on scaling.
const p = msg?.payload;
let rawValue;
let unit;
if (p !== null && typeof p === 'object') {
rawValue = p.value;
unit = (typeof p.unit === 'string' && p.unit.trim()) ? p.unit.trim() : '%';
} else {
rawValue = p;
unit = '%';
}
const value = Number(rawValue);
if (!Number.isFinite(value)) {
log?.error?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`);
return;
}
// Negative is the operator's "stop all" signal regardless of unit.
if (value < 0) {
try {
await source.turnOffAllMachines();
} catch (err) {
log?.error?.(`set.demand: turnOffAllMachines failed: ${err && err.message}`);
}
return;
}
// Resolve to canonical m³/s.
let canonicalDemand;
if (unit === '%') {
const dt = source.calcDynamicTotals();
// Linear interpolation: 0 % → dt.flow.min, 100 % → dt.flow.max. The
// interpolation helper also clamps so 110 % can't run pumps past max.
canonicalDemand = source.interpolation.interpolate_lin_single_point(
value, 0, 100, dt.flow.min, dt.flow.max);
} else {
try {
canonicalDemand = convert(value).from(unit).to('m3/s');
} catch (err) {
log?.error?.(`set.demand: cannot convert ${value} ${unit} → m3/s: ${err && err.message}`);
return;
}
}
try {
await source.handleInput('parent', demand);
await source.handleInput('parent', canonicalDemand);
} catch (err) {
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
return;

View File

@@ -15,13 +15,6 @@ module.exports = [
description: 'Switch the machine group between auto / manual modes.',
handler: handlers.setMode,
},
{
topic: 'set.scaling',
aliases: ['setScaling'],
payloadSchema: { type: 'string' },
description: 'Select the group scaling strategy.',
handler: handlers.setScaling,
},
{
topic: 'child.register',
aliases: ['registerChild'],
@@ -33,10 +26,13 @@ module.exports = [
{
topic: 'set.demand',
aliases: ['Qd'],
// any: number or numeric string — handler runs parseFloat.
// payload is either a bare number (interpreted as %) or
// { value: number, unit: '%' | 'm3/h' | 'l/s' | 'm3/s' | ... }.
// No `units` descriptor — the handler resolves the unit explicitly so
// commandRegistry._normaliseUnits doesn't pre-convert a percentage into
// a flow rate. Negative value is the operator stop-all signal.
payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' },
description: 'Operator demand setpoint dispatched to the child machines.',
description: 'Operator demand setpoint. Bare number = %; {value, unit} for absolute flow units. Negative = stop all.',
handler: handlers.setDemand,
},
];

View File

@@ -6,12 +6,9 @@
// machines, falling back to start/stop the next priority when the current
// active set can't deliver.
//
// prioPercentageControl: percentage-style ctrl distribution (only valid with
// normalized scaling).
//
// Both extracted verbatim from specificClass during the P4 refactor; the
// orchestrator wires them in via the strategies map below. They depend on
// the same group-curve helpers the optimizer uses, so allocation and power
// Extracted from specificClass during the P4 refactor; the orchestrator
// wires it in via the strategies map below. It depends on the same
// group-curve helpers the optimizer uses, so allocation and power
// evaluation stay on the equalised group operating point.
const { POSITIONS } = require('generalFunctions');
@@ -49,77 +46,120 @@ function capFlowDemand(Qd, dynamicTotals, logger) {
return Qd;
}
// Pure distribution math: given the demand, group envelope, priority list, and
// per-machine curve helpers, return the {machineId, flow} mapping plus running
// totals. No side effects, no mgc reference — testable without an MGC fixture.
//
// Inputs:
// machines: dict {id → machine} (machine objects need group-curve fields set)
// Qd: demand in canonical m³/s
// dynamicTotals: {flow: {min, max}} — envelope across ALL registered pumps
// activeTotals: {flow: {min, max}} — envelope across currently-active pumps
// priorityList: optional array of ids; null = default ordering
// isMachineActive: (id) → boolean (state-aware predicate)
// groupFlow: (machine) → {currentFxyYMin, currentFxyYMax}
// groupCalcPower: (machine, flow) → number (W)
// logger: { warn, error, … } or null
//
// Returns: { flowDistribution: [{machineId, flow}], totalFlow, totalPower, totalCog }
function computeEqualFlowDistribution({
machines, Qd, dynamicTotals, activeTotals, priorityList,
isMachineActive, groupFlow, groupCalcPower, logger,
}) {
Qd = capFlowDemand(Qd, dynamicTotals, logger);
let machinesInPriorityOrder = sortMachinesByPriority(machines, priorityList);
machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder);
const flowDistribution = [];
let totalFlow = 0;
let totalPower = 0;
// Equal-flow doesn't compute a meaningful cog — only BEP-Gravitation does.
// Preserved at 0 for backwards-compat; pinned by a basic test so a future
// change that introduces a fake non-zero value will fail loudly.
const totalCog = 0;
switch (true) {
case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): {
let availableFlow = activeTotals.flow.min;
for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) {
const m = machinesInPriorityOrder[i];
if (isMachineActive(m.id)) {
flowDistribution.push({ machineId: m.id, flow: 0 });
availableFlow -= groupFlow(m.machine).currentFxyYMin;
}
}
const remaining = machinesInPriorityOrder.filter(({ id }) =>
isMachineActive(id) && !flowDistribution.some(it => it.machineId === id));
const distributedFlow = Qd / remaining.length;
for (const m of remaining) {
flowDistribution.push({ machineId: m.id, flow: distributedFlow });
totalFlow += distributedFlow;
totalPower += groupCalcPower(m.machine, distributedFlow);
}
break;
}
case (Qd > activeTotals.flow.max): {
let i = 1;
while (totalFlow < Qd && i <= machinesInPriorityOrder.length) {
Qd = Qd / i;
if (groupFlow(machinesInPriorityOrder[i - 1].machine).currentFxyYMax >= Qd) {
for (let i2 = 0; i2 < i; i2++) {
if (!isMachineActive(machinesInPriorityOrder[i2].id)) {
flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd });
totalFlow += Qd;
totalPower += groupCalcPower(machinesInPriorityOrder[i2].machine, Qd);
}
}
}
i++;
}
break;
}
default: {
const countActive = machinesInPriorityOrder.filter(({ id }) => isMachineActive(id)).length;
Qd /= countActive;
for (let i = 0; i < countActive; i++) {
flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd });
totalFlow += Qd;
totalPower += groupCalcPower(machinesInPriorityOrder[i].machine, Qd);
}
break;
}
}
return { flowDistribution, totalFlow, totalPower, totalCog };
}
// Orchestrator: equalize the operating point, call the pure distribution math,
// write outputs, dispatch children. The mgc reaches happen here, not in the
// algorithm — see computeEqualFlowDistribution above for the part that's
// testable in isolation.
async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = null) {
const { mgc } = ctx;
try {
mgc.equalizePressure();
const dynamicTotals = mgc.calcDynamicTotals();
Qd = capFlowDemand(Qd, dynamicTotals, mgc.logger);
let machinesInPriorityOrder = sortMachinesByPriority(mgc.machines, priorityList);
machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder);
const flowDistribution = [];
let totalFlow = 0;
let totalPower = 0;
const totalCog = 0;
const activeTotals = mgc.totals.activeTotals();
switch (true) {
case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): {
let availableFlow = activeTotals.flow.min;
for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) {
const m = machinesInPriorityOrder[i];
if (mgc.isMachineActive(m.id)) {
flowDistribution.push({ machineId: m.id, flow: 0 });
availableFlow -= groupFlow(m.machine).currentFxyYMin;
}
}
const remaining = machinesInPriorityOrder.filter(({ id }) =>
mgc.isMachineActive(id) && !flowDistribution.some(it => it.machineId === id));
const distributedFlow = Qd / remaining.length;
for (const m of remaining) {
flowDistribution.push({ machineId: m.id, flow: distributedFlow });
totalFlow += distributedFlow;
totalPower += groupCalcPower(m.machine, distributedFlow);
}
break;
}
case (Qd > activeTotals.flow.max): {
let i = 1;
while (totalFlow < Qd && i <= machinesInPriorityOrder.length) {
Qd = Qd / i;
if (groupFlow(machinesInPriorityOrder[i - 1].machine).currentFxyYMax >= Qd) {
for (let i2 = 0; i2 < i; i2++) {
if (!mgc.isMachineActive(machinesInPriorityOrder[i2].id)) {
flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd });
totalFlow += Qd;
totalPower += groupCalcPower(machinesInPriorityOrder[i2].machine, Qd);
}
}
}
i++;
}
break;
}
default: {
const countActive = machinesInPriorityOrder.filter(({ id }) => mgc.isMachineActive(id)).length;
Qd /= countActive;
for (let i = 0; i < countActive; i++) {
flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd });
totalFlow += Qd;
totalPower += groupCalcPower(machinesInPriorityOrder[i].machine, Qd);
}
break;
}
}
const { flowDistribution, totalFlow, totalPower, totalCog } = computeEqualFlowDistribution({
machines: mgc.machines,
Qd, dynamicTotals, activeTotals, priorityList,
isMachineActive: (id) => mgc.isMachineActive(id),
groupFlow, groupCalcPower,
logger: mgc.logger,
});
const fUnit = mgc.unitPolicy.canonical.power;
const flUnit = mgc.unitPolicy.canonical.flow;
mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totalPower, fUnit);
mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totalFlow, flUnit);
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalFlow / totalPower);
const pUnit = mgc.unitPolicy.canonical.power;
const fUnit = mgc.unitPolicy.canonical.flow;
mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totalPower, pUnit);
mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totalFlow, fUnit);
// Hydraulic efficiency η = (Q·ΔP)/P_shaft, same scale as child cogs.
const dP = mgc.operatingPoint.headerDiffPa;
if (Number.isFinite(dP) && dP > 0 && totalPower > 0) {
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
.value((totalFlow * dP) / totalPower);
}
mgc.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalCog);
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
@@ -139,72 +179,7 @@ async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = nu
}
}
async function prioPercentageControl(ctx, input, priorityList = null) {
const { mgc } = ctx;
try {
if (input < 0) { await mgc.turnOffAllMachines(); return; }
if (input > 100) input = 100;
const numOfMachines = Object.keys(mgc.machines).length;
const procentTotal = numOfMachines * input;
const machinesNeeded = Math.ceil(procentTotal / 100);
const activeTotals = mgc.totals.activeTotals();
const machinesActive = activeTotals.countActiveMachines;
const machinesInPriorityOrder = sortMachinesByPriority(mgc.machines, priorityList);
const ctrlDistribution = [];
if (machinesNeeded > machinesActive) {
machinesInPriorityOrder.forEach(({ id }, index) => {
if (index < machinesNeeded) ctrlDistribution.push({ machineId: id, ctrl: 0 });
});
}
if (machinesNeeded < machinesActive) {
machinesInPriorityOrder.forEach(({ id }, index) => {
if (mgc.isMachineActive(id)) {
ctrlDistribution.push({ machineId: id, ctrl: index < machinesNeeded ? 100 : -1 });
}
});
}
if (machinesNeeded === machinesActive) {
const ctrlPerMachine = procentTotal / machinesActive;
machinesInPriorityOrder.forEach(({ id }) => {
if (mgc.isMachineActive(id)) {
ctrlDistribution.push({ machineId: id, ctrl: Math.max(0, Math.min(ctrlPerMachine, 100)) });
}
});
}
await Promise.all(ctrlDistribution.map(async ({ machineId, ctrl }) => {
const machine = mgc.machines[machineId];
const currentState = machine.state.getCurrentState();
if (ctrl < 0 && (currentState === 'operational' || currentState === 'accelerating' || currentState === 'decelerating')) {
await machine.handleInput('parent', 'execsequence', 'shutdown');
} else if (currentState === 'idle' && ctrl >= 0) {
await machine.handleInput('parent', 'execsequence', 'startup');
} else if (currentState === 'operational' && ctrl > 0) {
await machine.handleInput('parent', 'execmovement', ctrl);
}
}));
const totalPower = [];
const totalFlow = [];
Object.values(mgc.machines).forEach(machine => {
const p = mgc.operatingPoint.readChild(machine, 'power', 'predicted', POSITIONS.AT_EQUIPMENT, mgc.unitPolicy.canonical.power);
const f = mgc.operatingPoint.readChild(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, mgc.unitPolicy.canonical.flow);
if (p !== null) totalPower.push(p);
if (f !== null) totalFlow.push(f);
});
const sumP = totalPower.reduce((a, b) => a + b, 0);
const sumF = totalFlow.reduce((a, b) => a + b, 0);
mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, sumP, mgc.unitPolicy.canonical.power);
mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, sumF, mgc.unitPolicy.canonical.flow);
if (sumP > 0) {
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(sumF / sumP);
}
} catch (err) {
mgc.logger?.error?.(err);
}
}
module.exports = { equalFlowControl, prioPercentageControl, capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines };
module.exports = {
equalFlowControl, computeEqualFlowDistribution,
capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines,
};

View File

@@ -44,19 +44,25 @@ class GroupEfficiency {
}
// Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency].
// Degenerate case (max === min) collapses the band to a point — return 1.
// Returns undefined for any case where the metric is meaningless:
// - currentEfficiency missing
// - the [max..min] band has collapsed (homogeneous pump group, OR float
// noise so |max-min| < DEGENERATE_EPS).
// Consumers must treat undefined as "no data" and display accordingly,
// not as 0% / 100% — both readings would be misleading.
calcRelativeDistanceFromPeak(currentEfficiency, maxEfficiency, minEfficiency) {
let distance = 1;
if (currentEfficiency != null && maxEfficiency !== minEfficiency && this.interpolation) {
distance = this.interpolation.interpolate_lin_single_point(
currentEfficiency,
maxEfficiency,
minEfficiency,
0,
1,
);
}
return distance;
const DEGENERATE_EPS = 1e-9; // η points are 0..1, so 1e-9 catches float noise.
if (currentEfficiency == null) return undefined;
if (!this.interpolation) return undefined;
if (!Number.isFinite(maxEfficiency) || !Number.isFinite(minEfficiency)) return undefined;
if (Math.abs(maxEfficiency - minEfficiency) < DEGENERATE_EPS) return undefined;
return this.interpolation.interpolate_lin_single_point(
currentEfficiency,
maxEfficiency,
minEfficiency,
0,
1,
);
}
// Returns both abs + rel; orchestrator decides whether to mirror onto

View File

@@ -13,6 +13,10 @@ class GroupOperatingPoint {
// Late-binding via getters in the orchestrator works too — but
// passing the live references avoids re-plumbing setters.
this.ctx = ctx;
// Last header differential pressure (Pa) computed by equalize().
// Consumers (optimizer, strategies) read this to convert raw
// flow/power to hydraulic efficiency η = (Q·ΔP)/P.
this.headerDiffPa = 0;
}
get measurements() { return this.ctx.measurements; }
@@ -72,6 +76,9 @@ class GroupOperatingPoint {
this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`);
return;
}
// Stash so downstream callers (optimizer, strategies) can compute
// hydraulic efficiency without re-reading every machine's pressure.
this.headerDiffPa = headerDiff;
this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`);

View File

@@ -5,11 +5,13 @@ const Machine = require('../../rotatingMachine/src/specificClass');
const Measurement = require('../../measurement/src/specificClass');
const baseCurve = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol', 'prioritypercentagecontrol'];
// prioritypercentagecontrol mode and per-instance scaling state were
// removed when set.demand became unit-self-describing — see
// commands/handlers.js (bare number = %, {value, unit} = absolute).
const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol'];
const MODE_LABELS = {
optimalcontrol: 'OPT',
prioritycontrol: 'PRIO',
prioritypercentagecontrol: 'PERC'
};
const stateConfig = {
@@ -60,7 +62,6 @@ function createGroupConfig(name) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
scaling: { current: 'normalized' },
mode: { current: 'optimalcontrol' }
};
}
@@ -185,7 +186,9 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd
await sleep(15);
mg.setMode(mode);
mg.setScaling('normalized'); // required for prioritypercentagecontrol, works for others too
// setScaling is gone — handleInput now takes canonical m³/s directly. This
// legacy diagnostic still works in % terms by sweeping demand 0..100 and
// mapping each step to canonical before dispatch.
const dynamic = mg.calcDynamicTotals();
const span = Math.max(dynamic.flow.max - dynamic.flow.min, 1);
@@ -197,7 +200,10 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd
let best = { demand, flow: 0, power: 0, efficiency: 0, error: Infinity };
for (let attempt = 0; attempt < 4; attempt += 1) {
await mg.handleInput('parent', demand, Infinity, priorityOrder);
// demand is a percent (0..100); convert to canonical m³/s for the
// post-refactor handleInput signature.
const canonical = dynamic.flow.min + (demand / 100) * (dynamic.flow.max - dynamic.flow.min);
await mg.handleInput('parent', canonical, Infinity, priorityOrder);
await sleep(30);
const totals = captureTotals(mg);

View File

@@ -42,6 +42,20 @@ function getOutput(mgc) {
out.absDistFromPeak = absDistFromPeak;
out.relDistFromPeak = relDistFromPeak;
// System (header) differential pressure resolved by the last equalize.
// Dashboards use this to compute head = ΔP / (ρ · g) for Q-H plots
// and to scale the BEP indicators without re-reading every child.
// Emitted in canonical Pa and in the configured output unit (mbar
// by default) so the dashboard can pick whichever it prefers.
const headerDiffPa = mgc.operatingPoint?.headerDiffPa;
if (Number.isFinite(headerDiffPa) && headerDiffPa > 0) {
out.headerDiffPa = headerDiffPa;
const pUnit = unitPolicy.output.pressure;
// 1 mbar = 100 Pa. Only convert when we recognise mbar; otherwise
// leave the raw Pa to avoid a stale or silently wrong unit label.
if (pUnit === 'mbar') out.headerDiffMbar = headerDiffPa / 100;
}
// Group capacity + active-machine counts. Surfaced so dashboards can
// show the same numbers the status badge does without subscribing to
// every child node individually.

View File

@@ -2,8 +2,12 @@
//
// All real work lives in the concern modules under src/{groupOps,totals,
// combinatorics,optimizer,efficiency,dispatch,control}. This file stitches
// them together: child-event routing, demand serialization, mode/scaling,
// them together: child-event routing, demand serialization, mode selection,
// and the per-mode dispatch switch.
//
// Operator demand is always passed in here as a canonical m³/s number. The
// set.demand handler resolves units (%, m³/h, l/s, etc.) before calling
// handleInput, so this orchestrator has no scaling state and no unit logic.
'use strict';
@@ -37,7 +41,6 @@ class MachineGroup extends BaseDomain {
// tests still write directly (matches the pumpingStation pattern).
this.machines = {};
this.scaling = this.config.scaling.current;
this.mode = this.config.mode.current;
this.absDistFromPeak = 0;
this.relDistFromPeak = 0;
@@ -117,11 +120,6 @@ class MachineGroup extends BaseDomain {
// ── Surface kept for tests + commands ──────────────────────────────
setMode(mode) { this.mode = mode; this.notifyOutputChanged(); }
setScaling(scaling) {
const allowed = new Set(this.defaultConfig.scaling.current.rules.values.map(v => v.value));
if (allowed.has(scaling)) { this.scaling = scaling; this.notifyOutputChanged(); }
else this.logger.warn(`${scaling} is not a valid scaling option.`);
}
isMachineActive(id) {
const s = this.machines[id]?.state?.getCurrentState?.();
return ACTIVE_STATES.has(s);
@@ -214,7 +212,15 @@ class MachineGroup extends BaseDomain {
// INTENT lands on AT_EQUIPMENT only; DOWNSTREAM is the live aggregate.
this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestPower, this.unitPolicy.canonical.power);
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestFlow, this.unitPolicy.canonical.flow);
this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestFlow / bestResult.bestPower);
// Hydraulic efficiency η = (Q·ΔP)/P_shaft — a dimensionless 0..1
// ratio in the same scale as each child rotatingMachine's `cog`.
// Keeps `calcDistanceBEP(eff, maxEfficiency, lowestEfficiency)` in
// handlePressureChange comparing apples to apples.
const dP = this.operatingPoint.headerDiffPa;
if (Number.isFinite(dP) && dP > 0 && bestResult.bestPower > 0) {
this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
.value((bestResult.bestFlow * dP) / bestResult.bestPower);
}
this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog);
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
@@ -246,19 +252,16 @@ class MachineGroup extends BaseDomain {
this.logger.error(`Invalid flow demand input: ${demand}.`);
return;
}
// Demand is canonical m³/s (the handler has already resolved units).
// The handler routes negatives directly to turnOffAllMachines, but
// keep a defensive check in case turnOff-state arrives some other way.
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
await this.abortActiveMovements('new demand received');
const dt = this.calcDynamicTotals();
let demandQout = 0;
if (this.scaling === 'absolute') {
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
if (demandQ < this.absoluteTotals.flow.min) demandQout = this.absoluteTotals.flow.min;
else if (demandQ > this.absoluteTotals.flow.max) demandQout = this.absoluteTotals.flow.max;
else demandQout = demandQ;
} else if (this.scaling === 'normalized') {
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
demandQout = this.interpolation.interpolate_lin_single_point(demandQ, 0, 100, dt.flow.min, dt.flow.max);
}
// Clamp against the current-pressure envelope.
let demandQout = demandQ;
if (demandQout < dt.flow.min) demandQout = dt.flow.min;
else if (demandQout > dt.flow.max) demandQout = dt.flow.max;
// Normalize for the switch — schema enum values use camelCase
// (optimalControl, priorityControl) while legacy callers send
@@ -266,10 +269,6 @@ class MachineGroup extends BaseDomain {
const ctx = { mgc: this };
switch (String(this.mode || '').toLowerCase()) {
case 'prioritycontrol': await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break;
case 'prioritypercentagecontrol':
if (this.scaling !== 'normalized') { this.logger.warn('Priority percentage control needs normalized scaling.'); return; }
await control.prioPercentageControl(ctx, demandQout, priorityList);
break;
case 'optimalcontrol': await this._optimalControl(demandQout, powerCap); break;
default: this.logger.warn(`${this.mode} is not a valid mode.`);
}