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}