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 std::collections::HashMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::arrival_log::{ArrivalLog, CurrentTick, DEFAULT_ARRIVAL_WINDOW_TICKS};
20use crate::entity::EntityId;
21use crate::tagged_metrics::{MetricTags, TaggedMetric};
22use crate::world::World;
23
24use super::{ElevatorGroup, RepositionStrategy};
25
26/// Default reposition cooldown in ticks (~4 s at 60 Hz).
27///
28/// Long enough to suppress immediate back-to-back reposition commands
29/// when the arrival-rate ranking shifts, short enough that a
30/// freshly-parked car is responsive to genuinely changed demand.
31/// Tuned from playground observation: shorter windows (60–120 ticks)
32/// still let the lobby car flicker under `InterFloor` mode switches;
33/// longer windows (480+) start to feel stuck even when demand moved.
34pub const DEFAULT_REPOSITION_COOLDOWN_TICKS: u64 = 240;
35
36/// World resource: per-car tick-when-next-eligible for reposition.
37///
38/// Set by the movement phase when a repositioning car arrives at its
39/// target (via `phase = Idle` with `repositioning = true`). Consumed
40/// by the reposition phase's idle-pool filter — cars still in
41/// cooldown stay out of the pool and are skipped for that pass.
42///
43/// Prevents `AdaptiveParking` / `PredictiveParking` from sending the
44/// same car on repeated short hops as the hot-stop ranking shifts
45/// mid-rush. Energy cost + visual noise both drop.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct RepositionCooldowns {
48    /// Tick counter when each car becomes eligible again. Cars not
49    /// present in the map have no cooldown (fresh state).
50    pub eligible_at: HashMap<EntityId, u64>,
51}
52
53impl RepositionCooldowns {
54    /// Whether `car` is currently under cooldown at `tick`.
55    #[must_use]
56    pub fn is_cooling_down(&self, car: EntityId, tick: u64) -> bool {
57        self.eligible_at
58            .get(&car)
59            .is_some_and(|eligible| tick < *eligible)
60    }
61
62    /// Record a reposition arrival. Sets eligibility to
63    /// `arrival_tick + DEFAULT_REPOSITION_COOLDOWN_TICKS`.
64    pub fn record_arrival(&mut self, car: EntityId, arrival_tick: u64) {
65        self.eligible_at
66            .insert(car, arrival_tick + DEFAULT_REPOSITION_COOLDOWN_TICKS);
67    }
68
69    /// Rewrite every entry's car `EntityId` through `id_remap`, dropping
70    /// any entries whose car wasn't re-allocated during snapshot
71    /// restore. Mirrors `ArrivalLog::remap_entity_ids`.
72    pub fn remap_entity_ids(&mut self, id_remap: &HashMap<EntityId, EntityId>) {
73        let remapped: HashMap<EntityId, u64> = std::mem::take(&mut self.eligible_at)
74            .into_iter()
75            .filter_map(|(old, eligible)| id_remap.get(&old).map(|&new| (new, eligible)))
76            .collect();
77        self.eligible_at = remapped;
78    }
79}
80
81/// Distribute idle elevators evenly across the group's stops.
82///
83/// For each idle elevator, assigns it to the stop position that maximizes
84/// the minimum distance from any other (non-idle or already-assigned) elevator.
85/// This spreads coverage across the shaft.
86pub struct SpreadEvenly;
87
88impl RepositionStrategy for SpreadEvenly {
89    fn reposition(
90        &mut self,
91        idle_elevators: &[(EntityId, f64)],
92        stop_positions: &[(EntityId, f64)],
93        group: &ElevatorGroup,
94        world: &World,
95        out: &mut Vec<(EntityId, EntityId)>,
96    ) {
97        if idle_elevators.is_empty() || stop_positions.is_empty() {
98            return;
99        }
100
101        // Collect the *intended resting positions* of all non-idle
102        // elevators in this group — the target stop a car is
103        // committed to, not its transient current position. Without
104        // this, a car already en route to stop X gets counted as
105        // "occupying" wherever it happens to be mid-trip, and the
106        // strategy may spread an idle car straight to the same X.
107        let mut occupied: Vec<f64> = group
108            .elevator_entities()
109            .iter()
110            .filter_map(|&eid| {
111                if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
112                    return None;
113                }
114                intended_position(eid, world)
115            })
116            .collect();
117
118        for &(elev_eid, elev_pos) in idle_elevators {
119            // Primary criterion: maximize the minimum distance from any
120            // already-occupied position (true "spread"). Tie-breaker:
121            // prefer the stop closest to the elevator's current position
122            // — otherwise, with no occupied positions at sim start, every
123            // stop is tied at `INFINITY` and `max_by`'s last-wins default
124            // ships every car to the topmost stop. That was the reported
125            // "cars travel to the top at sim start with no demand" bug.
126            let best = stop_positions.iter().max_by(|a, b| {
127                let min_a = min_distance_to(a.1, &occupied);
128                let min_b = min_distance_to(b.1, &occupied);
129                min_a.total_cmp(&min_b).then_with(|| {
130                    let dist_a = (a.1 - elev_pos).abs();
131                    let dist_b = (b.1 - elev_pos).abs();
132                    // `max_by` returns the greater element; invert so the
133                    // closer stop to the elevator is considered greater.
134                    dist_b.total_cmp(&dist_a)
135                })
136            });
137
138            if let Some(&(stop_eid, stop_pos)) = best {
139                if (stop_pos - elev_pos).abs() > 1e-6 {
140                    out.push((elev_eid, stop_eid));
141                }
142                occupied.push(stop_pos);
143            }
144        }
145    }
146
147    fn builtin_id(&self) -> Option<super::BuiltinReposition> {
148        Some(super::BuiltinReposition::SpreadEvenly)
149    }
150}
151
152/// Return idle elevators to a configured home stop (default: first stop).
153///
154/// Classic lobby-return strategy. All idle elevators converge on a single
155/// designated stop, typically the ground floor or main lobby.
156pub struct ReturnToLobby {
157    /// Index into the group's stop list for the home stop.
158    /// Defaults to 0 (first stop).
159    pub home_stop_index: usize,
160}
161
162impl ReturnToLobby {
163    /// Create with default home stop (index 0).
164    #[must_use]
165    pub const fn new() -> Self {
166        Self { home_stop_index: 0 }
167    }
168
169    /// Create with a specific home stop index.
170    #[must_use]
171    pub const fn with_home(index: usize) -> Self {
172        Self {
173            home_stop_index: index,
174        }
175    }
176}
177
178impl Default for ReturnToLobby {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl RepositionStrategy for ReturnToLobby {
185    fn reposition(
186        &mut self,
187        idle_elevators: &[(EntityId, f64)],
188        stop_positions: &[(EntityId, f64)],
189        _group: &ElevatorGroup,
190        _world: &World,
191        out: &mut Vec<(EntityId, EntityId)>,
192    ) {
193        let Some(&(home_eid, home_pos)) = stop_positions.get(self.home_stop_index) else {
194            return;
195        };
196
197        out.extend(
198            idle_elevators
199                .iter()
200                .filter(|(_, pos)| (*pos - home_pos).abs() > 1e-6)
201                .map(|&(eid, _)| (eid, home_eid)),
202        );
203    }
204
205    fn builtin_id(&self) -> Option<super::BuiltinReposition> {
206        Some(super::BuiltinReposition::ReturnToLobby)
207    }
208}
209
210/// Position idle elevators near stops with historically high demand.
211///
212/// Reads per-stop throughput from the [`MetricTags`] system to weight
213/// stop positions. Idle elevators are assigned to the highest-demand
214/// stops that don't already have an elevator nearby.
215pub struct DemandWeighted;
216
217impl RepositionStrategy for DemandWeighted {
218    fn reposition(
219        &mut self,
220        idle_elevators: &[(EntityId, f64)],
221        stop_positions: &[(EntityId, f64)],
222        group: &ElevatorGroup,
223        world: &World,
224        out: &mut Vec<(EntityId, EntityId)>,
225    ) {
226        if idle_elevators.is_empty() || stop_positions.is_empty() {
227            return;
228        }
229
230        let tags = world.resource::<MetricTags>();
231        // `demand + 1.0` keeps zero-demand stops in the running — the
232        // strategy still produces a spread at sim start before any
233        // deliveries have been recorded.
234        let mut scored: Vec<(EntityId, f64, f64)> = stop_positions
235            .iter()
236            .map(|&(stop_eid, stop_pos)| {
237                let demand = tags
238                    .and_then(|t| {
239                        t.tags_for(stop_eid)
240                            .iter()
241                            .filter_map(|tag| t.metric(tag).map(TaggedMetric::total_delivered))
242                            .max()
243                    })
244                    .unwrap_or(0) as f64;
245                (stop_eid, stop_pos, demand + 1.0)
246            })
247            .collect();
248        scored.sort_by(|a, b| b.2.total_cmp(&a.2));
249
250        assign_greedy_by_score(&scored, idle_elevators, group, world, out);
251    }
252
253    fn builtin_id(&self) -> Option<super::BuiltinReposition> {
254        Some(super::BuiltinReposition::DemandWeighted)
255    }
256}
257
258/// Predictive parking: park idle elevators near stops with the
259/// highest recent per-stop arrival rate.
260///
261/// Reads the [`ArrivalLog`] and [`CurrentTick`] world resources
262/// (always present under a built sim) to compute a rolling window of
263/// arrivals. Cars are greedily assigned to the highest-rate stops that
264/// don't already have a car nearby, so the group spreads across the
265/// hottest floors rather than clustering on one.
266///
267/// Parallels the headline feature of Otis Compass Infinity — forecast
268/// demand from recent traffic, pre-position cars accordingly. Falls
269/// back to no-op when no arrivals have been logged.
270pub struct PredictiveParking {
271    /// Rolling window (ticks) used to compute per-stop arrival counts.
272    /// Shorter windows react faster; longer windows smooth noise.
273    window_ticks: u64,
274}
275
276impl PredictiveParking {
277    /// Create with the default rolling window
278    /// ([`DEFAULT_ARRIVAL_WINDOW_TICKS`]).
279    #[must_use]
280    pub const fn new() -> Self {
281        Self {
282            window_ticks: DEFAULT_ARRIVAL_WINDOW_TICKS,
283        }
284    }
285
286    /// Create with a custom rolling window (ticks). Shorter windows
287    /// react faster to traffic shifts; longer windows smooth out noise.
288    ///
289    /// # Panics
290    /// Panics on `window_ticks == 0`. A zero window would cause
291    /// `ArrivalLog::arrivals_in_window` to return 0 for every stop —
292    /// the strategy would silently no-op, which is almost never what
293    /// the caller meant.
294    #[must_use]
295    pub const fn with_window_ticks(window_ticks: u64) -> Self {
296        assert!(
297            window_ticks > 0,
298            "PredictiveParking::with_window_ticks requires a positive window"
299        );
300        Self { window_ticks }
301    }
302}
303
304impl Default for PredictiveParking {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310impl RepositionStrategy for PredictiveParking {
311    fn reposition(
312        &mut self,
313        idle_elevators: &[(EntityId, f64)],
314        stop_positions: &[(EntityId, f64)],
315        group: &ElevatorGroup,
316        world: &World,
317        out: &mut Vec<(EntityId, EntityId)>,
318    ) {
319        if idle_elevators.is_empty() || stop_positions.is_empty() {
320            return;
321        }
322        let Some(log) = world.resource::<ArrivalLog>() else {
323            return;
324        };
325        let now = world.resource::<CurrentTick>().map_or(0, |ct| ct.0);
326
327        // Score each stop by its arrival count over the window. Keep
328        // only positives — stops with zero recent arrivals are not
329        // parking targets (no signal to act on).
330        let mut scored: Vec<(EntityId, f64, u64)> = stop_positions
331            .iter()
332            .filter_map(|&(sid, pos)| {
333                let count = log.arrivals_in_window(sid, now, self.window_ticks);
334                (count > 0).then_some((sid, pos, count))
335            })
336            .collect();
337        if scored.is_empty() {
338            return;
339        }
340        // Highest arrival count first; stable sort preserves stop-id
341        // order on ties so the result stays deterministic.
342        scored.sort_by_key(|(_, _, count)| std::cmp::Reverse(*count));
343
344        assign_greedy_by_score(&scored, idle_elevators, group, world, out);
345    }
346
347    fn builtin_id(&self) -> Option<super::BuiltinReposition> {
348        Some(super::BuiltinReposition::PredictiveParking)
349    }
350}
351
352/// Mode-gated reposition: dispatches to an inner strategy chosen
353/// by the current [`TrafficMode`](crate::traffic_detector::TrafficMode).
354///
355/// Closes the playground-reported "chaotic repositioning" complaint:
356/// the single-strategy defaults either lock cars to the lobby
357/// ([`ReturnToLobby`]) or shuttle them toward the hottest stop
358/// ([`PredictiveParking`]) regardless of traffic shape. Adaptive
359/// picks per mode:
360///
361/// | Mode                                                             | Inner                |
362/// |------------------------------------------------------------------|----------------------|
363/// | [`UpPeak`](crate::traffic_detector::TrafficMode::UpPeak)         | [`ReturnToLobby`]    |
364/// | [`InterFloor`](crate::traffic_detector::TrafficMode::InterFloor) | [`PredictiveParking`]|
365/// | [`DownPeak`](crate::traffic_detector::TrafficMode::DownPeak)     | [`PredictiveParking`]|
366/// | [`Idle`](crate::traffic_detector::TrafficMode::Idle)             | no-op (stay put)     |
367///
368/// `DownPeak` reuses `PredictiveParking` intentionally: during a down
369/// peak, upper floors are the high-arrival stops (riders spawn there
370/// heading to the lobby), and `PredictiveParking` scores stops by
371/// [`ArrivalLog`] counts — so it correctly biases idle cars upward
372/// without needing a destination-aware variant. Falls back to
373/// `InterFloor` routing if the detector is missing from `World`
374/// (e.g. hand-built tests bypassing `Simulation`).
375pub struct AdaptiveParking {
376    /// Inner strategy used in up-peak mode. Configurable so games
377    /// can pin a different home stop (sky-lobby buildings, e.g.).
378    return_to_lobby: ReturnToLobby,
379    /// Inner strategy used when demand is diffuse or heading down.
380    predictive: PredictiveParking,
381}
382
383impl AdaptiveParking {
384    /// Create with defaults: `ReturnToLobby::new()` (home = stop 0)
385    /// and `PredictiveParking::new()` (default rolling window).
386    #[must_use]
387    pub const fn new() -> Self {
388        Self {
389            return_to_lobby: ReturnToLobby::new(),
390            predictive: PredictiveParking::new(),
391        }
392    }
393
394    /// Override the home stop used during `UpPeak`. Same semantics as
395    /// [`ReturnToLobby::with_home`].
396    #[must_use]
397    pub const fn with_home(mut self, index: usize) -> Self {
398        self.return_to_lobby = ReturnToLobby::with_home(index);
399        self
400    }
401
402    /// Override the window used for `InterFloor` / `DownPeak`
403    /// predictive parking. Same semantics as
404    /// [`PredictiveParking::with_window_ticks`].
405    ///
406    /// # Panics
407    /// Panics on `window_ticks = 0`, matching `PredictiveParking`.
408    #[must_use]
409    pub const fn with_window_ticks(mut self, window_ticks: u64) -> Self {
410        self.predictive = PredictiveParking::with_window_ticks(window_ticks);
411        self
412    }
413}
414
415impl Default for AdaptiveParking {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421impl RepositionStrategy for AdaptiveParking {
422    fn reposition(
423        &mut self,
424        idle_elevators: &[(EntityId, f64)],
425        stop_positions: &[(EntityId, f64)],
426        group: &ElevatorGroup,
427        world: &World,
428        out: &mut Vec<(EntityId, EntityId)>,
429    ) {
430        use crate::traffic_detector::{TrafficDetector, TrafficMode};
431        let mode = world
432            .resource::<TrafficDetector>()
433            .map_or(TrafficMode::InterFloor, TrafficDetector::current_mode);
434        match mode {
435            TrafficMode::Idle => {
436                // Stay put — no point commuting when there's no
437                // demand to pre-position for.
438            }
439            TrafficMode::UpPeak => {
440                self.return_to_lobby
441                    .reposition(idle_elevators, stop_positions, group, world, out);
442            }
443            TrafficMode::DownPeak | TrafficMode::InterFloor => {
444                self.predictive
445                    .reposition(idle_elevators, stop_positions, group, world, out);
446            }
447        }
448    }
449
450    fn builtin_id(&self) -> Option<super::BuiltinReposition> {
451        Some(super::BuiltinReposition::Adaptive)
452    }
453}
454
455/// No-op strategy: idle elevators stay where they stopped.
456///
457/// Use this to disable repositioning for a group while keeping
458/// the repositioning phase active for other groups.
459pub struct NearestIdle;
460
461impl RepositionStrategy for NearestIdle {
462    fn reposition(
463        &mut self,
464        _idle_elevators: &[(EntityId, f64)],
465        _stop_positions: &[(EntityId, f64)],
466        _group: &ElevatorGroup,
467        _world: &World,
468        _out: &mut Vec<(EntityId, EntityId)>,
469    ) {
470    }
471
472    fn builtin_id(&self) -> Option<super::BuiltinReposition> {
473        Some(super::BuiltinReposition::NearestIdle)
474    }
475}
476
477/// Shared greedy-assign step for score-driven parking strategies.
478///
479/// `scored` is the list of `(stop_id, stop_pos, _score)` in descending
480/// priority order (strategies sort/filter upstream). For each stop in
481/// that order, pick the closest still-unassigned idle elevator and
482/// send it there — unless the stop is already covered by a non-idle
483/// car or the closest idle car is already parked on it.
484///
485/// The tuple's third element is ignored here; it exists only to keep
486/// the caller's scoring type visible at the call site.
487fn assign_greedy_by_score<S>(
488    scored: &[(EntityId, f64, S)],
489    idle_elevators: &[(EntityId, f64)],
490    group: &ElevatorGroup,
491    world: &World,
492    out: &mut Vec<(EntityId, EntityId)>,
493) {
494    // Intended resting positions of all non-idle elevators — avoid
495    // parking on top of cars already committed to a stop, whether
496    // currently at that stop or still travelling there. Using
497    // `world.position` alone would miss the second case and allow an
498    // idle car to be assigned to a target another car is already
499    // en route to.
500    let mut occupied: Vec<f64> = group
501        .elevator_entities()
502        .iter()
503        .filter_map(|&eid| {
504            if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
505                return None;
506            }
507            intended_position(eid, world)
508        })
509        .collect();
510
511    let mut assigned: Vec<EntityId> = Vec::new();
512    for (stop_eid, stop_pos, _) in scored {
513        if min_distance_to(*stop_pos, &occupied) < 1e-6 {
514            continue;
515        }
516
517        let closest = idle_elevators
518            .iter()
519            .filter(|(eid, _)| !assigned.contains(eid))
520            .min_by(|a, b| (a.1 - stop_pos).abs().total_cmp(&(b.1 - stop_pos).abs()));
521
522        if let Some(&(elev_eid, elev_pos)) = closest
523            && (elev_pos - stop_pos).abs() > 1e-6
524        {
525            out.push((elev_eid, *stop_eid));
526            assigned.push(elev_eid);
527            occupied.push(*stop_pos);
528        }
529
530        if assigned.len() == idle_elevators.len() {
531            break;
532        }
533    }
534}
535
536/// Where a non-idle elevator is headed — its target-stop position
537/// when one is set, else its current position. Reposition strategies
538/// use this to build the "occupied" list so a car already en route
539/// to stop X is counted as occupying X (not its transient mid-trip
540/// position). Without this, a second car could be sent to the same
541/// X because the first car doesn't yet appear to be "there."
542fn intended_position(eid: EntityId, world: &World) -> Option<f64> {
543    if let Some(car) = world.elevator(eid)
544        && let Some(target) = car.target_stop()
545        && let Some(target_pos) = world.stop_position(target)
546    {
547        return Some(target_pos);
548    }
549    world.position(eid).map(|p| p.value)
550}
551
552/// Minimum distance from `pos` to any value in `others`.
553fn min_distance_to(pos: f64, others: &[f64]) -> f64 {
554    if others.is_empty() {
555        return f64::INFINITY;
556    }
557    others
558        .iter()
559        .map(|&o| (pos - o).abs())
560        .fold(f64::INFINITY, f64::min)
561}