P4 wave 1: extract MGC concerns into focused modules

src/groupOps/        groupOperatingPoint + groupCurves (pure functions)
  src/totals/          totalsCalculator (dynamic + absolute + active)
  src/combinatorics/   pumpCombinations (validPumpCombinations + checkSpecialCases)
  src/optimizer/       bestCombination (CoG) + bepGravitation (BEP-G + marginal-cost)
  src/efficiency/      groupEfficiency (calc + distance helpers)
  src/dispatch/        demandDispatcher (LatestWinsGate-based; replaces
                       _dispatchInFlight + _delayedCall)
  src/commands/        canonical names from start (set.mode/scaling/demand,
                       child.register) + legacy aliases
  CONTRACT.md          inputs/outputs/events surface

53 basic tests pass (52 new + 1 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P4 wave 2.

Findings flagged via agents (TODO append to OPEN_QUESTIONS.md):
  - calcGroupEfficiency.maxEfficiency is actually the mean (misleading name)
  - checkSpecialCases has a no-op `return false` inside forEach
  - MGC doesn't route cmd.startup/shutdown/estop — confirm if station broadcasts need it

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-10 20:45:23 +02:00
parent ea2857fb25
commit 619b1311d2
21 changed files with 1895 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
// Pure subset/combination generators used by the optimizer.
// All callable through `ctx` so this file stays free of class state.
// `ctx` must provide:
// - groupCurves: { groupFlow, groupPower } (from ../groupOps/groupCurves)
// - logger (warn/debug)
// - readChildMeasurement(machine, type, variant, position, canonicalUnit)
// - POSITIONS, unitPolicy.canonical.flow
const EXCLUDED_STATES = new Set(['off', 'coolingdown', 'stopping', 'emergencystop']);
// Reduce demand by the flow that manually-driven operational machines
// are already delivering. Returns the adjusted Qd (may be < 0).
function checkSpecialCases(machines, Qd, ctx) {
const { logger, readChildMeasurement, POSITIONS, unitPolicy } = ctx;
const canonicalFlow = unitPolicy?.canonical?.flow;
Object.values(machines).forEach(machine => {
const state = machine.state?.getCurrentState?.();
const mode = machine.currentMode;
if (state !== 'operational') return;
if (mode !== 'virtualControl' && mode !== 'fysicalControl') return;
const measuredFlow = readChildMeasurement
? readChildMeasurement(machine, 'flow', 'measured', POSITIONS.DOWNSTREAM, canonicalFlow)
: undefined;
const predictedFlow = readChildMeasurement
? readChildMeasurement(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, canonicalFlow)
: undefined;
let flow = 0;
if (Number.isFinite(measuredFlow) && measuredFlow !== 0) {
flow = measuredFlow;
} else if (Number.isFinite(predictedFlow) && predictedFlow !== 0) {
flow = predictedFlow;
} else {
// Unrecoverable: a machine is producing flow we can't quantify.
// Caller decides whether to abort the dispatch tick.
logger?.error?.(
"Dont perform calculation at all seeing that there is a machine working but we dont know the flow its producing"
);
return;
}
Qd = Qd - flow;
});
return Qd;
}
// Generate all non-empty machine subsets that can deliver Qd within powerCap.
// Inputs that can't possibly contribute (off / coolingdown / mode-locked) are
// excluded before the power set is built, so 2^N stays small in practice.
function validPumpCombinations(machines, Qd, ctx, powerCap = Infinity) {
const { groupCurves } = ctx;
const groupFlow = groupCurves?.groupFlow;
const groupPower = groupCurves?.groupPower;
Qd = checkSpecialCases(machines, Qd, ctx);
let subsets = [[]];
Object.keys(machines).forEach(machineId => {
const machine = machines[machineId];
const state = machine.state?.getCurrentState?.();
const validActionForMode =
typeof machine.isValidActionForMode === 'function'
? machine.isValidActionForMode('execsequence', 'auto')
: true;
if (EXCLUDED_STATES.has(state) || !validActionForMode) return;
const newSubsets = subsets.map(set => [...set, machineId]);
subsets = subsets.concat(newSubsets);
});
return subsets.filter(subset => {
if (subset.length === 0) return false;
const { maxFlow, minFlow, maxPower } = subset.reduce(
(acc, machineId) => {
const machine = machines[machineId];
const f = groupFlow(machine);
const p = groupPower(machine);
return {
maxFlow: acc.maxFlow + f.currentFxyYMax,
minFlow: acc.minFlow + f.currentFxyYMin,
maxPower: acc.maxPower + p.currentFxyYMax,
};
},
{ maxFlow: 0, minFlow: 0, maxPower: 0 },
);
return maxFlow >= Qd && minFlow <= Qd && maxPower <= powerCap;
});
}
module.exports = { validPumpCombinations, checkSpecialCases, EXCLUDED_STATES };

58
src/commands/handlers.js Normal file
View File

@@ -0,0 +1,58 @@
'use strict';
// Handler functions for machineGroupControl commands. Each handler receives:
// source: the domain (specificClass) instance — exposes setMode, setScaling,
// handleInput, childRegistrationUtils.registerChild, logger,
// config.general.name.
// msg: the Node-RED input message.
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
//
// Pure functions: no module-level state. The registry already enforces the
// typeof-check ladder; per-topic semantic validation lives here.
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
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;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
if (!childObj || !childObj.source) {
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
};
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}'`);
return;
}
try {
await source.handleInput('parent', demand);
} catch (err) {
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
return;
}
// Reply on Port 0 with the configured node name as topic — preserves the
// legacy "done" handshake some downstream flows rely on.
if (typeof ctx?.send === 'function') {
const reply = Object.assign({}, msg, {
topic: source?.config?.general?.name,
payload: 'done',
});
ctx.send(reply);
}
};

