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::entity::EntityId;
16use crate::tagged_metrics::{MetricTags, TaggedMetric};
17use crate::world::World;
18
19use super::{ElevatorGroup, RepositionStrategy};
20
21/// Distribute idle elevators evenly across the group's stops.
22///
23/// For each idle elevator, assigns it to the stop position that maximizes
24/// the minimum distance from any other (non-idle or already-assigned) elevator.
25/// This spreads coverage across the shaft.
26pub struct SpreadEvenly;
27
28impl RepositionStrategy for SpreadEvenly {
29    fn reposition(
30        &mut self,
31        idle_elevators: &[(EntityId, f64)],
32        stop_positions: &[(EntityId, f64)],
33        group: &ElevatorGroup,
34        world: &World,
35        out: &mut Vec<(EntityId, EntityId)>,
36    ) {
37        if idle_elevators.is_empty() || stop_positions.is_empty() {
38            return;
39        }
40
41        // Collect positions of all non-idle elevators in this group.
42        let mut occupied: Vec<f64> = group
43            .elevator_entities()
44            .iter()
45            .filter_map(|&eid| {
46                if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
47                    return None;
48                }
49                world.position(eid).map(|p| p.value)
50            })
51            .collect();
52
53        for &(elev_eid, elev_pos) in idle_elevators {
54            // Primary criterion: maximize the minimum distance from any
55            // already-occupied position (true "spread"). Tie-breaker:
56            // prefer the stop closest to the elevator's current position
57            // — otherwise, with no occupied positions at sim start, every
58            // stop is tied at `INFINITY` and `max_by`'s last-wins default
59            // ships every car to the topmost stop. That was the reported
60            // "cars travel to the top at sim start with no demand" bug.
61            let best = stop_positions.iter().max_by(|a, b| {
62                let min_a = min_distance_to(a.1, &occupied);
63                let min_b = min_distance_to(b.1, &occupied);
64                min_a.total_cmp(&min_b).then_with(|| {
65                    let dist_a = (a.1 - elev_pos).abs();
66                    let dist_b = (b.1 - elev_pos).abs();
67                    // `max_by` returns the greater element; invert so the
68                    // closer stop to the elevator is considered greater.
69                    dist_b.total_cmp(&dist_a)
70                })
71            });
72
73            if let Some(&(stop_eid, stop_pos)) = best {
74                if (stop_pos - elev_pos).abs() > 1e-6 {
75                    out.push((elev_eid, stop_eid));
76                }
77                occupied.push(stop_pos);
78            }
79        }
80    }
81}
82
83/// Return idle elevators to a configured home stop (default: first stop).
84///
85/// Classic lobby-return strategy. All idle elevators converge on a single
86/// designated stop, typically the ground floor or main lobby.
87pub struct ReturnToLobby {
88    /// Index into the group's stop list for the home stop.
89    /// Defaults to 0 (first stop).
90    pub home_stop_index: usize,
91}
92
93impl ReturnToLobby {
94    /// Create with default home stop (index 0).
95    #[must_use]
96    pub const fn new() -> Self {
97        Self { home_stop_index: 0 }
98    }
99
100    /// Create with a specific home stop index.
101    #[must_use]
102    pub const fn with_home(index: usize) -> Self {
103        Self {
104            home_stop_index: index,
105        }
106    }
107}
108
109impl Default for ReturnToLobby {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl RepositionStrategy for ReturnToLobby {
116    fn reposition(
117        &mut self,
118        idle_elevators: &[(EntityId, f64)],
119        stop_positions: &[(EntityId, f64)],
120        _group: &ElevatorGroup,
121        _world: &World,
122        out: &mut Vec<(EntityId, EntityId)>,
123    ) {
124        let Some(&(home_eid, home_pos)) = stop_positions.get(self.home_stop_index) else {
125            return;
126        };
127
128        out.extend(
129            idle_elevators
130                .iter()
131                .filter(|(_, pos)| (*pos - home_pos).abs() > 1e-6)
132                .map(|&(eid, _)| (eid, home_eid)),
133        );
134    }
135}
136
137/// Position idle elevators near stops with historically high demand.
138///
139/// Reads per-stop throughput from the [`MetricTags`] system to weight
140/// stop positions. Idle elevators are assigned to the highest-demand
141/// stops that don't already have an elevator nearby.
142pub struct DemandWeighted;
143
144impl RepositionStrategy for DemandWeighted {
145    fn reposition(
146        &mut self,
147        idle_elevators: &[(EntityId, f64)],
148        stop_positions: &[(EntityId, f64)],
149        group: &ElevatorGroup,
150        world: &World,
151        out: &mut Vec<(EntityId, EntityId)>,
152    ) {
153        if idle_elevators.is_empty() || stop_positions.is_empty() {
154            return;
155        }
156
157        let tags = world.resource::<MetricTags>();
158        let mut scored_stops: Vec<(EntityId, f64, f64)> = stop_positions
159            .iter()
160            .map(|&(stop_eid, stop_pos)| {
161                let demand = tags
162                    .and_then(|t| {
163                        t.tags_for(stop_eid)
164                            .iter()
165                            .filter_map(|tag| t.metric(tag).map(TaggedMetric::total_delivered))
166                            .max()
167                    })
168                    .unwrap_or(0) as f64;
169                (stop_eid, stop_pos, demand + 1.0)
170            })
171            .collect();
172
173        scored_stops.sort_by(|a, b| b.2.total_cmp(&a.2));
174
175        let mut occupied: Vec<f64> = group
176            .elevator_entities()
177            .iter()
178            .filter_map(|&eid| {
179                if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
180                    return None;
181                }
182                world.position(eid).map(|p| p.value)
183            })
184            .collect();
185
186        let mut assigned_elevators: Vec<EntityId> = Vec::new();
187
188        for (stop_eid, stop_pos, _demand) in &scored_stops {
189            if min_distance_to(*stop_pos, &occupied) < 1e-6 {
190                continue;
191            }
192
193            let closest = idle_elevators
194                .iter()
195                .filter(|(eid, _)| !assigned_elevators.contains(eid))
196                .min_by(|a, b| {
197                    let da = (a.1 - stop_pos).abs();
198                    let db = (b.1 - stop_pos).abs();
199                    da.total_cmp(&db)
200                });
201
202            if let Some(&(elev_eid, elev_pos)) = closest
203                && (elev_pos - stop_pos).abs() > 1e-6
204            {
205                out.push((elev_eid, *stop_eid));
206                assigned_elevators.push(elev_eid);
207                occupied.push(*stop_pos);
208            }
209
210            if assigned_elevators.len() == idle_elevators.len() {
211                break;
212            }
213        }
214    }
215}
216
217/// No-op strategy: idle elevators stay where they stopped.
218///
219/// Use this to disable repositioning for a group while keeping
220/// the repositioning phase active for other groups.
221pub struct NearestIdle;
222
223impl RepositionStrategy for NearestIdle {
224    fn reposition(
225        &mut self,
226        _idle_elevators: &[(EntityId, f64)],
227        _stop_positions: &[(EntityId, f64)],
228        _group: &ElevatorGroup,
229        _world: &World,
230        _out: &mut Vec<(EntityId, EntityId)>,
231    ) {
232    }
233}
234
235/// Minimum distance from `pos` to any value in `others`.
236fn min_distance_to(pos: f64, others: &[f64]) -> f64 {
237    if others.is_empty() {
238        return f64::INFINITY;
239    }
240    others
241        .iter()
242        .map(|&o| (pos - o).abs())
243        .fold(f64::INFINITY, f64::min)
244}