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>
87 lines
3.6 KiB
JavaScript
87 lines
3.6 KiB
JavaScript
'use strict';
|
||
|
||
// Per-machine time-parameterised plan. Pure: given a MachineProfile
|
||
// snapshot and a target position, computes how long the move will take.
|
||
//
|
||
// Cases by profile.state:
|
||
// idle / off startup ladder + ramp from min to target
|
||
// operational |target − position| / velocity
|
||
// accelerating |
|
||
// decelerating post-abort residue, same as operational
|
||
// starting remaining-in-starting + full warmup + ramp from min
|
||
// warmingup remaining-in-warmingup + ramp from min
|
||
// stopping | coolingdown non-interruptible deload; cannot contribute flow
|
||
// in this dispatch — returns null so the scheduler
|
||
// can exclude the machine from "up" candidates.
|
||
//
|
||
// Velocity of 0 returns Infinity (misconfigured speed) so the scheduler
|
||
// can demote the machine without crashing.
|
||
|
||
const ACTIVE_OPERATIONAL = new Set(['operational', 'accelerating', 'decelerating']);
|
||
const STARTUP_LADDER = new Set(['starting', 'warmingup']);
|
||
const SHUTDOWN_LADDER = new Set(['stopping', 'coolingdown']);
|
||
|
||
class MoveTrajectory {
|
||
constructor(profile, { targetPosition } = {}) {
|
||
if (!profile || typeof profile !== 'object') {
|
||
throw new TypeError('MoveTrajectory: profile is required');
|
||
}
|
||
if (!Number.isFinite(targetPosition)) {
|
||
throw new TypeError('MoveTrajectory: targetPosition must be a finite number');
|
||
}
|
||
this.profile = profile;
|
||
this.targetPosition = this._clampToBounds(targetPosition);
|
||
}
|
||
|
||
_clampToBounds(p) {
|
||
const { minPosition, maxPosition } = this.profile;
|
||
if (Number.isFinite(minPosition) && p < minPosition) return minPosition;
|
||
if (Number.isFinite(maxPosition) && p > maxPosition) return maxPosition;
|
||
return p;
|
||
}
|
||
|
||
// Seconds from "fire" until the machine is delivering flow at
|
||
// targetPosition. Null when the machine is in a non-contributing
|
||
// (shutting-down) state.
|
||
etaToTargetS() {
|
||
const p = this.profile;
|
||
const v = p.velocityPctPerS;
|
||
const target = this.targetPosition;
|
||
|
||
if (SHUTDOWN_LADDER.has(p.state)) return null;
|
||
|
||
if (!Number.isFinite(v) || v <= 0) return Infinity;
|
||
|
||
if (p.state === 'operational' || ACTIVE_OPERATIONAL.has(p.state)) {
|
||
const dist = Math.abs(target - p.position);
|
||
return dist / v;
|
||
}
|
||
|
||
if (p.state === 'warmingup') {
|
||
// Remaining warmup, then ramp from minPosition to target.
|
||
// Ramp starts from minPosition because the pump is not moving
|
||
// during warmup — position is held at min.
|
||
const remW = p.remainingTransitionS ?? p.timings.warmingupS;
|
||
const rampDist = Math.max(0, target - p.minPosition);
|
||
return remW + rampDist / v;
|
||
}
|
||
|
||
if (p.state === 'starting') {
|
||
// Remaining-in-starting + full warmup duration + ramp from min.
|
||
const remS = p.remainingTransitionS ?? p.timings.startingS;
|
||
const rampDist = Math.max(0, target - p.minPosition);
|
||
return remS + p.timings.warmingupS + rampDist / v;
|
||
}
|
||
|
||
// idle / off / emergencystop / maintenance / any non-active state
|
||
// not in the ladders: full startup sequence to operational, then ramp.
|
||
const rampDist = Math.max(0, target - p.minPosition);
|
||
return p.timings.startingS + p.timings.warmingupS + rampDist / v;
|
||
}
|
||
}
|
||
|
||
MoveTrajectory.SHUTDOWN_LADDER = SHUTDOWN_LADDER;
|
||
MoveTrajectory.STARTUP_LADDER = STARTUP_LADDER;
|
||
|
||
module.exports = MoveTrajectory;
|