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
148/// Return idle elevators to a configured home stop (default: first stop).
149///
150/// Classic lobby-return strategy. All idle elevators converge on a single
151/// designated stop, typically the ground floor or main lobby.
152pub struct ReturnToLobby {
153 /// Index into the group's stop list for the home stop.
154 /// Defaults to 0 (first stop).
155 pub home_stop_index: usize,
156}
157
158impl ReturnToLobby {
159 /// Create with default home stop (index 0).
160 #[must_use]
161 pub const fn new() -> Self {
162 Self { home_stop_index: 0 }
163 }
164
165 /// Create with a specific home stop index.
166 #[must_use]
167 pub const fn with_home(index: usize) -> Self {
168 Self {
169 home_stop_index: index,
170 }
171 }
172}
173
174impl Default for ReturnToLobby {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl RepositionStrategy for ReturnToLobby {
181 fn reposition(
182 &mut self,
183 idle_elevators: &[(EntityId, f64)],
184 stop_positions: &[(EntityId, f64)],
185 _group: &ElevatorGroup,
186 _world: &World,
187 out: &mut Vec<(EntityId, EntityId)>,
188 ) {
189 let Some(&(home_eid, home_pos)) = stop_positions.get(self.home_stop_index) else {
190 return;
191 };
192
193 out.extend(
194 idle_elevators
195 .iter()
196 .filter(|(_, pos)| (*pos - home_pos).abs() > 1e-6)
197 .map(|&(eid, _)| (eid, home_eid)),
198 );
199 }
200}
201
202/// Position idle elevators near stops with historically high demand.
203///
204/// Reads per-stop throughput from the [`MetricTags`] system to weight
205/// stop positions. Idle elevators are assigned to the highest-demand
206/// stops that don't already have an elevator nearby.
207pub struct DemandWeighted;
208
209impl RepositionStrategy for DemandWeighted {
210 fn reposition(
211 &mut self,
212 idle_elevators: &[(EntityId, f64)],
213 stop_positions: &[(EntityId, f64)],
214 group: &ElevatorGroup,
215 world: &World,
216 out: &mut Vec<(EntityId, EntityId)>,
217 ) {
218 if idle_elevators.is_empty() || stop_positions.is_empty() {
219 return;
220 }
221
222 let tags = world.resource::<MetricTags>();
223 // `demand + 1.0` keeps zero-demand stops in the running — the
224 // strategy still produces a spread at sim start before any
225 // deliveries have been recorded.
226 let mut scored: Vec<(EntityId, f64, f64)> = stop_positions
227 .iter()
228 .map(|&(stop_eid, stop_pos)| {
229 let demand = tags
230 .and_then(|t| {
231 t.tags_for(stop_eid)
232 .iter()
233 .filter_map(|tag| t.metric(tag).map(TaggedMetric::total_delivered))
234 .max()
235 })
236 .unwrap_or(0) as f64;
237 (stop_eid, stop_pos, demand + 1.0)
238 })
239 .collect();
240 scored.sort_by(|a, b| b.2.total_cmp(&a.2));
241
242 assign_greedy_by_score(&scored, idle_elevators, group, world, out);
243 }
244}
245
246/// Predictive parking: park idle elevators near stops with the
247/// highest recent per-stop arrival rate.
248///
249/// Reads the [`ArrivalLog`] and [`CurrentTick`] world resources
250/// (always present under a built sim) to compute a rolling window of
251/// arrivals. Cars are greedily assigned to the highest-rate stops that
252/// don't already have a car nearby, so the group spreads across the
253/// hottest floors rather than clustering on one.
254///
255/// Parallels the headline feature of Otis Compass Infinity — forecast
256/// demand from recent traffic, pre-position cars accordingly. Falls
257/// back to no-op when no arrivals have been logged.
258pub struct PredictiveParking {
259 /// Rolling window (ticks) used to compute per-stop arrival counts.
260 /// Shorter windows react faster; longer windows smooth noise.
261 window_ticks: u64,
262}
263
264impl PredictiveParking {
265 /// Create with the default rolling window
266 /// ([`DEFAULT_ARRIVAL_WINDOW_TICKS`]).
267 #[must_use]
268 pub const fn new() -> Self {
269 Self {
270 window_ticks: DEFAULT_ARRIVAL_WINDOW_TICKS,
271 }
272 }
273
274 /// Create with a custom rolling window (ticks). Shorter windows
275 /// react faster to traffic shifts; longer windows smooth out noise.
276 ///
277 /// # Panics
278 /// Panics on `window_ticks == 0`. A zero window would cause
279 /// `ArrivalLog::arrivals_in_window` to return 0 for every stop —
280 /// the strategy would silently no-op, which is almost never what
281 /// the caller meant.
282 #[must_use]
283 pub const fn with_window_ticks(window_ticks: u64) -> Self {
284 assert!(
285 window_ticks > 0,
286 "PredictiveParking::with_window_ticks requires a positive window"
287 );
288 Self { window_ticks }
289 }
290}
291
292impl Default for PredictiveParking {
293 fn default() -> Self {
294 Self::new()
295 }
296}
297
298impl RepositionStrategy for PredictiveParking {
299 fn reposition(
300 &mut self,
301 idle_elevators: &[(EntityId, f64)],
302 stop_positions: &[(EntityId, f64)],
303 group: &ElevatorGroup,
304 world: &World,
305 out: &mut Vec<(EntityId, EntityId)>,
306 ) {
307 if idle_elevators.is_empty() || stop_positions.is_empty() {
308 return;
309 }
310 let Some(log) = world.resource::<ArrivalLog>() else {
311 return;
312 };
313 let now = world.resource::<CurrentTick>().map_or(0, |ct| ct.0);
314
315 // Score each stop by its arrival count over the window. Keep
316 // only positives — stops with zero recent arrivals are not
317 // parking targets (no signal to act on).
318 let mut scored: Vec<(EntityId, f64, u64)> = stop_positions
319 .iter()
320 .filter_map(|&(sid, pos)| {
321 let count = log.arrivals_in_window(sid, now, self.window_ticks);
322 (count > 0).then_some((sid, pos, count))
323 })
324 .collect();
325 if scored.is_empty() {
326 return;
327 }
328 // Highest arrival count first; stable sort preserves stop-id
329 // order on ties so the result stays deterministic.
330 scored.sort_by_key(|(_, _, count)| std::cmp::Reverse(*count));
331
332 assign_greedy_by_score(&scored, idle_elevators, group, world, out);
333 }
334}
335
336/// Mode-gated reposition: dispatches to an inner strategy chosen
337/// by the current [`TrafficMode`](crate::traffic_detector::TrafficMode).
338///
339/// Closes the playground-reported "chaotic repositioning" complaint:
340/// the single-strategy defaults either lock cars to the lobby
341/// ([`ReturnToLobby`]) or shuttle them toward the hottest stop
342/// ([`PredictiveParking`]) regardless of traffic shape. Adaptive
343/// picks per mode:
344///
345/// | Mode | Inner |
346/// |------------------------------------------------------------------|----------------------|
347/// | [`UpPeak`](crate::traffic_detector::TrafficMode::UpPeak) | [`ReturnToLobby`] |
348/// | [`InterFloor`](crate::traffic_detector::TrafficMode::InterFloor) | [`PredictiveParking`]|
349/// | [`DownPeak`](crate::traffic_detector::TrafficMode::DownPeak) | [`PredictiveParking`]|
350/// | [`Idle`](crate::traffic_detector::TrafficMode::Idle) | no-op (stay put) |
351///
352/// `DownPeak` reuses `PredictiveParking` intentionally: during a down
353/// peak, upper floors are the high-arrival stops (riders spawn there
354/// heading to the lobby), and `PredictiveParking` scores stops by
355/// [`ArrivalLog`] counts — so it correctly biases idle cars upward
356/// without needing a destination-aware variant. Falls back to
357/// `InterFloor` routing if the detector is missing from `World`
358/// (e.g. hand-built tests bypassing `Simulation`).
359pub struct AdaptiveParking {
360 /// Inner strategy used in up-peak mode. Configurable so games
361 /// can pin a different home stop (sky-lobby buildings, e.g.).
362 return_to_lobby: ReturnToLobby,
363 /// Inner strategy used when demand is diffuse or heading down.
364 predictive: PredictiveParking,
365}
366
367impl AdaptiveParking {
368 /// Create with defaults: `ReturnToLobby::new()` (home = stop 0)
369 /// and `PredictiveParking::new()` (default rolling window).
370 #[must_use]
371 pub const fn new() -> Self {
372 Self {
373 return_to_lobby: ReturnToLobby::new(),
374 predictive: PredictiveParking::new(),
375 }
376 }
377
378 /// Override the home stop used during `UpPeak`. Same semantics as
379 /// [`ReturnToLobby::with_home`].
380 #[must_use]
381 pub const fn with_home(mut self, index: usize) -> Self {
382 self.return_to_lobby = ReturnToLobby::with_home(index);
383 self
384 }
385
386 /// Override the window used for `InterFloor` / `DownPeak`
387 /// predictive parking. Same semantics as
388 /// [`PredictiveParking::with_window_ticks`].
389 ///
390 /// # Panics
391 /// Panics on `window_ticks = 0`, matching `PredictiveParking`.
392 #[must_use]
393 pub const fn with_window_ticks(mut self, window_ticks: u64) -> Self {
394 self.predictive = PredictiveParking::with_window_ticks(window_ticks);
395 self
396 }
397}
398
399impl Default for AdaptiveParking {
400 fn default() -> Self {
401 Self::new()
402 }
403}
404
405impl RepositionStrategy for AdaptiveParking {
406 fn reposition(
407 &mut self,
408 idle_elevators: &[(EntityId, f64)],
409 stop_positions: &[(EntityId, f64)],
410 group: &ElevatorGroup,
411 world: &World,
412 out: &mut Vec<(EntityId, EntityId)>,
413 ) {
414 use crate::traffic_detector::{TrafficDetector, TrafficMode};
415 let mode = world
416 .resource::<TrafficDetector>()
417 .map_or(TrafficMode::InterFloor, TrafficDetector::current_mode);
418 match mode {
419 TrafficMode::Idle => {
420 // Stay put — no point commuting when there's no
421 // demand to pre-position for.
422 }
423 TrafficMode::UpPeak => {
424 self.return_to_lobby
425 .reposition(idle_elevators, stop_positions, group, world, out);
426 }
427 TrafficMode::DownPeak | TrafficMode::InterFloor => {
428 self.predictive
429 .reposition(idle_elevators, stop_positions, group, world, out);
430 }
431 }
432 }
433}
434
435/// No-op strategy: idle elevators stay where they stopped.
436///
437/// Use this to disable repositioning for a group while keeping
438/// the repositioning phase active for other groups.
439pub struct NearestIdle;
440
441impl RepositionStrategy for NearestIdle {
442 fn reposition(
443 &mut self,
444 _idle_elevators: &[(EntityId, f64)],
445 _stop_positions: &[(EntityId, f64)],
446 _group: &ElevatorGroup,
447 _world: &World,
448 _out: &mut Vec<(EntityId, EntityId)>,
449 ) {
450 }
451}
452
453/// Shared greedy-assign step for score-driven parking strategies.
454///
455/// `scored` is the list of `(stop_id, stop_pos, _score)` in descending
456/// priority order (strategies sort/filter upstream). For each stop in
457/// that order, pick the closest still-unassigned idle elevator and
458/// send it there — unless the stop is already covered by a non-idle
459/// car or the closest idle car is already parked on it.
460///
461/// The tuple's third element is ignored here; it exists only to keep
462/// the caller's scoring type visible at the call site.
463fn assign_greedy_by_score<S>(
464 scored: &[(EntityId, f64, S)],
465 idle_elevators: &[(EntityId, f64)],
466 group: &ElevatorGroup,
467 world: &World,
468 out: &mut Vec<(EntityId, EntityId)>,
469) {
470 // Intended resting positions of all non-idle elevators — avoid
471 // parking on top of cars already committed to a stop, whether
472 // currently at that stop or still travelling there. Using
473 // `world.position` alone would miss the second case and allow an
474 // idle car to be assigned to a target another car is already
475 // en route to.
476 let mut occupied: Vec<f64> = group
477 .elevator_entities()
478 .iter()
479 .filter_map(|&eid| {
480 if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
481 return None;
482 }
483 intended_position(eid, world)
484 })
485 .collect();
486
487 let mut assigned: Vec<EntityId> = Vec::new();
488 for (stop_eid, stop_pos, _) in scored {
489 if min_distance_to(*stop_pos, &occupied) < 1e-6 {
490 continue;
491 }
492
493 let closest = idle_elevators
494 .iter()
495 .filter(|(eid, _)| !assigned.contains(eid))
496 .min_by(|a, b| (a.1 - stop_pos).abs().total_cmp(&(b.1 - stop_pos).abs()));
497
498 if let Some(&(elev_eid, elev_pos)) = closest
499 && (elev_pos - stop_pos).abs() > 1e-6
500 {
501 out.push((elev_eid, *stop_eid));
502 assigned.push(elev_eid);
503 occupied.push(*stop_pos);
504 }
505
506 if assigned.len() == idle_elevators.len() {
507 break;
508 }
509 }
510}
511
512/// Where a non-idle elevator is headed — its target-stop position
513/// when one is set, else its current position. Reposition strategies
514/// use this to build the "occupied" list so a car already en route
515/// to stop X is counted as occupying X (not its transient mid-trip
516/// position). Without this, a second car could be sent to the same
517/// X because the first car doesn't yet appear to be "there."
518fn intended_position(eid: EntityId, world: &World) -> Option<f64> {
519 if let Some(car) = world.elevator(eid)
520 && let Some(target) = car.target_stop()
521 && let Some(target_pos) = world.stop_position(target)
522 {
523 return Some(target_pos);
524 }
525 world.position(eid).map(|p| p.value)
526}
527
528/// Minimum distance from `pos` to any value in `others`.
529fn min_distance_to(pos: f64, others: &[f64]) -> f64 {
530 if others.is_empty() {
531 return f64::INFINITY;
532 }
533 others
534 .iter()
535 .map(|&o| (pos - o).abs())
536 .fold(f64::INFINITY, f64::min)
537}