37
src/commands/index.js Normal file
View File

@@ -0,0 +1,37 @@
'use strict';
// machineGroupControl command registry. Consumed by BaseNodeAdapter via
// `static commands = require('./commands')`. Each descriptor maps a
// canonical msg.topic to its handler; legacy names are listed under
// `aliases` and emit a one-time deprecation warning at runtime.
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
handler: handlers.setMode,
},
{
topic: 'set.scaling',
aliases: ['setScaling'],
payloadSchema: { type: 'string' },
handler: handlers.setScaling,
},
{
topic: 'child.register',
aliases: ['registerChild'],
// payload is the Node-RED id (string) of the child node.
payloadSchema: { type: 'string' },
handler: handlers.registerChild,
},
{
topic: 'set.demand',
aliases: ['Qd'],
// any: number or numeric string — handler runs parseFloat.
payloadSchema: { type: 'any' },
handler: handlers.setDemand,
},
];

View File

@@ -0,0 +1,38 @@
'use strict';
const { LatestWinsGate } = require('generalFunctions');
// Thin wrapper around LatestWinsGate for the MGC demand path. Replaces
// the original `_dispatchInFlight` + `_delayedCall` pair in
// specificClass.handleInput: a new demand arriving while a dispatch is
// in flight overwrites any pending one, so the latest value always wins
// and intermediates are dropped silently.
class DemandDispatcher {
constructor(ctx = {}, runFn) {
if (typeof runFn !== 'function') {
throw new TypeError('DemandDispatcher requires a runFn');
}
this.ctx = ctx;
this.logger = ctx.logger || null;
this._runFn = runFn;
this._gate = new LatestWinsGate(
async (demand) => this._runFn(demand, this.ctx),
{ logger: this.logger },
);
}
fire(demand) {
this._gate.fire(demand);
}
drain() {
return this._gate.drain();
}
get inFlight() {
return this._gate.size > 0;
}
}
module.exports = DemandDispatcher;

View File

