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/// No-op strategy: idle elevators stay where they stopped.
273///
274/// Use this to disable repositioning for a group while keeping
275/// the repositioning phase active for other groups.
276pub struct NearestIdle;
277
278impl RepositionStrategy for NearestIdle {
279 fn reposition(
280 &mut self,
281 _idle_elevators: &[(EntityId, f64)],
282 _stop_positions: &[(EntityId, f64)],
283 _group: &ElevatorGroup,
284 _world: &World,
285 _out: &mut Vec<(EntityId, EntityId)>,
286 ) {
287 }
288}
289
290/// Shared greedy-assign step for score-driven parking strategies.
291///
292/// `scored` is the list of `(stop_id, stop_pos, _score)` in descending
293/// priority order (strategies sort/filter upstream). For each stop in
294/// that order, pick the closest still-unassigned idle elevator and
295/// send it there — unless the stop is already covered by a non-idle
296/// car or the closest idle car is already parked on it.
297///
298/// The tuple's third element is ignored here; it exists only to keep
299/// the caller's scoring type visible at the call site.
300fn assign_greedy_by_score<S>(
301 scored: &[(EntityId, f64, S)],
302 idle_elevators: &[(EntityId, f64)],
303 group: &ElevatorGroup,
304 world: &World,
305 out: &mut Vec<(EntityId, EntityId)>,
306) {
307 // Positions of non-idle elevators — avoid parking on top of cars
308 // already in service.
309 let mut occupied: Vec<f64> = group
310 .elevator_entities()
311 .iter()
312 .filter_map(|&eid| {
313 if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
314 return None;
315 }
316 world.position(eid).map(|p| p.value)
317 })
318 .collect();
319
320 let mut assigned: Vec<EntityId> = Vec::new();
321 for (stop_eid, stop_pos, _) in scored {
322 if min_distance_to(*stop_pos, &occupied) < 1e-6 {
323 continue;
324 }
325
326 let closest = idle_elevators
327 .iter()
328 .filter(|(eid, _)| !assigned.contains(eid))
329 .min_by(|a, b| (a.1 - stop_pos).abs().total_cmp(&(b.1 - stop_pos).abs()));
330
331 if let Some(&(elev_eid, elev_pos)) = closest
332 && (elev_pos - stop_pos).abs() > 1e-6
333 {
334 out.push((elev_eid, *stop_eid));
335 assigned.push(elev_eid);
336 occupied.push(*stop_pos);
337 }
338
339 if assigned.len() == idle_elevators.len() {
340 break;
341 }
342 }
343}
344
345/// Minimum distance from `pos` to any value in `others`.
346fn min_distance_to(pos: f64, others: &[f64]) -> f64 {
347 if others.is_empty() {
348 return f64::INFINITY;
349 }
350 others
351 .iter()
352 .map(|&o| (pos - o).abs())
353 .fold(f64::INFINITY, f64::min)
354}