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/// Mode-gated reposition: dispatches to an inner strategy chosen
273/// by the current [`TrafficMode`](crate::traffic_detector::TrafficMode).
274///
275/// Closes the playground-reported "chaotic repositioning" complaint:
276/// the single-strategy defaults either lock cars to the lobby
277/// ([`ReturnToLobby`]) or shuttle them toward the hottest stop
278/// ([`PredictiveParking`]) regardless of traffic shape. Adaptive
279/// picks per mode:
280///
281/// | Mode                                                             | Inner                |
282/// |------------------------------------------------------------------|----------------------|
283/// | [`UpPeak`](crate::traffic_detector::TrafficMode::UpPeak)         | [`ReturnToLobby`]    |
284/// | [`InterFloor`](crate::traffic_detector::TrafficMode::InterFloor) | [`PredictiveParking`]|
285/// | [`DownPeak`](crate::traffic_detector::TrafficMode::DownPeak)     | [`PredictiveParking`]|
286/// | [`Idle`](crate::traffic_detector::TrafficMode::Idle)             | no-op (stay put)     |
287///
288/// `DownPeak` reuses `PredictiveParking` intentionally: during a down
289/// peak, upper floors are the high-arrival stops (riders spawn there
290/// heading to the lobby), and `PredictiveParking` scores stops by
291/// [`ArrivalLog`] counts — so it correctly biases idle cars upward
292/// without needing a destination-aware variant. Falls back to
293/// `InterFloor` routing if the detector is missing from `World`
294/// (e.g. hand-built tests bypassing `Simulation`).
295pub struct AdaptiveParking {
296    /// Inner strategy used in up-peak mode. Configurable so games
297    /// can pin a different home stop (sky-lobby buildings, e.g.).
298    return_to_lobby: ReturnToLobby,
299    /// Inner strategy used when demand is diffuse or heading down.
300    predictive: PredictiveParking,
301}
302
303impl AdaptiveParking {
304    /// Create with defaults: `ReturnToLobby::new()` (home = stop 0)
305    /// and `PredictiveParking::new()` (default rolling window).
306    #[must_use]
307    pub const fn new() -> Self {
308        Self {
309            return_to_lobby: ReturnToLobby::new(),
310            predictive: PredictiveParking::new(),
311        }
312    }
313
314    /// Override the home stop used during `UpPeak`. Same semantics as
315    /// [`ReturnToLobby::with_home`].
316    #[must_use]
317    pub const fn with_home(mut self, index: usize) -> Self {
318        self.return_to_lobby = ReturnToLobby::with_home(index);
319        self
320    }
321
322    /// Override the window used for `InterFloor` / `DownPeak`
323    /// predictive parking. Same semantics as
324    /// [`PredictiveParking::with_window_ticks`].
325    ///
326    /// # Panics
327    /// Panics on `window_ticks = 0`, matching `PredictiveParking`.
328    #[must_use]
329    pub const fn with_window_ticks(mut self, window_ticks: u64) -> Self {
330        self.predictive = PredictiveParking::with_window_ticks(window_ticks);
331        self
332    }
333}
334
335impl Default for AdaptiveParking {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341impl RepositionStrategy for AdaptiveParking {
342    fn reposition(
343        &mut self,
344        idle_elevators: &[(EntityId, f64)],
345        stop_positions: &[(EntityId, f64)],
346        group: &ElevatorGroup,
347        world: &World,
348        out: &mut Vec<(EntityId, EntityId)>,
349    ) {
350        use crate::traffic_detector::{TrafficDetector, TrafficMode};
351        let mode = world
352            .resource::<TrafficDetector>()
353            .map_or(TrafficMode::InterFloor, TrafficDetector::current_mode);
354        match mode {
355            TrafficMode::Idle => {
356                // Stay put — no point commuting when there's no
357                // demand to pre-position for.
358            }
359            TrafficMode::UpPeak => {
360                self.return_to_lobby
361                    .reposition(idle_elevators, stop_positions, group, world, out);
362            }
363            TrafficMode::DownPeak | TrafficMode::InterFloor => {
364                self.predictive
365                    .reposition(idle_elevators, stop_positions, group, world, out);
366            }
367        }
368    }
369}
370
371/// No-op strategy: idle elevators stay where they stopped.
372///
373/// Use this to disable repositioning for a group while keeping
374/// the repositioning phase active for other groups.
375pub struct NearestIdle;
376
377impl RepositionStrategy for NearestIdle {
378    fn reposition(
379        &mut self,
380        _idle_elevators: &[(EntityId, f64)],
381        _stop_positions: &[(EntityId, f64)],
382        _group: &ElevatorGroup,
383        _world: &World,
384        _out: &mut Vec<(EntityId, EntityId)>,
385    ) {
386    }
387}
388
389/// Shared greedy-assign step for score-driven parking strategies.
390///
391/// `scored` is the list of `(stop_id, stop_pos, _score)` in descending
392/// priority order (strategies sort/filter upstream). For each stop in
393/// that order, pick the closest still-unassigned idle elevator and
394/// send it there — unless the stop is already covered by a non-idle
395/// car or the closest idle car is already parked on it.
396///
397/// The tuple's third element is ignored here; it exists only to keep
398/// the caller's scoring type visible at the call site.
399fn assign_greedy_by_score<S>(
400    scored: &[(EntityId, f64, S)],
401    idle_elevators: &[(EntityId, f64)],
402    group: &ElevatorGroup,
403    world: &World,
404    out: &mut Vec<(EntityId, EntityId)>,
405) {
406    // Positions of non-idle elevators — avoid parking on top of cars
407    // already in service.
408    let mut occupied: Vec<f64> = group
409        .elevator_entities()
410        .iter()
411        .filter_map(|&eid| {
412            if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
413                return None;
414            }
415            world.position(eid).map(|p| p.value)
416        })
417        .collect();
418
419    let mut assigned: Vec<EntityId> = Vec::new();
420    for (stop_eid, stop_pos, _) in scored {
421        if min_distance_to(*stop_pos, &occupied) < 1e-6 {
422            continue;
423        }
424
425        let closest = idle_elevators
426            .iter()
427            .filter(|(eid, _)| !assigned.contains(eid))
428            .min_by(|a, b| (a.1 - stop_pos).abs().total_cmp(&(b.1 - stop_pos).abs()));
429
430        if let Some(&(elev_eid, elev_pos)) = closest
431            && (elev_pos - stop_pos).abs() > 1e-6
432        {
433            out.push((elev_eid, *stop_eid));
434            assigned.push(elev_eid);
435            occupied.push(*stop_pos);
436        }
437
438        if assigned.len() == idle_elevators.len() {
439            break;
440        }
441    }
442}
443
444/// Minimum distance from `pos` to any value in `others`.
445fn min_distance_to(pos: f64, others: &[f64]) -> f64 {
446    if others.is_empty() {
447        return f64::INFINITY;
448    }
449    others
450        .iter()
451        .map(|&o| (pos - o).abs())
452        .fold(f64::INFINITY, f64::min)
453}