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}