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