@@ -0,0 +1,90 @@
'use strict';
// Aggregates per-machine efficiency (cog) into group-level metrics and
// computes distance-from-peak. Extracted verbatim from specificClass.js
// (calcGroupEfficiency / calcDistanceFromPeak / calcRelativeDistanceFromPeak /
// calcDistanceBEP) so the orchestrator can delegate without inheriting
// the arithmetic.
class GroupEfficiency {
constructor(ctx = {}) {
this.ctx = ctx;
this.logger = ctx.logger || null;
this.interpolation = ctx.interpolation || null;
this.measurements = ctx.measurements || null;
this.machines = ctx.machines || null;
}
// Average of per-machine cog plus the worst-performing machine's cog.
// `maxEfficiency` is misleadingly named — it is in fact the MEAN cog
// across all machines, treated as the group-level "peak" target.
// Kept that way for behavioural parity with the original.
calcGroupEfficiency(machines) {
const target = machines || this.machines;
let cumEfficiency = 0;
let machineCount = 0;
let lowestEfficiency = Infinity;
Object.entries(target || {}).forEach(([_id, machine]) => {
cumEfficiency += machine.cog;
if (machine.cog < lowestEfficiency) {
lowestEfficiency = machine.cog;
}
machineCount++;
});
const maxEfficiency = cumEfficiency / machineCount;
const currentEfficiency = this._readCurrentEfficiency();
return { maxEfficiency, lowestEfficiency, currentEfficiency };
}
calcDistanceFromPeak(currentEfficiency, peakEfficiency) {
return Math.abs(currentEfficiency - peakEfficiency);
}
// Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency].
// Degenerate case (max === min) collapses the band to a point — return 1.
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;
}
// Returns both abs + rel; orchestrator decides whether to mirror onto
// its own this.absDistFromPeak / this.relDistFromPeak fields.
calcDistanceBEP(currentEfficiency, maxEfficiency, minEfficiency) {
const absDistFromPeak = this.calcDistanceFromPeak(currentEfficiency, maxEfficiency);
const relDistFromPeak = this.calcRelativeDistanceFromPeak(
currentEfficiency,
maxEfficiency,
minEfficiency,
);
return { absDistFromPeak, relDistFromPeak };
}
// Pull the latest measured efficiency from the container if one was
// provided. Optional convenience — orchestrator may read it directly.
_readCurrentEfficiency() {
if (!this.measurements) return null;
try {
return this.measurements
.type('efficiency')
.variant('predicted')
.position('atequipment')
.getCurrentValue();
} catch (_err) {
return null;
}
}
}
module.exports = GroupEfficiency;

View File

@@ -0,0 +1,27 @@
// Group-scope read helpers for pump curves.
//
// Optimizers and totals evaluate each pump at the GROUP operating point
// (set by GroupOperatingPoint.equalize), not the pump's individual sensor-
// driven point. Each pump exposes a parallel "group*" predict object —
// these helpers fall back to the individual predicts when the pump hasn't
// been initialised for group operation yet (first tick after register).
function groupFlow(machine /*, ctx */) {
return machine.groupPredictFlow ?? machine.predictFlow;
}
function groupPower(machine /*, ctx */) {
return machine.groupPredictPower ?? machine.predictPower;
}
function groupNCog(machine /*, ctx */) {
return machine.groupPredictFlow ? (machine.groupNCog ?? 0) : (machine.NCog ?? 0);
}
function groupCalcPower(machine, flow /*, ctx */) {
return typeof machine.groupCalcPower === 'function'
? machine.groupCalcPower(flow)
: machine.inputFlowCalcPower(flow);
}
module.exports = { groupFlow, groupPower, groupNCog, groupCalcPower };

View File

