'use strict'; // Priority-based control strategies for machineGroupControl. // // equalFlowControl: distribute demand equally across priority-ordered active // machines, falling back to start/stop the next priority when the current // active set can't deliver. // // 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'); const { groupFlow, groupCalcPower } = require('../groupOps/groupCurves'); function sortMachinesByPriority(machines, priorityList) { if (priorityList && Array.isArray(priorityList)) { return priorityList .filter(id => machines[id]) .map(id => ({ id, machine: machines[id] })); } return Object.entries(machines) .map(([id, machine]) => ({ id, machine })) .sort((a, b) => a.id - b.id); } function filterOutUnavailableMachines(list) { return list.filter(({ machine }) => { const state = machine.state.getCurrentState(); const validActionForMode = machine.isValidActionForMode('execsequence', 'auto'); return !(state === 'off' || state === 'coolingdown' || state === 'stopping' || state === 'emergencystop' || !validActionForMode); }); } function capFlowDemand(Qd, dynamicTotals, logger) { if (Qd < dynamicTotals.flow.min && Qd > 0) { logger?.warn?.(`Flow demand ${Qd} below min ${dynamicTotals.flow.min}; capping.`); return dynamicTotals.flow.min; } if (Qd > dynamicTotals.flow.max) { logger?.warn?.(`Flow demand ${Qd} above max ${dynamicTotals.flow.max}; capping.`); return dynamicTotals.flow.max; } 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(); const activeTotals = mgc.totals.activeTotals(); const { flowDistribution, totalFlow, totalPower, totalCog } = computeEqualFlowDistribution({ machines: mgc.machines, Qd, dynamicTotals, activeTotals, priorityList, isMachineActive: (id) => mgc.isMachineActive(id), groupFlow, groupCalcPower, logger: mgc.logger, }); 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); // Route the chosen distribution through the shared planner/executor // path. With planner.useRendezvous=true (the default) all pumps // reach their per-pump flow target at the same wall-clock instant; // with it false, every command fires at tick 0 — same effect as // the legacy Promise.all dispatch but with correct startup/shutdown // ordering (the planner emits execsequence BEFORE flowmovement for // idle pumps, where the legacy code emitted them in the opposite // order and relied on the pump's delayedMove queue to recover). await mgc._dispatchFlowDistribution(flowDistribution); } catch (err) { mgc.logger?.error?.(err); } } module.exports = { equalFlowControl, computeEqualFlowDistribution, capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines, };