Skip to main content

elevator_core/
movement.rs

1//! Trapezoidal velocity-profile movement physics.
2//!
3//! [`tick_movement`](crate::movement::tick_movement) is the linear
4//! integrator used for shafts, tethers, and any
5//! [`LineKind::Linear`](crate::components::LineKind::Linear) topology.
6//! [`tick_movement_cyclic`](crate::movement::tick_movement_cyclic) (gated
7//! by the `loop_lines` feature) wraps the same physics in cyclic-position
8//! semantics for [`LineKind::Loop`](crate::components::LineKind::Loop):
9//! position is always normalised into `[0, circumference)`, ticks that
10//! would jump past the seam wrap correctly, and
11//! [`headway_clamp_target`](crate::movement::headway_clamp_target)
12//! enforces the no-overtake invariant when multiple cars share a loop.
13
14/// Distance required to brake to a stop from a given velocity at a fixed
15/// deceleration rate.
16///
17/// Uses the standard kinematic formula `v² / (2·a)`. Returns `0.0` for a
18/// stationary object or a non-positive deceleration (defensive: avoids
19/// division-by-zero / negative-distance footguns in consumer code).
20#[must_use]
21pub fn braking_distance(velocity: f64, deceleration: f64) -> f64 {
22    if deceleration <= 0.0 {
23        return 0.0;
24    }
25    let speed = velocity.abs();
26    speed * speed / (2.0 * deceleration)
27}
28
29/// Result of one tick of movement physics.
30#[derive(Debug, Clone, Copy)]
31pub struct MovementResult {
32    /// Current position after this tick.
33    pub position: f64,
34    /// Current velocity after this tick.
35    pub velocity: f64,
36    /// Whether the elevator has arrived at the target.
37    pub arrived: bool,
38}
39
40/// Advance position/velocity toward a target using a trapezoidal velocity profile.
41///
42/// - `position`: current position
43/// - `velocity`: current velocity (signed)
44/// - `target_position`: where we want to be
45/// - `max_speed`: maximum speed magnitude
46/// - `acceleration`: acceleration rate (positive)
47/// - `deceleration`: deceleration rate (positive)
48/// - `dt`: time step
49#[must_use]
50pub fn tick_movement(
51    position: f64,
52    velocity: f64,
53    target_position: f64,
54    max_speed: f64,
55    acceleration: f64,
56    deceleration: f64,
57    dt: f64,
58) -> MovementResult {
59    const EPSILON: f64 = 1e-9;
60
61    let displacement = target_position - position;
62
63    // Already at target and stationary.
64    if displacement.abs() < EPSILON && velocity.abs() < EPSILON {
65        return MovementResult {
66            position: target_position,
67            velocity: 0.0,
68            arrived: true,
69        };
70    }
71
72    let sign = displacement.signum();
73    let distance_remaining = displacement.abs();
74    let speed = velocity.abs();
75    let safe_decel = deceleration.max(EPSILON);
76    let stopping_distance = speed * speed / (2.0 * safe_decel);
77    // Opposing direction: car is moving away from the (possibly retargeted)
78    // destination. Must brake at `deceleration` before accelerating back —
79    // not at `acceleration`, which is the wrong physics when accel ≠ decel.
80    let opposing = velocity * sign < 0.0;
81
82    let new_velocity = if opposing || stopping_distance >= distance_remaining - EPSILON {
83        // Decelerate
84        let v = crate::fp::fma(-safe_decel * dt, velocity.signum(), velocity);
85        // Clamp to zero if sign would flip.
86        if velocity > 0.0 && v < 0.0 || velocity < 0.0 && v > 0.0 {
87            0.0
88        } else {
89            v
90        }
91    } else if speed < max_speed {
92        // Accelerate toward target
93        let v = crate::fp::fma(acceleration * dt, sign, velocity);
94        // Clamp magnitude to max_speed
95        if v.abs() > max_speed {
96            sign * max_speed
97        } else {
98            v
99        }
100    } else {
101        // Cruise
102        sign * max_speed
103    };
104
105    let new_pos = crate::fp::fma(new_velocity, dt, position);
106
107    // Overshoot check: did we cross the target?
108    let new_displacement = target_position - new_pos;
109    if new_displacement.abs() < EPSILON || (new_displacement.signum() - sign).abs() > EPSILON {
110        return MovementResult {
111            position: target_position,
112            velocity: 0.0,
113            arrived: true,
114        };
115    }
116
117    MovementResult {
118        position: new_pos,
119        velocity: new_velocity,
120        arrived: false,
121    }
122}
123
124/// Advance position/velocity along a closed-loop axis using the same
125/// trapezoidal profile as [`tick_movement`], with the result wrapped
126/// into `[0, circumference)`.
127///
128/// One-way semantics: a Loop car always travels in the positive
129/// direction. The "distance to target" is the *forward cyclic distance*
130/// (`position` → `target_position` going forward), which is always
131/// non-negative and at most `circumference`. Backwards velocity is
132/// physically meaningful only as a transient artefact of the
133/// trapezoidal integrator's deceleration step; consumers should treat
134/// it as instantaneous and not propagate it as a steady state.
135///
136/// Seam crossing is handled implicitly: the integrator runs on a virtual
137/// linear axis pinned at `position = 0` with target `forward_distance`,
138/// then the output is mapped back onto the loop by adding the travelled
139/// distance to the original `position` and wrapping. A tick that would
140/// jump past `circumference` therefore lands at the correct cyclic
141/// position regardless of how far the seam is from the start.
142///
143/// Returns the unmodified `position` and `velocity = 0` with
144/// `arrived = false` when `circumference <= 0.0` or non-finite — the
145/// same defensive degradation pattern used by [`super::components::cyclic`].
146///
147/// # Arguments
148///
149/// - `position`: current position. Should be in `[0, circumference)`;
150///   inputs outside the range are wrapped before integration.
151/// - `velocity`: current signed velocity along the loop's forward axis.
152/// - `target_position`: where we want to be, in `[0, circumference)`.
153/// - `max_speed`, `acceleration`, `deceleration`, `dt`: identical to
154///   [`tick_movement`].
155/// - `circumference`: total loop length. Must be `> 0` and finite.
156#[cfg(feature = "loop_lines")]
157#[must_use]
158#[allow(
159    clippy::too_many_arguments,
160    reason = "mirrors tick_movement plus circumference; passing a struct adds boilerplate without clarifying the call site"
161)]
162pub fn tick_movement_cyclic(
163    position: f64,
164    velocity: f64,
165    target_position: f64,
166    max_speed: f64,
167    acceleration: f64,
168    deceleration: f64,
169    dt: f64,
170    circumference: f64,
171) -> MovementResult {
172    use crate::components::cyclic::{forward_distance, wrap_position};
173
174    if !circumference.is_finite() || circumference <= 0.0 {
175        return MovementResult {
176            position,
177            velocity: 0.0,
178            arrived: false,
179        };
180    }
181
182    let pos = wrap_position(position, circumference);
183    let target = wrap_position(target_position, circumference);
184    let fwd = forward_distance(pos, target, circumference);
185
186    // Run the proven linear integrator on a virtual axis: start at 0,
187    // target at `fwd`. Reuses every overshoot / opposing-direction /
188    // brake-distance branch in `tick_movement` rather than duplicating
189    // them in cyclic form.
190    let linear = tick_movement(
191        0.0,
192        velocity,
193        fwd,
194        max_speed,
195        acceleration,
196        deceleration,
197        dt,
198    );
199
200    if linear.arrived {
201        return MovementResult {
202            position: target,
203            velocity: 0.0,
204            arrived: true,
205        };
206    }
207
208    // `linear.position` is the distance travelled this tick (since the
209    // virtual start was 0). Apply it as a forward offset and wrap.
210    let new_pos = wrap_position(pos + linear.position, circumference);
211    MovementResult {
212        position: new_pos,
213        velocity: linear.velocity,
214        arrived: false,
215    }
216}
217
218/// Cyclic-aware no-overtake clamp on a trailing car's intended target.
219///
220/// Returns the *effective* target position the trailer should aim at,
221/// guaranteeing the trailer cannot end up within `min_headway` of the
222/// leader's tail along the forward direction. The math is purely cyclic:
223///
224/// - `gap = forward_distance(trailer, leader)` — slack ahead of the
225///   trailer right now
226/// - `safe_advance = max(0, gap - min_headway)` — how far the trailer
227///   can advance without violating headway
228/// - if the intended forward distance fits in `safe_advance`, return
229///   `intended` unchanged; otherwise return the trailer position
230///   advanced by exactly `safe_advance` (wrapped)
231///
232/// Coincident leader / trailer (gap = 0) collapses to "stay put" — the
233/// returned target is the trailer's own position, which the integrator
234/// interprets as "already arrived, decelerate to zero". Non-positive or
235/// non-finite `circumference` / `min_headway` short-circuits to
236/// returning `intended` unchanged so a misconfigured Loop degrades
237/// gracefully rather than silently parking every car at its current
238/// position.
239#[cfg(feature = "loop_lines")]
240#[must_use]
241pub fn headway_clamp_target(
242    trailer_position: f64,
243    leader_position: f64,
244    intended_target: f64,
245    min_headway: f64,
246    circumference: f64,
247) -> f64 {
248    use crate::components::cyclic::{forward_distance, wrap_position};
249
250    if !circumference.is_finite() || circumference <= 0.0 || !min_headway.is_finite() {
251        return intended_target;
252    }
253
254    let trailer = wrap_position(trailer_position, circumference);
255    let leader = wrap_position(leader_position, circumference);
256    let intended = wrap_position(intended_target, circumference);
257
258    let gap = forward_distance(trailer, leader, circumference);
259    let safe_advance = (gap - min_headway).max(0.0);
260    let intended_advance = forward_distance(trailer, intended, circumference);
261
262    debug_assert!(
263        safe_advance >= 0.0,
264        "headway clamp produced negative safe_advance: gap={gap} min_headway={min_headway}"
265    );
266
267    if intended_advance <= safe_advance {
268        intended
269    } else {
270        wrap_position(trailer + safe_advance, circumference)
271    }
272}
273
274#[cfg(all(test, feature = "loop_lines"))]
275#[allow(
276    clippy::panic,
277    reason = "test-only assertions; production-code lint doesn't apply"
278)]
279mod cyclic_tests {
280    use super::*;
281    use crate::components::cyclic::forward_distance;
282
283    const MAX_SPEED: f64 = 5.0;
284    const ACCEL: f64 = 2.0;
285    const DECEL: f64 = 2.0;
286    const DT: f64 = 1.0;
287    const C: f64 = 100.0;
288
289    fn approx(actual: f64, expected: f64) {
290        assert!(
291            (actual - expected).abs() < 1e-9,
292            "expected {expected}, got {actual}",
293        );
294    }
295
296    #[test]
297    fn cyclic_arrival_at_target_returns_arrived() {
298        let r = tick_movement_cyclic(50.0, 0.0, 50.0, MAX_SPEED, ACCEL, DECEL, DT, C);
299        approx(r.position, 50.0);
300        approx(r.velocity, 0.0);
301        assert!(r.arrived);
302    }
303
304    #[test]
305    fn cyclic_handles_seam_crossing_in_one_tick() {
306        // pos=95, vel=5, dt=1 ⇒ would land at 100 (= 0). Target sits 10 units
307        // ahead through the seam at 5, so the integrator should still cruise
308        // forward and *not* arrive yet.
309        let r = tick_movement_cyclic(95.0, 5.0, 5.0, MAX_SPEED, ACCEL, DECEL, DT, C);
310        // Expect position wrapped into [0, C).
311        assert!(
312            (0.0..C).contains(&r.position),
313            "post-seam position {} out of [0, {C})",
314            r.position
315        );
316        // Forward distance to target should have decreased monotonically.
317        let new_fwd = forward_distance(r.position, 5.0, C);
318        let old_fwd = forward_distance(95.0, 5.0, C);
319        assert!(
320            new_fwd < old_fwd,
321            "forward distance must decrease: {old_fwd} → {new_fwd}",
322        );
323    }
324
325    #[test]
326    fn cyclic_full_lap_eventually_arrives() {
327        // Drive from 0 toward 99 (going forward through the seam not needed).
328        let mut pos = 0.0;
329        let mut vel = 0.0;
330        let target = 99.0;
331        for _ in 0..200 {
332            let r = tick_movement_cyclic(pos, vel, target, MAX_SPEED, ACCEL, DECEL, DT, C);
333            pos = r.position;
334            vel = r.velocity;
335            if r.arrived {
336                approx(pos, target);
337                return;
338            }
339        }
340        panic!("car failed to arrive within 200 ticks; final pos={pos} vel={vel}");
341    }
342
343    #[test]
344    fn cyclic_through_seam_eventually_arrives() {
345        // Start at 80, target at 20 — the only forward path is through the seam.
346        let mut pos = 80.0;
347        let mut vel = 0.0;
348        let target = 20.0;
349        let mut crossed_seam = false;
350        for _ in 0..200 {
351            let prev = pos;
352            let r = tick_movement_cyclic(pos, vel, target, MAX_SPEED, ACCEL, DECEL, DT, C);
353            // A forward step that wraps yields new_pos < old_pos.
354            if r.position < prev && !r.arrived {
355                crossed_seam = true;
356            }
357            pos = r.position;
358            vel = r.velocity;
359            if r.arrived {
360                approx(pos, target);
361                assert!(
362                    crossed_seam,
363                    "arrival without seam crossing implies wrong-way travel"
364                );
365                return;
366            }
367        }
368        panic!("did not arrive within 200 ticks");
369    }
370
371    #[test]
372    fn cyclic_degenerate_circumference_returns_input() {
373        let r = tick_movement_cyclic(50.0, 3.0, 60.0, MAX_SPEED, ACCEL, DECEL, DT, 0.0);
374        approx(r.position, 50.0);
375        approx(r.velocity, 0.0);
376        assert!(!r.arrived);
377    }
378
379    // ── headway_clamp_target ──────────────────────────────────────
380
381    #[test]
382    fn headway_clamp_passes_through_when_gap_is_large() {
383        let intended = 30.0;
384        let clamped = headway_clamp_target(0.0, 80.0, intended, 5.0, C);
385        approx(clamped, 30.0);
386    }
387
388    #[test]
389    fn headway_clamp_caps_target_at_leader_minus_headway() {
390        // trailer=0, leader=10, headway=5 ⇒ safe_advance=5, so intended=20 clamps to 5.
391        let clamped = headway_clamp_target(0.0, 10.0, 20.0, 5.0, C);
392        approx(clamped, 5.0);
393    }
394
395    #[test]
396    fn headway_clamp_collapses_to_self_when_gap_is_zero() {
397        let clamped = headway_clamp_target(50.0, 50.0, 60.0, 5.0, C);
398        approx(clamped, 50.0);
399    }
400
401    #[test]
402    fn headway_clamp_handles_seam_crossing_gap() {
403        // trailer=95, leader=5, headway=2 ⇒ gap=10, safe_advance=8.
404        // Intended target is 4 (forward distance 9), exceeds safe_advance 8.
405        // Clamped target is 95 + 8 = 103 → wraps to 3.
406        let clamped = headway_clamp_target(95.0, 5.0, 4.0, 2.0, C);
407        approx(clamped, 3.0);
408    }
409
410    #[test]
411    fn headway_clamp_degenerate_inputs_pass_through() {
412        approx(headway_clamp_target(0.0, 50.0, 30.0, 5.0, 0.0), 30.0);
413        approx(headway_clamp_target(0.0, 50.0, 30.0, f64::NAN, C), 30.0);
414    }
415
416    // ── property test: cyclic ordering invariant under headway clamp ──
417
418    /// A linear-congruential generator produces deterministic random
419    /// values without pulling in the `rand` crate as a dev-dep just for
420    /// this test. The seed is fixed so reproducibility across CI runs is
421    /// guaranteed; the property holds for *every* sequence the LCG
422    /// generates, not just the seeded one.
423    fn lcg_next(state: &mut u64) -> f64 {
424        // PCG-style multiplier and increment; standard high-quality LCG constants.
425        *state = state
426            .wrapping_mul(6_364_136_223_846_793_005)
427            .wrapping_add(1_442_695_040_888_963_407);
428        // Map upper 53 bits into [0, 1).
429        #[allow(
430            clippy::cast_precision_loss,
431            reason = "mapping the top 53 bits of u64 into [0, 1) is the standard f64 quantisation"
432        )]
433        let upper = (*state >> 11) as f64;
434        upper / ((1u64 << 53) as f64)
435    }
436
437    #[test]
438    fn property_headway_invariant_holds_across_random_ticks() {
439        const N_CARS: usize = 4;
440        const HEADWAY: f64 = 5.0;
441        const TICKS: u32 = 10_000;
442
443        // Initial spacing > headway, even gaps around the loop.
444        let mut positions = [0.0_f64, 25.0, 50.0, 75.0];
445        let mut velocities = [0.0_f64; N_CARS];
446        // Each car has its own intended target; we shuffle them randomly.
447        let mut targets = [10.0_f64, 35.0, 60.0, 85.0];
448
449        let mut rng = 0x00C0_FFEE_u64;
450
451        for tick in 0..TICKS {
452            // Once in a while, pick a fresh forward target for one car.
453            if tick % 73 == 0 {
454                let i = (lcg_next(&mut rng) * N_CARS as f64) as usize % N_CARS;
455                let advance = lcg_next(&mut rng).mul_add(30.0, 5.0);
456                targets[i] = (positions[i] + advance) % C;
457            }
458
459            // For each car, look up its leader (next car forward in cyclic order),
460            // clamp its target by headway, and advance one tick.
461            // Find indices sorted by forward distance from car 0 to establish order.
462            let mut order: [usize; N_CARS] = [0, 1, 2, 3];
463            order.sort_by(|&a, &b| {
464                let da = forward_distance(positions[0], positions[a], C);
465                let db = forward_distance(positions[0], positions[b], C);
466                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
467            });
468
469            // For each car at index `order[k]`, the leader is at `order[(k+1) % N_CARS]`.
470            for k in 0..N_CARS {
471                let me = order[k];
472                let leader = order[(k + 1) % N_CARS];
473                let clamped =
474                    headway_clamp_target(positions[me], positions[leader], targets[me], HEADWAY, C);
475                let r = tick_movement_cyclic(
476                    positions[me],
477                    velocities[me],
478                    clamped,
479                    MAX_SPEED,
480                    ACCEL,
481                    DECEL,
482                    DT,
483                    C,
484                );
485                positions[me] = r.position;
486                velocities[me] = r.velocity;
487            }
488
489            // Re-sort using post-tick positions so the invariant check sees
490            // actual cyclic-order pairs. Without this, a hypothetical bug
491            // that lets car `order[k]` overtake `order[k+1]` would flip the
492            // pair's order; `forward_distance` on the stale ordering would
493            // then return ~`C`, which is trivially `>= HEADWAY`, and the
494            // assert would silently pass even though the invariant was
495            // violated.
496            order.sort_by(|&a, &b| {
497                let da = forward_distance(positions[0], positions[a], C);
498                let db = forward_distance(positions[0], positions[b], C);
499                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
500            });
501
502            // Invariant: every pair of consecutive cars in cyclic order has
503            // forward gap >= HEADWAY (within a tiny tolerance for the
504            // trapezoidal integrator's overshoot epsilon).
505            for k in 0..N_CARS {
506                let me = order[k];
507                let leader = order[(k + 1) % N_CARS];
508                let gap = forward_distance(positions[me], positions[leader], C);
509                assert!(
510                    gap >= HEADWAY - 1e-6,
511                    "tick {tick}: headway invariant violated: car {me} → leader {leader} gap={gap} < {HEADWAY}",
512                );
513            }
514        }
515    }
516}