@@ -0,0 +1,93 @@
const { POSITIONS } = require('generalFunctions');
// Group-scope measurement read/write + header equalization.
//
// Pulled out of specificClass during the P4 refactor: the equalization
// logic is the source of truth for the "one consistent header operating
// point" that the optimizer and totals modules both depend on. Keeping it
// in one place makes the order-of-operations explicit (read header, write
// onto every machine's group-scope predicts).
class GroupOperatingPoint {
constructor(ctx = {}) {
// ctx: { measurements, machines, unitPolicy, logger }
// Late-binding via getters in the orchestrator works too — but
// passing the live references avoids re-plumbing setters.
this.ctx = ctx;
}
get measurements() { return this.ctx.measurements; }
get machines() { return this.ctx.machines; }
get unitPolicy() { return this.ctx.unitPolicy; }
get logger() { return this.ctx.logger; }
readChild(machine, type, variant, position, unit = null) {
return machine?.measurements
?.type(type)
?.variant(variant)
?.position(position)
?.getCurrentValue(unit || undefined);
}
writeOwn(type, variant, position, value, unit = null, timestamp = Date.now()) {
if (!Number.isFinite(value)) return;
this.measurements
.type(type)
.variant(variant)
.position(position)
.value(value, timestamp, unit || undefined);
}
// Force every machine's predict-curve interpolators to use the same
// (header) differential pressure for MGC's optimization. See the
// original _equalizeOperatingPoint commentary in specificClass for
// the full rationale (header source order, fDimension fallback).
equalize() {
const machines = this.machines || {};
if (Object.keys(machines).length === 0) return;
const pressureUnit = this.unitPolicy.canonical.pressure;
const groupHeaderDown = this.measurements
.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM)
.getCurrentValue(pressureUnit);
const groupHeaderUp = this.measurements
.type('pressure').variant('measured').position(POSITIONS.UPSTREAM)
.getCurrentValue(pressureUnit);
const childDown = [];
const childUp = [];
Object.values(machines).forEach(machine => {
const d = this.readChild(machine, 'pressure', 'measured', POSITIONS.DOWNSTREAM, pressureUnit);
const u = this.readChild(machine, 'pressure', 'measured', POSITIONS.UPSTREAM, pressureUnit);
if (Number.isFinite(d) && d > 0) childDown.push(d);
if (Number.isFinite(u) && u > 0) childUp.push(u);
});
const downIsHeader = Number.isFinite(groupHeaderDown) && groupHeaderDown > 0;
const upIsHeader = Number.isFinite(groupHeaderUp) && groupHeaderUp > 0;
const headerDownstream = downIsHeader ? groupHeaderDown : (childDown.length ? Math.max(...childDown) : 0);
const headerUpstream = upIsHeader ? groupHeaderUp : (childUp.length ? Math.min(...childUp) : 0);
const headerDiff = headerDownstream - headerUpstream;
if (!Number.isFinite(headerDiff) || headerDiff <= 0) {
this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`);
return;
}
this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`);
Object.values(machines).forEach(machine => {
if (typeof machine.setGroupOperatingPoint === 'function') {
machine.setGroupOperatingPoint(headerDownstream, headerUpstream);
} else {
// Older rotatingMachine without the group API — direct
// fDimension write keeps demos working while submodules
// are rolled forward.
if (machine.predictFlow) machine.predictFlow.fDimension = headerDiff;
if (machine.predictPower) machine.predictPower.fDimension = headerDiff;
if (machine.predictCtrl) machine.predictCtrl.fDimension = headerDiff;
}
});
}
}
module.exports = GroupOperatingPoint;

View File

