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            let best = stop_positions.iter().max_by(|a, b| {
55                let min_a = min_distance_to(a.1, &occupied);
56                let min_b = min_distance_to(b.1, &occupied);
57                min_a.total_cmp(&min_b)
58            });
59
60            if let Some(&(stop_eid, stop_pos)) = best {
61                if (stop_pos - elev_pos).abs() > 1e-6 {
62                    out.push((elev_eid, stop_eid));
63                }
64                occupied.push(stop_pos);
65            }
66        }
67    }
68}
69
70/// Return idle elevators to a configured home stop (default: first stop).
71///
72/// Classic lobby-return strategy. All idle elevators converge on a single
73/// designated stop, typically the ground floor or main lobby.
74pub struct ReturnToLobby {
75    /// Index into the group's stop list for the home stop.
76    /// Defaults to 0 (first stop).
77    pub home_stop_index: usize,
78}
79
80impl ReturnToLobby {
81    /// Create with default home stop (index 0).
82    #[must_use]
83    pub const fn new() -> Self {
84        Self { home_stop_index: 0 }
85    }
86
87    /// Create with a specific home stop index.
88    #[must_use]
89    pub const fn with_home(index: usize) -> Self {
90        Self {
91            home_stop_index: index,
92        }
93    }
94}
95
96impl Default for ReturnToLobby {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl RepositionStrategy for ReturnToLobby {
103    fn reposition(
104        &mut self,
105        idle_elevators: &[(EntityId, f64)],
106        stop_positions: &[(EntityId, f64)],
107        _group: &ElevatorGroup,
108        _world: &World,
109        out: &mut Vec<(EntityId, EntityId)>,
110    ) {
111        let Some(&(home_eid, home_pos)) = stop_positions.get(self.home_stop_index) else {
112            return;
113        };
114
115        out.extend(
116            idle_elevators
117                .iter()
118                .filter(|(_, pos)| (*pos - home_pos).abs() > 1e-6)
119                .map(|&(eid, _)| (eid, home_eid)),
120        );
121    }
122}
123
124/// Position idle elevators near stops with historically high demand.
125///
126/// Reads per-stop throughput from the [`MetricTags`] system to weight
127/// stop positions. Idle elevators are assigned to the highest-demand
128/// stops that don't already have an elevator nearby.
129pub struct DemandWeighted;
130
131impl RepositionStrategy for DemandWeighted {
132    fn reposition(
133        &mut self,
134        idle_elevators: &[(EntityId, f64)],
135        stop_positions: &[(EntityId, f64)],
136        group: &ElevatorGroup,
137        world: &World,
138        out: &mut Vec<(EntityId, EntityId)>,
139    ) {
140        if idle_elevators.is_empty() || stop_positions.is_empty() {
141            return;
142        }
143
144        let tags = world.resource::<MetricTags>();
145        let mut scored_stops: Vec<(EntityId, f64, f64)> = stop_positions
146            .iter()
147            .map(|&(stop_eid, stop_pos)| {
148                let demand = tags
149                    .and_then(|t| {
150                        t.tags_for(stop_eid)
151                            .iter()
152                            .filter_map(|tag| t.metric(tag).map(TaggedMetric::total_delivered))
153                            .max()
154                    })
155                    .unwrap_or(0) as f64;
156                (stop_eid, stop_pos, demand + 1.0)
157            })
158            .collect();
159
160        scored_stops.sort_by(|a, b| b.2.total_cmp(&a.2));
161
162        let mut occupied: Vec<f64> = group
163            .elevator_entities()
164            .iter()
165            .filter_map(|&eid| {
166                if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
167                    return None;
168                }
169                world.position(eid).map(|p| p.value)
170            })
171            .collect();
172
173        let mut assigned_elevators: Vec<EntityId> = Vec::new();
174
175        for (stop_eid, stop_pos, _demand) in &scored_stops {
176            if min_distance_to(*stop_pos, &occupied) < 1e-6 {
177                continue;
178            }
179
180            let closest = idle_elevators
181                .iter()
182                .filter(|(eid, _)| !assigned_elevators.contains(eid))
183                .min_by(|a, b| {
184                    let da = (a.1 - stop_pos).abs();
185                    let db = (b.1 - stop_pos).abs();
186                    da.total_cmp(&db)
187                });
188
189            if let Some(&(elev_eid, elev_pos)) = closest
190                && (elev_pos - stop_pos).abs() > 1e-6
191            {
192                out.push((elev_eid, *stop_eid));
193                assigned_elevators.push(elev_eid);
194                occupied.push(*stop_pos);
195            }
196
197            if assigned_elevators.len() == idle_elevators.len() {
198                break;
199            }
200        }
201    }
202}
203
204/// No-op strategy: idle elevators stay where they stopped.
205///
206/// Use this to disable repositioning for a group while keeping
207/// the repositioning phase active for other groups.
208pub struct NearestIdle;
209
210impl RepositionStrategy for NearestIdle {
211    fn reposition(
212        &mut self,
213        _idle_elevators: &[(EntityId, f64)],
214        _stop_positions: &[(EntityId, f64)],
215        _group: &ElevatorGroup,
216        _world: &World,
217        _out: &mut Vec<(EntityId, EntityId)>,
218    ) {
219    }
220}
221
222/// Minimum distance from `pos` to any value in `others`.
223fn min_distance_to(pos: f64, others: &[f64]) -> f64 {
224    if others.is_empty() {
225        return f64::INFINITY;
226    }
227    others
228        .iter()
229        .map(|&o| (pos - o).abs())
230        .fold(f64::INFINITY, f64::min)
231}