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>
7.6 KiB
Reference — Limitations
Note
What
machineGroupControldoes not do, current rough edges, and open questions. The planner-decline question is tracked as Gitea issueRnD/machineGroupControl#1; other open items live in.agents/improvements/IMPROVEMENTS_BACKLOG.mdin the superproject.
When you would not use this node
| Scenario | Use instead |
|---|---|
| A single pump | Wire rotatingMachine directly under your parent. MGC's combinatorics + totals add no value below N=2. |
| Valves (no curve, no FSM-driven motor) | valveGroupControl. MGC's optimizer assumes a flow-vs-pressure characteristic. |
| Pumps behind independent headers | Multiple MGCs (one per header), each parented to its own logical aggregator. The equaliser assumes a shared discharge / suction pressure. |
| Curve-less assets | Without a curve, optimalControl excludes the machine from every combination; the dispatch loop falls into the empty-set branch and warns each tick. |
| Mixed compressor + pump groups | The optimizer is curve-agnostic in principle, but the η = (Q·ΔP)/P_shaft identity used in _optimalControl assumes an incompressible-flow head. Use separate MGCs per phase. |
Known limitations
maintenance mode is in the schema but not in the dispatch switch
config.mode.current accepts maintenance as a valid value (per the schema enum), but _runDispatch's mode switch only handles optimalcontrol and prioritycontrol. Picking maintenance will log 'maintenance' is not a valid mode. on every demand. Treated as schema-vs-code drift, not a runtime bug.
priorityControl bypasses the movement planner
equalFlowControl (the priority-mode strategy) still uses the legacy direct-dispatch path:
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', ...);
if (currentState === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
} else { ... shutdown ... }
}));
The planner is only wired through optimalControl. Consequence: priority-mode transitions can show a flow dip while one pump warms up and another keeps spinning. Tracked for a future pass; the planner's API is mode-agnostic so the surgery is straightforward when priorities allow.
mgc.scaling is undefined
The orchestrator no longer carries a scaling field — set.demand is unit-self-describing per message. The io/output.js formatter still references mgc.scaling, which always reads undefined. The status-badge cosmetically displays norm. This is a leftover artifact of the pre-refactor design; harmless, scheduled for removal.
Group efficiency naming — maxEfficiency is the mean, not the peak
GroupEfficiency.calcGroupEfficiency returns { maxEfficiency, lowestEfficiency }. maxEfficiency is the mean cog across all machines, not the maximum. The name is preserved for behavioural parity with the pre-refactor code; callers using it as "the peak" will over-estimate the BEP target. Tracked — rename is a follow-up.
calcAbsoluteTotals implicit pressure coupling
TotalsCalculator.calcAbsoluteTotals iterates a machine's predictFlow.inputCurve and re-indexes the SAME pressure key into predictPower.inputCurve. If the two curves were sampled at different pressures the lookup is undefined and the call throws. Mitigation deferred to the rotatingMachine curveLoader pass (P5).
Power-cap parameter has no canonical topic
handleInput(source, demand, powerCap) accepts a powerCap argument and threads it to validPumpCombinations, but there is no set.power-cap topic in commands/index.js. Only programmatic callers can set it. Tracked.
Per-pump fan-out not on Port 0
MGC's Port 0 carries the group aggregate only (atEquipment_predicted_flow, headerDiffPa, etc.). If you want per-pump trends on a dashboard you must wire each rotatingMachine's Port 0 separately. By design — the alternative would put N × M fields on the MGC payload.
Curve-less members silently drop out
combinatorics/pumpCombinations.validPumpCombinations filters by FSM state and mode but not by curve presence. A machine with predictFlow === null (because its curve loader failed at startup) has currentFxyYMin / Max = 0, so its contribution to subset envelopes is zero. It can still appear in subsets — the optimizer just gives it zero flow. The drop-out is silent; the only signal is the curve-loader's error log at startup.
Open questions (tracked)
| Question | Where it lives |
|---|---|
| Should the planner ever decline a combination when the slowest startup exceeds an SLA on demand spikes? | machineGroupControl#1 |
Wire the movement planner through priorityControl |
Internal — not yet ticketed |
Remove the mgc.scaling artifact + the scaling badge field |
Internal |
Rename maxEfficiency → meanGroupCog in GroupEfficiency |
Internal |
| Decline-and-fall-back vs always-commit on planner level | Same as the Gitea issue above |
Migration notes
From pre-planner
The MGC's _optimalControl used to fan commands out inline (lines 226–239 in 26e92b5^):
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', ...);
if (state === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
} else if (ACTIVE_STATES.has(state)) {
await machine.handleInput('parent', 'execsequence', 'shutdown');
}
}));
That code is gone. The new path: build profiles → scheduler.plan → executor.replan → await executor.tick() (synchronous first tick) → setInterval(1000) for the rest. The flow / power numbers and the optimizer's pick are unchanged; only the timing of the per-pump commands changed.
If your test fixture relied on commands firing inline during _runDispatch, the new behaviour fires fireAtTickN=0 commands synchronously inside the first await executor.tick() and later ones on the wall-clock interval. Tests that asserted exact timing should use the executor.schedule() introspection getter.
From pre-unit-self-describing demand
The old set.scaling topic and its persistent scaling.current config field have been removed. Each set.demand now carries its own unit context:
| Pre | Post |
|---|---|
set.scaling = "absolute"; set.demand = 80 |
set.demand = {value: 80, unit: "m3/h"} |
set.scaling = "normalized"; set.demand = 50 |
set.demand = 50 (bare number = %) |
set.scaling = "absolute"; set.demand = 0.022 (m³/s) |
set.demand = {value: 0.022, unit: "m3/s"} |
Old flows that still send set.scaling will silently ignore it; the topic is no longer registered.
From prioritypercentagecontrol
The mode prioritypercentagecontrol was retired with the unit-self-describing refactor. Use priorityControl with absolute-unit set.demand payloads, or optimalControl with the same.
Related pages
| Page | Why |
|---|---|
| Home | Intuitive overview |
| Reference — Contracts | Topic + config + child filters |
| Reference — Architecture | Code map, dispatch lifecycle, planner internals |
| Reference — Examples | Shipped flows + debug recipes |
| rotatingMachine — Limitations | The child's own limitations (drift, multi-parent, virtual-child stale data) |