@@ -0,0 +1,188 @@
// BEP-gravitation optimizer: bias flow allocation toward each pump's BEP,
// then refine via marginal-cost swaps. `ctx` shape matches bestCombination.js.
const MC_ITER_CAP = 50; // marginal-cost refinement iterations
const MC_RELATIVE_EXIT = 0.001; // exit when the mc gap is < 0.1% of expensive.mc
// Estimate dP/dQ slopes around the BEP on the group operating point.
// Returns finite numbers for everything; falls back to zero slopes if the
// curve is flat or the machine has not been initialised.
function estimateSlopesAtBEP(machine, Q_BEP, ctx, delta = 1.0) {
const { groupCurves } = ctx;
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
const minFlow = groupFlow(machine).currentFxyYMin;
const maxFlow = groupFlow(machine).currentFxyYMax;
const span = Math.max(0, maxFlow - minFlow);
const normalizedCog = Math.max(0, Math.min(1, groupNCog(machine) || 0));
const targetBEP = Q_BEP ?? (minFlow + span * normalizedCog);
const clampFlow = (flow) => Math.min(maxFlow, Math.max(minFlow, flow));
const center = clampFlow(targetBEP);
const deltaSafe = Math.max(delta, 0.01);
const leftFlow = clampFlow(center - deltaSafe);
const rightFlow = clampFlow(center + deltaSafe);
const powerAt = (flow) => groupCalcPower(machine, flow);
const P_center = powerAt(center);
const P_left = powerAt(leftFlow);
const P_right = powerAt(rightFlow);
const slopeLeft = (P_center - P_left) / Math.max(1e-6, center - leftFlow);
const slopeRight = (P_right - P_center) / Math.max(1e-6, rightFlow - center);
const alpha = Math.max(1e-6, (Math.abs(slopeLeft) + Math.abs(slopeRight)) / 2);
return { slopeLeft, slopeRight, alpha, Q_BEP: center, P_BEP: P_center };
}
// Redistribute `delta` across pumps using slope-derived weights; flatter
// curves attract more flow. Bounded: exits on zero progress or no capacity.
function redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional = true) {
const tolerance = 1e-3;
let remaining = delta;
const entryMap = new Map(flowDistribution.map(entry => [entry.machineId, entry]));
while (Math.abs(remaining) > tolerance) {
const increasing = remaining > 0;
const candidates = pumpInfos.map(info => {
const entry = entryMap.get(info.id);
if (!entry) return null;
const capacity = increasing ? info.maxFlow - entry.flow : entry.flow - info.minFlow;
if (capacity <= tolerance) return null;
const slope = increasing
? (directional ? info.slopes.slopeRight : info.slopes.alpha)
: (directional ? info.slopes.slopeLeft : info.slopes.alpha);
const weight = 1 / Math.max(1e-6, Math.abs(slope) || info.slopes.alpha || 1);
return { entry, capacity, weight };
}).filter(Boolean);
if (!candidates.length) break;
const weightSum = candidates.reduce((sum, c) => sum + c.weight * c.capacity, 0);
if (weightSum <= 0) break;
let progress = 0;
candidates.forEach(candidate => {
let share = (candidate.weight * candidate.capacity / weightSum) * Math.abs(remaining);
share = Math.min(share, candidate.capacity);
if (share <= 0) return;
if (increasing) candidate.entry.flow += share;
else candidate.entry.flow -= share;
progress += share;
});
if (progress <= tolerance) break;
remaining += increasing ? -progress : progress;
}
}
function _marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx) {
const { groupCalcPower } = ctx.groupCurves;
const mcDelta = Math.max(1e-6, (Qd / pumpInfos.length) * 0.005);
for (let iter = 0; iter < MC_ITER_CAP; iter++) {
const mcEntries = flowDistribution.map(entry => {
const info = pumpInfos.find(i => i.id === entry.machineId);
const pNow = groupCalcPower(info.machine, entry.flow);
const pUp = groupCalcPower(info.machine, Math.min(info.maxFlow, entry.flow + mcDelta));
return { entry, info, mc: (pUp - pNow) / mcDelta };
});
let expensive = null;
let cheap = null;
for (const e of mcEntries) {
if (e.entry.flow > e.info.minFlow + mcDelta && (!expensive || e.mc > expensive.mc)) expensive = e;
if (e.entry.flow < e.info.maxFlow - mcDelta && (!cheap || e.mc < cheap.mc)) cheap = e;
}
if (!expensive || !cheap || expensive === cheap) break;
if (expensive.mc - cheap.mc < expensive.mc * MC_RELATIVE_EXIT) break;
const before = groupCalcPower(expensive.info.machine, expensive.entry.flow)
+ groupCalcPower(cheap.info.machine, cheap.entry.flow);
const after = groupCalcPower(expensive.info.machine, expensive.entry.flow - mcDelta)
+ groupCalcPower(cheap.info.machine, cheap.entry.flow + mcDelta);
if (after < before) {
expensive.entry.flow -= mcDelta;
cheap.entry.flow += mcDelta;
} else {
break;
}
}
}
function calcBestCombinationBEPGravitation(combinations, Qd, ctx, method = 'BEP-Gravitation-Directional') {
const { machines, groupCurves } = ctx;
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
const directional = method === 'BEP-Gravitation-Directional';
let bestCombination = null;
let bestPower = Infinity;
let bestFlow = 0;
let bestCog = 0;
let bestDeviation = Infinity;
combinations.forEach(combination => {
const pumpInfos = combination.map(machineId => {
const machine = machines[machineId];
const minFlow = groupFlow(machine).currentFxyYMin;
const maxFlow = groupFlow(machine).currentFxyYMax;
const span = Math.max(0, maxFlow - minFlow);
const NCog = Math.max(0, Math.min(1, groupNCog(machine) || 0));
const estimatedBEP = minFlow + span * NCog;
const slopes = estimateSlopesAtBEP(machine, estimatedBEP, ctx);
return { id: machineId, machine, minFlow, maxFlow, NCog, Q_BEP: slopes.Q_BEP, slopes };
});
if (pumpInfos.length === 0) return;
const flowDistribution = pumpInfos.map(info => ({
machineId: info.id,
flow: Math.min(info.maxFlow, Math.max(info.minFlow, info.Q_BEP)),
}));
let totalFlow = flowDistribution.reduce((s, e) => s + e.flow, 0);
const delta = Qd - totalFlow;
if (Math.abs(delta) > 1e-6) {
redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional);
}
flowDistribution.forEach(entry => {
const info = pumpInfos.find(i => i.id === entry.machineId);
entry.flow = Math.min(info.maxFlow, Math.max(info.minFlow, entry.flow));
});
_marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx);
let totalPower = 0;
totalFlow = 0;
flowDistribution.forEach(entry => {
totalFlow += entry.flow;
const info = pumpInfos.find(i => i.id === entry.machineId);
totalPower += groupCalcPower(info.machine, entry.flow);
});
const totalCog = pumpInfos.reduce((s, info) => s + info.NCog, 0);
const deviation = pumpInfos.reduce((sum, info) => {
const entry = flowDistribution.find(item => item.machineId === info.id);
const deltaFlow = entry ? (entry.flow - info.Q_BEP) : 0;
return sum + (deltaFlow * deltaFlow) * (info.slopes.alpha || 1);
}, 0);
const shouldUpdate = totalPower < bestPower
|| (totalPower === bestPower && deviation < bestDeviation);
if (shouldUpdate) {
bestCombination = flowDistribution.map(e => ({ ...e }));
bestPower = totalPower;
bestFlow = totalFlow;
bestCog = totalCog;
bestDeviation = deviation;
}
});
return { bestCombination, bestPower, bestFlow, bestCog, bestDeviation, method };
}
module.exports = {
calcBestCombinationBEPGravitation,
estimateSlopesAtBEP,
redistributeFlowBySlope,
};

