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>
Two governance items from the 2026-05-14 quality review:
- test/_output-manifest.md enumerates every Port 0/1/2 key MGC emits, its
source, type, range, and which tests cover it in populated/degraded states
(per .claude/rules/output-coverage.md).
- src/control/strategies.js extracts computeEqualFlowDistribution as a pure
function so the equal-flow algorithm is testable without an MGC fixture.
test/basic/equalFlowDistribution.basic.test.js (6 tests) covers all three
demand branches and pins the legacy quirk where the default branch counts
active machines but iterates priority-ordered first-N (documented in the
test so the future cleanup is a deliberate change).
Plus rolled-up session work that landed alongside:
- set.demand is now unit-self-describing ({value, unit:'m3/h'|'l/s'|'%'|...}
or bare number = %); setScaling/scaling.current removed from MGC, commands,
editor (mgc.html), specificClass.
- _optimalControl + equalFlowControl now compute eta = (Q*dP)/P_shaft rather
than Q/P, keeping the metric in the same scale as each child's cog.
- groupEfficiency.calcRelativeDistanceFromPeak returns undefined (was 1) when
pumps are homogeneous (|max-min| < 1e-9). Dashboard treats undefined as
'-' instead of showing a misleading 100% / 0% reading.
- examples/02-Dashboard.json: auto-init inject so the dashboard populates at
deploy, NCog formatter normalizes the SUM emitted by MGC by
machineCountActive, Q-H fanout trims the flat-Q tail so the H axis isn't
stretched to 40m by curve-envelope clamp points, num/pct treat null AND
undefined as no-data (closes the +null === 0 trap).
- new test/integration/dashboard-fanout.integration.test.js (17 tests),
bep-distance-demand-sweep.integration.test.js (3 tests),
group-bep-cascade.integration.test.js -- total suite now 108/108 green.
- .gitignore: wiki/test.gif (143 MB screen recording, kept locally only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each fixture's machineConfig() now passes asset: { model, unit } only —
the supplier / category / type strings are derived at runtime via
assetResolver in rotatingMachine's _setupCurves. Six integration tests
updated. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
specificClass.js 319 → 311 lines. Removed inline _dispatchInFlight +
_delayedCall + finally block. handleInput is now a 1-line delegate
to DemandDispatcher.fireAndWait({source, demand, ...}).
turnOffAllMachines calls _demandDispatcher.cancelPending().
DemandDispatcher 39 → 53 lines. One integration test rewritten to
use the new sentinel-resolution semantics. 77/77 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PS calls turnOffAllMachines on every tick once level < stopLevel. Two
ways the pump could re-engage after we shut it down:
1. _delayedCall: a 1% dead-zone keep-alive parked in MGC's deferred
dispatch fires from the in-flight handleInput's finally block AFTER
the shutdown completes, dispatching flow + startup to a fresh pump.
Clear _delayedCall at the top of turnOff.
2. Concurrent shutdown calls on the same pump interrupt each other
before the sequence can transition past stopping. Track shutdown-
in-flight per pump and skip if one is already underway.
Together with the rotatingMachine delayedMove-clearing fix, this lets
the level-based hysteresis cycle complete: pumps shut off cleanly at
stopLevel, basin reverses direction, refills to startLevel, repeat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scenario 5 covers 100% → 0% → 100% with the second 100% landing
mid-shutdown (stopping/coolingdown) — exercises the path where
delayedMove must NOT be saved on a non-idle non-residue state without
a follow-up startup, since transitionToState('idle') doesn't fire it.
Scenario 6 walks 10%→100%→10% monotonically and asserts the down-sweep's
final demand is honoured (catches the user's observed "stuck around
60% going up, no reaction going down" symptom — where pumps would
otherwise freeze at a stale setpoint from the up-sweep).
Both pass with the current MGC dispatch fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the dispatch loop only fired flowmovement for pumps in
'operational' or transitioned 'idle' pumps via execsequence-startup-then-flowmovement.
Pumps mid-startup (starting/warmingup) were silently skipped. With PS
sending demand every tick, intermediate setpoints during the startup
window never reached the pump — it locked onto the very first
snapshot's flowmovement and froze there.
Now flowmovement is sent regardless of state and rotatingMachine's
state.moveTo handles the queueing (delayedMove for transients, unpark
for residue, immediate for operational). Crucially, flowmovement runs
BEFORE execsequence-startup so the FIRST call's stale setpoint can't
land on an already-operational pump and overwrite the latest
delayedMove that fires at end of startup.
Adds three integration tests:
- demand-cycle-walkthrough: 0..100% sweep with clean per-step table
- idle-startup-deadlock: four scenarios that pin the dispatch behaviour
including the regression guard for varying-demand-during-startup
- optimizer-combination-choice: physical-validity invariants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### Bug fix — stale flow cache on shutdown (specificClass.js)
When turnOffAllMachines() fires (negative demand, zero flow demand, or
safety trip), the MGC was only shutting pumps down. The pumps' last
emitted predicted flow / power stayed in the MeasurementContainer,
so the parent pumpingStation kept computing net flow from cached
non-zero values — reading the MGC as "still draining" when it wasn't.
Net: net-flow direction and safety triggers misfired during and
shortly after an MGC shutdown.
Fix: after shutting down all machines, write 0 to the predicted
flow (downstream + atEquipment) and predicted power (atEquipment)
slots so the cache reflects reality immediately.
### Correctness — async/await on shutdown (specificClass.js)
Two call sites invoked turnOffAllMachines() without awaiting it, so
the subsequent `return` raced the shutdown promises. Now awaited.
Also DRY'd one inline shutdown loop into a call to
turnOffAllMachines().
### Physics correction — NCog for centrifugal pumps (integration tests)
The previous tests asserted NCog > 0 for centrifugal pumps. That's
physically wrong: for variable-speed centrifugal pumps P ∝ n³ and
Q ∝ n, so Q/P ∝ 1/n² is monotonically decreasing with speed. Peak
efficiency (peak Q/P) is always at minimum speed → cogIndex = 0 →
NCog = 0 by the current formula.
Tests now:
- Assert NCog == 0 for all centrifugal configurations
- Assert distributeByNCog() falls back to equal distribution when
NCog == 0 (confirmed by the existing tests 4-6 that slope-based
redistribution is what actually differentiates pumps with different
BEPs — not NCog)
This matches the actual implementation; the previous tests were
asserting an idealised COG model that doesn't apply here.
### Editor hygiene (mgc.html, nodeClass.js)
- mgc.html: add missing asset-menu defaults (uuid, supplier, category,
assetType, model, unit) — brings MGC in line with rotatingMachine
and pumpingStation editor shapes.
- nodeClass.js: clear node status badge on close.
All 13 tests (basic + integration) pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix flowmovement unit mismatch: MGC computed flow in canonical (m³/s)
but rotatingMachine expects output units (m³/h). All flowmovement calls
now convert via _canonicalToOutputFlow(). Without this fix, every pump
stayed at minimum flow regardless of demand.
- Fix absolute scaling: demandQout vs demandQ comparison bug, reorder
conditions so <= 0 is checked first, add else branch for valid demand.
- Fix empty Qd <= 0 block: now calls turnOffAllMachines().
- Add empty-machines guards on optimalControl and equalizePressure.
- Add null fallback (|| 0) on pressure measurement reads.
- Fix division-by-zero in calcRelativeDistanceFromPeak.
- Fix missing flowmovement after startup in equalFlowControl.
- Add marginal-cost refinement loop in BEP-Gravitation: after slope-based
redistribution, iteratively shifts flow from highest actual dP/dQ to
lowest using real power evaluations. Closes gap to brute-force optimum
from 2.1% to <0.1% without affecting combination selection stability.
- Add NCog distribution comparison tests and brute-force power table test.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>