Files
machineGroupControl/src/movement/moveTrajectory.js
znetsixe 472402c62d feat(mgc): rendezvous planner — same-time landing across all modes
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>
2026-05-17 19:43:55 +02:00

87 lines
3.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;