View File

@@ -0,0 +1,88 @@
// CoG-based combination optimizer.
// Pure function: picks the combination whose CoG-weighted flow allocation
// yields the lowest total power, clamped to each machine's curve envelope.
//
// `ctx` must provide:
// - machines: machineId -> machine
// - groupCurves: { groupFlow, groupNCog, groupCalcPower }
// - logger (optional, for debug traces)
const ROUND_2 = 100;
function calcBestCombination(combinations, Qd, ctx) {
const { machines, groupCurves, logger } = ctx;
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
let bestCombination = null;
let bestPower = Infinity;
let bestFlow = 0;
let bestCog = 0;
combinations.forEach(combination => {
const totalCoG = combination.reduce((sum, id) => {
return sum + Math.round((groupNCog(machines[id]) || 0) * ROUND_2) / ROUND_2;
}, 0);
// CoG-weighted initial distribution; if all CoGs are 0, split evenly.
let flowDistribution = combination.map(machineId => {
const machine = machines[machineId];
let flow;
if (totalCoG === 0) {
flow = Qd / combination.length;
} else {
flow = ((groupNCog(machine) || 0) / totalCoG) * Qd;
logger?.debug?.(`Machine Normalized CoG-based distribution ${machineId} flow: ${flow}`);
}
return { machineId, flow };
});
const clamped = flowDistribution.map(entry => {
const machine = machines[entry.machineId];
const min = groupFlow(machine).currentFxyYMin;
const max = groupFlow(machine).currentFxyYMax;
const clampedFlow = Math.min(max, Math.max(min, entry.flow));
return { ...entry, flow: clampedFlow, min, max, desired: entry.flow };
});
// Spill the unmet remainder once: distribute proportionally to each
// machine's *desired* share, weighted toward those with headroom.
let remainder = Qd - clamped.reduce((sum, entry) => sum + entry.flow, 0);
if (Math.abs(remainder) > 1e-6) {
const adjustable = clamped.filter(entry =>
remainder > 0 ? entry.flow < entry.max : entry.flow > entry.min,
);
const weightSum = adjustable.reduce((s, e) => s + e.desired, 0) || adjustable.length;
adjustable.forEach(entry => {
const weight = entry.desired / weightSum || 1 / adjustable.length;
const delta = remainder * weight;
const next = remainder > 0
? Math.min(entry.max, entry.flow + delta)
: Math.max(entry.min, entry.flow + delta);
remainder -= (next - entry.flow);
entry.flow = next;
});
}
flowDistribution = clamped;
let totalFlow = 0;
let totalPower = 0;
flowDistribution.forEach(({ machineId, flow }) => {
totalFlow += flow;
totalPower += groupCalcPower(machines[machineId], flow);
});
if (totalPower < bestPower) {
logger?.debug?.(`New best combination found: ${totalPower} < ${bestPower}`);
bestPower = totalPower;
bestFlow = totalFlow;
bestCog = totalCoG;
bestCombination = flowDistribution;
}
});
return { bestCombination, bestPower, bestFlow, bestCog };
}
module.exports = { calcBestCombination };

