'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;