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:
96
src/combinatorics/pumpCombinations.js
Normal file
96
src/combinatorics/pumpCombinations.js
Normal 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
58
src/commands/handlers.js
Normal 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
37
src/commands/index.js
Normal 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,
|
||||
},
|
||||
];
|
||||
38
src/dispatch/demandDispatcher.js
Normal file
38
src/dispatch/demandDispatcher.js
Normal 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;
|
||||
90
src/efficiency/groupEfficiency.js
Normal file
90
src/efficiency/groupEfficiency.js
Normal 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;
|
||||
27
src/groupOps/groupCurves.js
Normal file
27
src/groupOps/groupCurves.js
Normal 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 };
|
||||
93
src/groupOps/groupOperatingPoint.js
Normal file
93
src/groupOps/groupOperatingPoint.js
Normal 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;
|
||||
188
src/optimizer/bepGravitation.js
Normal file
188
src/optimizer/bepGravitation.js
Normal 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,
|
||||
};
|
||||
88
src/optimizer/bestCombination.js
Normal file
88
src/optimizer/bestCombination.js
Normal 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
17
src/optimizer/index.js
Normal 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,
|
||||
};
|
||||
117
src/totals/totalsCalculator.js
Normal file
117
src/totals/totalsCalculator.js
Normal 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;
|
||||
Reference in New Issue
Block a user