17
src/optimizer/index.js Normal file
View File

@@ -0,0 +1,17 @@
const cog = require('./bestCombination');
const bep = require('./bepGravitation');
// Pick the optimizer module by config string.
// Anything other than the two BEP variants falls back to CoG.
function pickOptimizer(method) {
if (method === 'BEP-Gravitation' || method === 'BEP-Gravitation-Directional') return bep;
return cog;
}
module.exports = {
pickOptimizer,
calcBestCombination: cog.calcBestCombination,
calcBestCombinationBEPGravitation: bep.calcBestCombinationBEPGravitation,
estimateSlopesAtBEP: bep.estimateSlopesAtBEP,
redistributeFlowBySlope: bep.redistributeFlowBySlope,
};

View File

@@ -0,0 +1,117 @@
const { POSITIONS } = require('generalFunctions');
const { groupFlow, groupPower, groupNCog } = require('../groupOps/groupCurves');
// Aggregations across every machine in the group.
//
// calcAbsoluteTotals scans the full input-curve envelope (worst/best case
// over the pump's entire pressure range). calcDynamicTotals reads the
// current group operating point (after equalize). activeTotals only sums
// machines that are operationally active right now.
class TotalsCalculator {
constructor(ctx = {}) {
// ctx: { machines, unitPolicy, logger, operatingPoint, isMachineActive }
// operatingPoint is a GroupOperatingPoint instance (for readChild).
// isMachineActive is delegated back to the orchestrator so the
// state-machine vocabulary lives in one place.
this.ctx = ctx;
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 };
this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
}
get machines() { return this.ctx.machines || {}; }
get unitPolicy() { return this.ctx.unitPolicy; }
get logger() { return this.ctx.logger; }
get operatingPoint() { return this.ctx.operatingPoint; }
isMachineActive(id) {
if (typeof this.ctx.isMachineActive === 'function') return this.ctx.isMachineActive(id);
const s = this.machines[id]?.state?.getCurrentState?.();
return s === 'operational' || s === 'accelerating' || s === 'decelerating';
}
calcAbsoluteTotals() {
const out = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
Object.values(this.machines).forEach(machine => {
const totals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
Object.entries(machine.predictFlow.inputCurve).forEach(([pressure, xyCurve]) => {
const minFlow = Math.min(...xyCurve.y);
const maxFlow = Math.max(...xyCurve.y);
const minPower = Math.min(...machine.predictPower.inputCurve[pressure].y);
const maxPower = Math.max(...machine.predictPower.inputCurve[pressure].y);
if (minFlow < totals.flow.min) totals.flow.min = minFlow;
if (minPower < totals.power.min) totals.power.min = minPower;
if (maxFlow > totals.flow.max) totals.flow.max = maxFlow;
if (maxPower > totals.power.max) totals.power.max = maxPower;
});
if (totals.flow.min < out.flow.min) out.flow.min = totals.flow.min;
if (totals.power.min < out.power.min) out.power.min = totals.power.min;
out.flow.max += totals.flow.max;
out.power.max += totals.power.max;
});
// Empty-group + sentinel reset: Infinity / -Infinity are math
// artefacts of the reducer's initial values; downstream code
// expects clean zeros.
if (out.flow.min === Infinity) { this.logger?.warn?.('Flow min Infinity — zeroing.'); out.flow.min = 0; }
if (out.power.min === Infinity) { this.logger?.warn?.('Power min Infinity — zeroing.'); out.power.min = 0; }
if (out.flow.max === -Infinity) { this.logger?.warn?.('Flow max -Infinity — zeroing.'); out.flow.max = 0; }
if (out.power.max === -Infinity) { this.logger?.warn?.('Power max -Infinity — zeroing.'); out.power.max = 0; }
this.absoluteTotals = out;
return out;
}
calcDynamicTotals() {
const out = { flow: { min: Infinity, max: 0, act: 0 }, power: { min: Infinity, max: 0, act: 0 }, NCog: 0 };
const fUnit = this.unitPolicy.canonical.flow;
const pUnit = this.unitPolicy.canonical.power;
Object.values(this.machines).forEach(machine => {
if (!machine.hasCurve) {
this.logger?.error?.(`Machine ${machine.config?.general?.id} has no valid curve — skipping.`);
return;
}
const gpf = groupFlow(machine);
const gpp = groupPower(machine);
const minFlow = gpf.currentFxyYMin;
const maxFlow = gpf.currentFxyYMax;
const minPower = gpp.currentFxyYMin;
const maxPower = gpp.currentFxyYMax;
const actFlow = this.operatingPoint?.readChild(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, fUnit) || 0;
const actPower = this.operatingPoint?.readChild(machine, 'power', 'predicted', POSITIONS.AT_EQUIPMENT, pUnit) || 0;
if (minFlow < out.flow.min) out.flow.min = minFlow;
if (minPower < out.power.min) out.power.min = minPower;
out.flow.max += maxFlow;
out.power.max += maxPower;
out.flow.act += actFlow;
out.power.act += actPower;
out.NCog += groupNCog(machine);
});
this.dynamicTotals = out;
return out;
}
activeTotals() {
const out = { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 }, countActiveMachines: 0 };
Object.entries(this.machines).forEach(([id, machine]) => {
if (!this.isMachineActive(id)) return;
const gpf = groupFlow(machine);
const gpp = groupPower(machine);
out.flow.min += gpf.currentFxyYMin;
out.flow.max += gpf.currentFxyYMax;
out.power.min += gpp.currentFxyYMin;
out.power.max += gpp.currentFxyYMax;
out.countActiveMachines += 1;
});
return out;
}
}
module.exports = TotalsCalculator;