Skip to main content

elevator_core/dispatch/
reposition.rs

1//! Built-in repositioning strategies for idle elevators.
2//!
3//! # Example
4//!
5//! ```rust
6//! use elevator_core::prelude::*;
7//! use elevator_core::dispatch::BuiltinReposition;
8//!
9//! let sim = SimulationBuilder::demo()
10//!     .reposition(SpreadEvenly, BuiltinReposition::SpreadEvenly)
11//!     .build()
12//!     .unwrap();
13//! ```
14
15use crate::arrival_log::{ArrivalLog, CurrentTick, DEFAULT_ARRIVAL_WINDOW_TICKS};
16use crate::entity::EntityId;
17use crate::tagged_metrics::{MetricTags, TaggedMetric};
18use crate::world::World;
19
20use super::{ElevatorGroup, RepositionStrategy};
21
22/// Distribute idle elevators evenly across the group's stops.
23///
24/// For each idle elevator, assigns it to the stop position that maximizes
25/// the minimum distance from any other (non-idle or already-assigned) elevator.
26/// This spreads coverage across the shaft.
27pub struct SpreadEvenly;
28
29impl RepositionStrategy for SpreadEvenly {
30    fn reposition(
31        &mut self,
32        idle_elevators: &[(EntityId, f64)],
33        stop_positions: &[(EntityId, f64)],
34        group: &ElevatorGroup,
35        world: &World,
36        out: &mut Vec<(EntityId, EntityId)>,
37    ) {
38        if idle_elevators.is_empty() || stop_positions.is_empty() {
39            return;
40        }
41
42        // Collect positions of all non-idle elevators in this group.
43        let mut occupied: Vec<f64> = group
44            .elevator_entities()
45            .iter()
46            .filter_map(|&eid| {
47                if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
48                    return None;
49                }
50                world.position(eid).map(|p| p.value)
51            })
52            .collect();
53
54        for &(elev_eid, elev_pos) in idle_elevators {
55            // Primary criterion: maximize the minimum distance from any
56            // already-occupied position (true "spread"). Tie-breaker:
57            // prefer the stop closest to the elevator's current position
58            // — otherwise, with no occupied positions at sim start, every
59            // stop is tied at `INFINITY` and `max_by`'s last-wins default
60            // ships every car to the topmost stop. That was the reported
61            // "cars travel to the top at sim start with no demand" bug.
62            let best = stop_positions.iter().max_by(|a, b| {
63                let min_a = min_distance_to(a.1, &occupied);
64                let min_b = min_distance_to(b.1, &occupied);
65                min_a.total_cmp(&min_b).then_with(|| {
66                    let dist_a = (a.1 - elev_pos).abs();
67                    let dist_b = (b.1 - elev_pos).abs();
68                    // `max_by` returns the greater element; invert so the
69                    // closer stop to the elevator is considered greater.
70                    dist_b.total_cmp(&dist_a)
71                })
72            });
73
74            if let Some(&(stop_eid, stop_pos)) = best {
75                if (stop_pos - elev_pos).abs() > 1e-6 {
76                    out.push((elev_eid, stop_eid));
77                }
78                occupied.push(stop_pos);
79            }
80        }
81    }
82}
83
84/// Return idle elevators to a configured home stop (default: first stop).
85///
86/// Classic lobby-return strategy. All idle elevators converge on a single
87/// designated stop, typically the ground floor or main lobby.
88pub struct ReturnToLobby {
89    /// Index into the group's stop list for the home stop.
90    /// Defaults to 0 (first stop).
91    pub home_stop_index: usize,
92}
93
94impl ReturnToLobby {
95    /// Create with default home stop (index 0).
96    #[must_use]
97    pub const fn new() -> Self {
98        Self { home_stop_index: 0 }
99    }
100
101    /// Create with a specific home stop index.
102    #[must_use]
103    pub const fn with_home(index: usize) -> Self {
104        Self {
105            home_stop_index: index,
106        }
107    }
108}
109
110impl Default for ReturnToLobby {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl RepositionStrategy for ReturnToLobby {
117    fn reposition(
118        &mut self,
119        idle_elevators: &[(EntityId, f64)],
120        stop_positions: &[(EntityId, f64)],
121        _group: &ElevatorGroup,
122        _world: &World,
123        out: &mut Vec<(EntityId, EntityId)>,
124    ) {
125        let Some(&(home_eid, home_pos)) = stop_positions.get(self.home_stop_index) else {
126            return;
127        };
128
129        out.extend(
130            idle_elevators
131                .iter()
132                .filter(|(_, pos)| (*pos - home_pos).abs() > 1e-6)
133                .map(|&(eid, _)| (eid, home_eid)),
134        );
135    }
136}
137
138/// Position idle elevators near stops with historically high demand.
139///
140/// Reads per-stop throughput from the [`MetricTags`] system to weight
141/// stop positions. Idle elevators are assigned to the highest-demand
142/// stops that don't already have an elevator nearby.
143pub struct DemandWeighted;
144
145impl RepositionStrategy for DemandWeighted {
146    fn reposition(
147        &mut self,
148        idle_elevators: &[(EntityId, f64)],
149        stop_positions: &[(EntityId, f64)],
150        group: &ElevatorGroup,
151        world: &World,
152        out: &mut Vec<(EntityId, EntityId)>,
153    ) {
154        if idle_elevators.is_empty() || stop_positions.is_empty() {
155            return;
156        }
157
158        let tags = world.resource::<MetricTags>();
159        // `demand + 1.0` keeps zero-demand stops in the running — the
160        // strategy still produces a spread at sim start before any
161        // deliveries have been recorded.
162        let mut scored: Vec<(EntityId, f64, f64)> = stop_positions
163            .iter()
164            .map(|&(stop_eid, stop_pos)| {
165                let demand = tags
166                    .and_then(|t| {
167                        t.tags_for(stop_eid)
168                            .iter()
169                            .filter_map(|tag| t.metric(tag).map(TaggedMetric::total_delivered))
170                            .max()
171                    })
172                    .unwrap_or(0) as f64;
173                (stop_eid, stop_pos, demand + 1.0)
174            })
175            .collect();
176        scored.sort_by(|a, b| b.2.total_cmp(&a.2));
177
178        assign_greedy_by_score(&scored, idle_elevators, group, world, out);
179    }
180}
181
182/// Predictive parking: park idle elevators near stops with the
183/// highest recent per-stop arrival rate.
184///
185/// Reads the [`ArrivalLog`] and [`CurrentTick`] world resources
186/// (always present under a built sim) to compute a rolling window of
187/// arrivals. Cars are greedily assigned to the highest-rate stops that
188/// don't already have a car nearby, so the group spreads across the
189/// hottest floors rather than clustering on one.
190///
191/// Parallels the headline feature of Otis Compass Infinity — forecast
192/// demand from recent traffic, pre-position cars accordingly. Falls
193/// back to no-op when no arrivals have been logged.
194pub struct PredictiveParking {
195    /// Rolling window (ticks) used to compute per-stop arrival counts.
196    /// Shorter windows react faster; longer windows smooth noise.
197    window_ticks: u64,
198}
199
200impl PredictiveParking {
201    /// Create with the default rolling window
202    /// ([`DEFAULT_ARRIVAL_WINDOW_TICKS`]).
203    #[must_use]
204    pub const fn new() -> Self {
205        Self {
206            window_ticks: DEFAULT_ARRIVAL_WINDOW_TICKS,
207        }
208    }
209
210    /// Create with a custom rolling window (ticks). Shorter windows
211    /// react faster to traffic shifts; longer windows smooth out noise.
212    ///
213    /// # Panics
214    /// Panics on `window_ticks == 0`. A zero window would cause
215    /// `ArrivalLog::arrivals_in_window` to return 0 for every stop —
216    /// the strategy would silently no-op, which is almost never what
217    /// the caller meant.
218    #[must_use]
219    pub const fn with_window_ticks(window_ticks: u64) -> Self {
220        assert!(
221            window_ticks > 0,
222            "PredictiveParking::with_window_ticks requires a positive window"
223        );
224        Self { window_ticks }
225    }
226}
227
228impl Default for PredictiveParking {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl RepositionStrategy for PredictiveParking {
235    fn reposition(
236        &mut self,
237        idle_elevators: &[(EntityId, f64)],
238        stop_positions: &[(EntityId, f64)],
239        group: &ElevatorGroup,
240        world: &World,
241        out: &mut Vec<(EntityId, EntityId)>,
242    ) {
243        if idle_elevators.is_empty() || stop_positions.is_empty() {
244            return;
245        }
246        let Some(log) = world.resource::<ArrivalLog>() else {
247            return;
248        };
249        let now = world.resource::<CurrentTick>().map_or(0, |ct| ct.0);
250
251        // Score each stop by its arrival count over the window. Keep
252        // only positives — stops with zero recent arrivals are not
253        // parking targets (no signal to act on).
254        let mut scored: Vec<(EntityId, f64, u64)> = stop_positions
255            .iter()
256            .filter_map(|&(sid, pos)| {
257                let count = log.arrivals_in_window(sid, now, self.window_ticks);
258                (count > 0).then_some((sid, pos, count))
259            })
260            .collect();
261        if scored.is_empty() {
262            return;
263        }
264        // Highest arrival count first; stable sort preserves stop-id
265        // order on ties so the result stays deterministic.
266        scored.sort_by_key(|(_, _, count)| std::cmp::Reverse(*count));
267
268        assign_greedy_by_score(&scored, idle_elevators, group, world, out);
269    }
270}
271
272/// No-op strategy: idle elevators stay where they stopped.
273///
274/// Use this to disable repositioning for a group while keeping
275/// the repositioning phase active for other groups.
276pub struct NearestIdle;
277
278impl RepositionStrategy for NearestIdle {
279    fn reposition(
280        &mut self,
281        _idle_elevators: &[(EntityId, f64)],
282        _stop_positions: &[(EntityId, f64)],
283        _group: &ElevatorGroup,
284        _world: &World,
285        _out: &mut Vec<(EntityId, EntityId)>,
286    ) {
287    }
288}
289
290/// Shared greedy-assign step for score-driven parking strategies.
291///
292/// `scored` is the list of `(stop_id, stop_pos, _score)` in descending
293/// priority order (strategies sort/filter upstream). For each stop in
294/// that order, pick the closest still-unassigned idle elevator and
295/// send it there — unless the stop is already covered by a non-idle
296/// car or the closest idle car is already parked on it.
297///
298/// The tuple's third element is ignored here; it exists only to keep
299/// the caller's scoring type visible at the call site.
300fn assign_greedy_by_score<S>(
301    scored: &[(EntityId, f64, S)],
302    idle_elevators: &[(EntityId, f64)],
303    group: &ElevatorGroup,
304    world: &World,
305    out: &mut Vec<(EntityId, EntityId)>,
306) {
307    // Positions of non-idle elevators — avoid parking on top of cars
308    // already in service.
309    let mut occupied: Vec<f64> = group
310        .elevator_entities()
311        .iter()
312        .filter_map(|&eid| {
313            if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
314                return None;
315            }
316            world.position(eid).map(|p| p.value)
317        })
318        .collect();
319
320    let mut assigned: Vec<EntityId> = Vec::new();
321    for (stop_eid, stop_pos, _) in scored {
322        if min_distance_to(*stop_pos, &occupied) < 1e-6 {
323            continue;
324        }
325
326        let closest = idle_elevators
327            .iter()
328            .filter(|(eid, _)| !assigned.contains(eid))
329            .min_by(|a, b| (a.1 - stop_pos).abs().total_cmp(&(b.1 - stop_pos).abs()));
330
331        if let Some(&(elev_eid, elev_pos)) = closest
332            && (elev_pos - stop_pos).abs() > 1e-6
333        {
334            out.push((elev_eid, *stop_eid));
335            assigned.push(elev_eid);
336            occupied.push(*stop_pos);
337        }
338
339        if assigned.len() == idle_elevators.len() {
340            break;
341        }
342    }
343}
344
345/// Minimum distance from `pos` to any value in `others`.
346fn min_distance_to(pos: f64, others: &[f64]) -> f64 {
347    if others.is_empty() {
348        return f64::INFINITY;
349    }
350    others
351        .iter()
352        .map(|&o| (pos - o).abs())
353        .fold(f64::INFINITY, f64::min)
354}