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