Skip to main content

elevator_core/components/
cyclic.rs

1//! Cyclic-distance helpers for closed-loop topologies.
2//!
3//! These helpers operate on positions along a one-dimensional axis that
4//! wraps modulo `circumference`. They are the foundation that
5//! [`LineKind::Loop`](crate::components::LineKind) consumers — movement
6//! physics, ETA math, headway clamping — build on.
7//!
8//! All helpers treat any of the following as degenerate and return safe
9//! values (typically `0.0` or the input unchanged) rather than panicking
10//! or propagating `NaN`:
11//!
12//! - `circumference <= 0.0`
13//! - non-finite `circumference` (including `NaN` and `±∞`)
14//! - non-finite position arguments
15//!
16//! Construction-time validation is responsible for rejecting such
17//! configurations before they reach these helpers; the defensive returns
18//! exist so a misconfigured run degrades gracefully rather than producing
19//! `NaN` cascades through ETA / dispatch math.
20
21/// Normalize a position into `[0, circumference)`.
22///
23/// Uses [`f64::rem_euclid`] so negative inputs wrap correctly (unlike
24/// `%` which preserves the sign). The `>= circumference` guard handles
25/// the rare case where `rem_euclid` rounds a tiny negative input up to
26/// exactly `circumference` due to floating-point precision loss —
27/// without it the `[0, C)` invariant would be silently violated.
28///
29/// Returns the input unchanged when `circumference <= 0.0` or when
30/// `p` is non-finite.
31///
32/// ```
33/// # use elevator_core::components::cyclic::wrap_position;
34/// assert_eq!(wrap_position(0.0, 100.0), 0.0);
35/// assert_eq!(wrap_position(50.0, 100.0), 50.0);
36/// assert_eq!(wrap_position(100.0, 100.0), 0.0);
37/// assert_eq!(wrap_position(125.0, 100.0), 25.0);
38/// assert_eq!(wrap_position(-25.0, 100.0), 75.0);
39/// assert_eq!(wrap_position(-100.0, 100.0), 0.0);
40/// ```
41#[must_use]
42pub fn wrap_position(p: f64, circumference: f64) -> f64 {
43    // Guard explicitly against non-finite *and* non-positive circumference.
44    // Splitting the check (rather than `c <= 0.0`) is necessary because
45    // `NaN <= 0.0` is `false` in IEEE 754 — without the finiteness
46    // gate a NaN circumference would slip past and `rem_euclid` would
47    // propagate it into the result.
48    if !circumference.is_finite() || circumference <= 0.0 || !p.is_finite() {
49        return p;
50    }
51    let r = p.rem_euclid(circumference);
52    if r >= circumference { 0.0 } else { r }
53}
54
55/// Forward (one-way) cyclic distance from `from` to `to` along a loop.
56///
57/// Always returns a value in `[0, circumference)`. Coincident positions
58/// return `0.0`, not `circumference` — distance to "the same point" is
59/// zero, even though "going all the way around back to the same point"
60/// is also a meaningful concept on a loop. Callers that need the
61/// "full lap" interpretation should add `circumference` to a `0.0`
62/// result themselves.
63///
64/// Returns `0.0` when `circumference <= 0.0`.
65///
66/// ```
67/// # use elevator_core::components::cyclic::forward_distance;
68/// assert_eq!(forward_distance(10.0, 30.0, 100.0), 20.0);
69/// assert_eq!(forward_distance(90.0, 10.0, 100.0), 20.0);
70/// assert_eq!(forward_distance(50.0, 50.0, 100.0), 0.0);
71/// assert_eq!(forward_distance(0.0, 99.0, 100.0), 99.0);
72/// // Inputs outside [0, C) are wrapped first.
73/// assert_eq!(forward_distance(110.0, 30.0, 100.0), 20.0);
74/// assert_eq!(forward_distance(-10.0, 30.0, 100.0), 40.0);
75/// ```
76#[must_use]
77pub fn forward_distance(from: f64, to: f64, circumference: f64) -> f64 {
78    // Reject every degenerate input shape upfront so the subtraction
79    // below cannot produce `±∞` / `NaN`. The split finiteness guard on
80    // `circumference` is required because `NaN <= 0.0` is `false` —
81    // see the matching note in `wrap_position`.
82    if !circumference.is_finite() || circumference <= 0.0 || !from.is_finite() || !to.is_finite() {
83        return 0.0;
84    }
85    let from = wrap_position(from, circumference);
86    let to = wrap_position(to, circumference);
87    let d = to - from;
88    if d < 0.0 { d + circumference } else { d }
89}
90
91/// Shortest unsigned cyclic distance between `a` and `b` along a loop.
92///
93/// Returns the smaller of [`forward_distance(a, b)`](forward_distance)
94/// and `circumference - forward_distance(a, b)`. Always in `[0, C/2]`.
95///
96/// Useful when the direction of travel is irrelevant (e.g. spatial
97/// adjacency queries). For dispatch and ETA on a one-way loop, use
98/// [`forward_distance`] instead — the shorter chord is the wrong
99/// answer when you can only travel one way.
100///
101/// ```
102/// # use elevator_core::components::cyclic::cyclic_distance;
103/// assert_eq!(cyclic_distance(10.0, 30.0, 100.0), 20.0);
104/// assert_eq!(cyclic_distance(90.0, 10.0, 100.0), 20.0);
105/// assert_eq!(cyclic_distance(0.0, 50.0, 100.0), 50.0);
106/// assert_eq!(cyclic_distance(0.0, 51.0, 100.0), 49.0);
107/// ```
108#[must_use]
109pub fn cyclic_distance(a: f64, b: f64, circumference: f64) -> f64 {
110    // `forward_distance` short-circuits on NaN inputs, but mirroring the
111    // guard here keeps the `[0, C/2]` invariant explicit at the entry
112    // point — and the `circumference - fwd` step below would otherwise
113    // fail the same way for non-finite circumference.
114    if !circumference.is_finite() || circumference <= 0.0 {
115        return 0.0;
116    }
117    let fwd = forward_distance(a, b, circumference);
118    let back = circumference - fwd;
119    fwd.min(back)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    /// `assert_eq!` on f64 trips `clippy::float_cmp`; this helper expresses
127    /// the bit-exact intent the tests actually want when inputs are powers
128    /// of two and arithmetic is closed under f64.
129    fn approx(actual: f64, expected: f64) {
130        assert!(
131            (actual - expected).abs() < 1e-12,
132            "expected {expected}, got {actual}",
133        );
134    }
135
136    #[test]
137    fn wrap_handles_zero_circumference() {
138        approx(wrap_position(50.0, 0.0), 50.0);
139        approx(wrap_position(50.0, -1.0), 50.0);
140    }
141
142    #[test]
143    fn wrap_handles_non_finite() {
144        assert!(wrap_position(f64::NAN, 100.0).is_nan());
145        assert!(wrap_position(f64::INFINITY, 100.0).is_infinite());
146    }
147
148    #[test]
149    fn forward_distance_is_directional() {
150        approx(forward_distance(10.0, 30.0, 100.0), 20.0);
151        approx(forward_distance(30.0, 10.0, 100.0), 80.0);
152    }
153
154    #[test]
155    fn forward_distance_returns_zero_on_non_finite() {
156        approx(forward_distance(f64::INFINITY, 30.0, 100.0), 0.0);
157        approx(forward_distance(30.0, f64::INFINITY, 100.0), 0.0);
158        approx(forward_distance(f64::NAN, 30.0, 100.0), 0.0);
159        approx(forward_distance(30.0, f64::NAN, 100.0), 0.0);
160        approx(forward_distance(f64::NEG_INFINITY, 30.0, 100.0), 0.0);
161    }
162
163    #[test]
164    fn helpers_handle_nan_circumference() {
165        // NaN <= 0.0 is false in IEEE 754; `!(c > 0.0)` is the only guard
166        // that covers both NaN and non-positive uniformly. Without that
167        // form, NaN would bypass the guard and propagate through arithmetic.
168        approx(wrap_position(5.0, f64::NAN), 5.0);
169        approx(forward_distance(5.0, 10.0, f64::NAN), 0.0);
170        approx(cyclic_distance(5.0, 10.0, f64::NAN), 0.0);
171    }
172
173    #[test]
174    fn forward_distance_zero_on_coincident() {
175        approx(forward_distance(50.0, 50.0, 100.0), 0.0);
176        approx(forward_distance(0.0, 100.0, 100.0), 0.0);
177    }
178
179    #[test]
180    fn cyclic_distance_is_symmetric() {
181        for &(a, b) in &[(10.0_f64, 30.0_f64), (5.0, 95.0), (0.0, 50.0)] {
182            let ab = cyclic_distance(a, b, 100.0);
183            let ba = cyclic_distance(b, a, 100.0);
184            assert!((ab - ba).abs() < 1e-12, "{a} -> {b}: {ab} vs {ba}");
185        }
186    }
187
188    #[test]
189    fn cyclic_distance_capped_at_half_circumference() {
190        for d in 0..=100 {
191            let result = cyclic_distance(0.0, f64::from(d), 100.0);
192            assert!(result <= 50.0 + 1e-12, "d={d} result={result}");
193        }
194    }
195}