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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user