Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
183 lines
8.2 KiB
JavaScript
183 lines
8.2 KiB
JavaScript
'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,
|
|
};
|