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::reposition::SpreadEvenly;
8//!
9//! let sim = SimulationBuilder::demo()
10//! .reposition(SpreadEvenly, BuiltinReposition::SpreadEvenly)
11//! .build()
12//! .unwrap();
13//! ```
14
15use std::collections::{BTreeMap, 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). `BTreeMap`
50 /// for deterministic snapshot bytes across processes — `HashMap`
51 /// iteration order varies by per-process hash seed and leaked into
52 /// the postcard byte stream (#254 follow-up). `pub(crate)` rather
53 /// than `pub` so external consumers go through `record_arrival` /
54 /// `is_cooling_down` instead of mutating the map directly, and so a
55 /// future container swap doesn't ripple into downstream API breaks.
56 pub(crate) eligible_at: BTreeMap<EntityId, u64>,
57}
58
59impl RepositionCooldowns {
60 /// Whether `car` is currently under cooldown at `tick`.
61 #[must_use]
62 pub fn is_cooling_down(&self, car: EntityId, tick: u64) -> bool {
63 self.eligible_at
64 .get(&car)
65 .is_some_and(|eligible| tick < *eligible)
66 }
67
68 /// Record a reposition arrival. Sets eligibility to
69 /// `arrival_tick + DEFAULT_REPOSITION_COOLDOWN_TICKS`.
70 pub fn record_arrival(&mut self, car: EntityId, arrival_tick: u64) {
71 self.eligible_at
72 .insert(car, arrival_tick + DEFAULT_REPOSITION_COOLDOWN_TICKS);
73 }
74
75 /// Rewrite every entry's car `EntityId` through `id_remap`, dropping
76 /// any entries whose car wasn't re-allocated during snapshot
77 /// restore. Mirrors `ArrivalLog::remap_entity_ids`.
78 pub fn remap_entity_ids(&mut self, id_remap: &HashMap<EntityId, EntityId>) {
79 let remapped: BTreeMap<EntityId, u64> = std::mem::take(&mut self.eligible_at)
80 .into_iter()
81 .filter_map(|(old, eligible)| id_remap.get(&old).map(|&new| (new, eligible)))
82 .collect();
83 self.eligible_at = remapped;
84 }
85}
86
87/// Distribute idle elevators evenly across the group's stops.
88///
89/// For each idle elevator, assigns it to the stop position that maximizes
90/// the minimum distance from any other (non-idle or already-assigned) elevator.
91/// This spreads coverage across the shaft.
92pub struct SpreadEvenly;
93
94impl RepositionStrategy for SpreadEvenly {
95 fn reposition(
96 &mut self,
97 idle_elevators: &[(EntityId, f64)],
98 stop_positions: &[(EntityId, f64)],
99 group: &ElevatorGroup,
100 world: &World,
101 out: &mut Vec<(EntityId, EntityId)>,
102 ) {
103 if idle_elevators.is_empty() || stop_positions.is_empty() {
104 return;
105 }
106
107 // Collect the *intended resting positions* of all non-idle
108 // elevators in this group — the target stop a car is
109 // committed to, not its transient current position. Without
110 // this, a car already en route to stop X gets counted as
111 // "occupying" wherever it happens to be mid-trip, and the
112 // strategy may spread an idle car straight to the same X.
113 let mut occupied: Vec<f64> = group
114 .elevator_entities()
115 .iter()
116 .filter_map(|&eid| {
117 if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
118 return None;
119 }
120 intended_position(eid, world)
121 })
122 .collect();
123
124 for &(elev_eid, elev_pos) in idle_elevators {
125 // Primary criterion: maximize the minimum distance from any
126 // already-occupied position (true "spread"). Tie-breaker:
127 // prefer the stop closest to the elevator's current position
128 // — otherwise, with no occupied positions at sim start, every
129 // stop is tied at `INFINITY` and `max_by`'s last-wins default
130 // ships every car to the topmost stop. That was the reported
131 // "cars travel to the top at sim start with no demand" bug.
132 let best = stop_positions.iter().max_by(|a, b| {
133 let min_a = min_distance_to(a.1, &occupied);
134 let min_b = min_distance_to(b.1, &occupied);
135 min_a.total_cmp(&min_b).then_with(|| {
136 let dist_a = (a.1 - elev_pos).abs();
137 let dist_b = (b.1 - elev_pos).abs();
138 // `max_by` returns the greater element; invert so the
139 // closer stop to the elevator is considered greater.
140 dist_b.total_cmp(&dist_a)
141 })
142 });
143
144 if let Some(&(stop_eid, stop_pos)) = best {
145 if (stop_pos - elev_pos).abs() > 1e-6 {
146 out.push((elev_eid, stop_eid));
147 }
148 occupied.push(stop_pos);
149 }
150 }
151 }
152
153 fn builtin_id(&self) -> Option<super::BuiltinReposition> {
154 Some(super::BuiltinReposition::SpreadEvenly)
155 }
156}
157
158/// Return idle elevators to a configured home stop (default: first stop).
159///
160/// Classic lobby-return strategy. All idle elevators converge on a single
161/// designated stop, typically the ground floor or main lobby.
162pub struct ReturnToLobby {
163 /// Index into the group's stop list for the home stop.
164 /// Defaults to 0 (first stop).
165 pub home_stop_index: usize,
166}
167
168impl ReturnToLobby {
169 /// Create with default home stop (index 0).
170 #[must_use]
171 pub const fn new() -> Self {
172 Self { home_stop_index: 0 }
173 }
174
175 /// Create with a specific home stop index.
176 #[must_use]
177 pub const fn with_home(index: usize) -> Self {
178 Self {
179 home_stop_index: index,
180 }
181 }
182}
183
184impl Default for ReturnToLobby {
185 fn default() -> Self {
186 Self::new()
187 }
188}
189
190impl RepositionStrategy for ReturnToLobby {
191 fn reposition(
192 &mut self,
193 idle_elevators: &[(EntityId, f64)],
194 stop_positions: &[(EntityId, f64)],
195 _group: &ElevatorGroup,
196 _world: &World,
197 out: &mut Vec<(EntityId, EntityId)>,
198 ) {
199 let Some(&(home_eid, home_pos)) = stop_positions.get(self.home_stop_index) else {
200 return;
201 };
202
203 out.extend(
204 idle_elevators
205 .iter()
206 .filter(|(_, pos)| (*pos - home_pos).abs() > 1e-6)
207 .map(|&(eid, _)| (eid, home_eid)),
208 );
209 }
210
211 fn builtin_id(&self) -> Option<super::BuiltinReposition> {
212 Some(super::BuiltinReposition::ReturnToLobby)
213 }
214}
215
216/// Position idle elevators near stops with historically high demand.
217///
218/// Reads per-stop throughput from the [`MetricTags`] system to weight
219/// stop positions. Idle elevators are assigned to the highest-demand
220/// stops that don't already have an elevator nearby.
221pub struct DemandWeighted;
222
223impl RepositionStrategy for DemandWeighted {
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 if idle_elevators.is_empty() || stop_positions.is_empty() {
233 return;
234 }
235
236 let tags = world.resource::<MetricTags>();
237 // `demand + 1.0` keeps zero-demand stops in the running — the
238 // strategy still produces a spread at sim start before any
239 // deliveries have been recorded.
240 let mut scored: Vec<(EntityId, f64, f64)> = stop_positions
241 .iter()
242 .map(|&(stop_eid, stop_pos)| {
243 let demand = tags
244 .and_then(|t| {
245 t.tags_for(stop_eid)
246 .iter()
247 .filter_map(|tag| t.metric(tag).map(TaggedMetric::total_delivered))
248 .max()
249 })
250 .unwrap_or(0) as f64;
251 (stop_eid, stop_pos, demand + 1.0)
252 })
253 .collect();
254 scored.sort_by(|a, b| b.2.total_cmp(&a.2));
255
256 assign_greedy_by_score(&scored, idle_elevators, group, world, out);
257 }
258
259 fn builtin_id(&self) -> Option<super::BuiltinReposition> {
260 Some(super::BuiltinReposition::DemandWeighted)
261 }
262}
263
264/// Predictive parking: park idle elevators near stops with the
265/// highest recent per-stop arrival rate.
266///
267/// Reads the [`ArrivalLog`] and [`CurrentTick`] world resources
268/// (always present under a built sim) to compute a rolling window of
269/// arrivals. Cars are greedily assigned to the highest-rate stops that
270/// don't already have a car nearby, so the group spreads across the
271/// hottest floors rather than clustering on one.
272///
273/// Parallels the headline feature of Otis Compass Infinity — forecast
274/// demand from recent traffic, pre-position cars accordingly. Falls
275/// back to no-op when no arrivals have been logged.
276pub struct PredictiveParking {
277 /// Rolling window (ticks) used to compute per-stop arrival counts.
278 /// Shorter windows react faster; longer windows smooth noise.
279 window_ticks: u64,
280}
281
282impl PredictiveParking {
283 /// Create with the default rolling window
284 /// ([`DEFAULT_ARRIVAL_WINDOW_TICKS`]).
285 #[must_use]
286 pub const fn new() -> Self {
287 Self {
288 window_ticks: DEFAULT_ARRIVAL_WINDOW_TICKS,
289 }
290 }
291
292 /// Create with a custom rolling window (ticks). Shorter windows
293 /// react faster to traffic shifts; longer windows smooth out noise.
294 ///
295 /// # Panics
296 /// Panics on `window_ticks == 0`. A zero window would cause
297 /// `ArrivalLog::arrivals_in_window` to return 0 for every stop —
298 /// the strategy would silently no-op, which is almost never what
299 /// the caller meant.
300 #[must_use]
301 pub const fn with_window_ticks(window_ticks: u64) -> Self {
302 assert!(
303 window_ticks > 0,
304 "PredictiveParking::with_window_ticks requires a positive window"
305 );
306 Self { window_ticks }
307 }
308}
309
310impl Default for PredictiveParking {
311 fn default() -> Self {
312 Self::new()
313 }
314}
315
316impl RepositionStrategy for PredictiveParking {
317 fn reposition(
318 &mut self,
319 idle_elevators: &[(EntityId, f64)],
320 stop_positions: &[(EntityId, f64)],
321 group: &ElevatorGroup,
322 world: &World,
323 out: &mut Vec<(EntityId, EntityId)>,
324 ) {
325 if idle_elevators.is_empty() || stop_positions.is_empty() {
326 return;
327 }
328 let Some(log) = world.resource::<ArrivalLog>() else {
329 return;
330 };
331 let now = world.resource::<CurrentTick>().map_or(0, |ct| ct.0);
332
333 // Score each stop by its arrival count over the window. Keep
334 // only positives — stops with zero recent arrivals are not
335 // parking targets (no signal to act on).
336 let mut scored: Vec<(EntityId, f64, u64)> = stop_positions
337 .iter()
338 .filter_map(|&(sid, pos)| {
339 let count = log.arrivals_in_window(sid, now, self.window_ticks);
340 (count > 0).then_some((sid, pos, count))
341 })
342 .collect();
343 if scored.is_empty() {
344 return;
345 }
346 // Highest arrival count first; stable sort preserves stop-id
347 // order on ties so the result stays deterministic.
348 scored.sort_by_key(|(_, _, count)| std::cmp::Reverse(*count));
349
350 assign_greedy_by_score(&scored, idle_elevators, group, world, out);
351 }
352
353 fn builtin_id(&self) -> Option<super::BuiltinReposition> {
354 Some(super::BuiltinReposition::PredictiveParking)
355 }
356
357 fn min_arrival_log_window(&self) -> u64 {
358 self.window_ticks
359 }
360}
361
362/// Mode-gated reposition: dispatches to an inner strategy chosen
363/// by the current [`TrafficMode`](crate::traffic_detector::TrafficMode).
364///
365/// Closes the playground-reported "chaotic repositioning" complaint:
366/// the single-strategy defaults either lock cars to the lobby
367/// ([`ReturnToLobby`]) or shuttle them toward the hottest stop
368/// ([`PredictiveParking`]) regardless of traffic shape. Adaptive
369/// picks per mode:
370///
371/// | Mode | Inner |
372/// |------------------------------------------------------------------|----------------------|
373/// | [`UpPeak`](crate::traffic_detector::TrafficMode::UpPeak) | [`ReturnToLobby`] |
374/// | [`InterFloor`](crate::traffic_detector::TrafficMode::InterFloor) | [`PredictiveParking`]|
375/// | [`DownPeak`](crate::traffic_detector::TrafficMode::DownPeak) | [`PredictiveParking`]|
376/// | [`Idle`](crate::traffic_detector::TrafficMode::Idle) | no-op (stay put) |
377///
378/// `DownPeak` reuses `PredictiveParking` intentionally: during a down
379/// peak, upper floors are the high-arrival stops (riders spawn there
380/// heading to the lobby), and `PredictiveParking` scores stops by
381/// [`ArrivalLog`] counts — so it correctly biases idle cars upward
382/// without needing a destination-aware variant. Falls back to
383/// `InterFloor` routing if the detector is missing from `World`
384/// (e.g. hand-built tests bypassing `Simulation`).
385pub struct AdaptiveParking {
386 /// Inner strategy used in up-peak mode. Configurable so games
387 /// can pin a different home stop (sky-lobby buildings, e.g.).
388 return_to_lobby: ReturnToLobby,
389 /// Inner strategy used when demand is diffuse or heading down.
390 predictive: PredictiveParking,
391}
392
393impl AdaptiveParking {
394 /// Create with defaults: `ReturnToLobby::new()` (home = stop 0)
395 /// and `PredictiveParking::new()` (default rolling window).
396 #[must_use]
397 pub const fn new() -> Self {
398 Self {
399 return_to_lobby: ReturnToLobby::new(),
400 predictive: PredictiveParking::new(),
401 }
402 }
403
404 /// Override the home stop used during `UpPeak`. Same semantics as
405 /// [`ReturnToLobby::with_home`].
406 #[must_use]
407 pub const fn with_home(mut self, index: usize) -> Self {
408 self.return_to_lobby = ReturnToLobby::with_home(index);
409 self
410 }
411
412 /// Override the window used for `InterFloor` / `DownPeak`
413 /// predictive parking. Same semantics as
414 /// [`PredictiveParking::with_window_ticks`].
415 ///
416 /// # Panics
417 /// Panics on `window_ticks = 0`, matching `PredictiveParking`.
418 #[must_use]
419 pub const fn with_window_ticks(mut self, window_ticks: u64) -> Self {
420 self.predictive = PredictiveParking::with_window_ticks(window_ticks);
421 self
422 }
423}
424
425impl Default for AdaptiveParking {
426 fn default() -> Self {
427 Self::new()
428 }
429}
430
431impl RepositionStrategy for AdaptiveParking {
432 fn reposition(
433 &mut self,
434 idle_elevators: &[(EntityId, f64)],
435 stop_positions: &[(EntityId, f64)],
436 group: &ElevatorGroup,
437 world: &World,
438 out: &mut Vec<(EntityId, EntityId)>,
439 ) {
440 use crate::traffic_detector::{TrafficDetector, TrafficMode};
441 let mode = world
442 .resource::<TrafficDetector>()
443 .map_or(TrafficMode::InterFloor, TrafficDetector::current_mode);
444 match mode {
445 TrafficMode::Idle => {
446 // Stay put — no point commuting when there's no
447 // demand to pre-position for.
448 }
449 TrafficMode::UpPeak => {
450 self.return_to_lobby
451 .reposition(idle_elevators, stop_positions, group, world, out);
452 }
453 TrafficMode::DownPeak | TrafficMode::InterFloor => {
454 self.predictive
455 .reposition(idle_elevators, stop_positions, group, world, out);
456 }
457 }
458 }
459
460 fn builtin_id(&self) -> Option<super::BuiltinReposition> {
461 Some(super::BuiltinReposition::Adaptive)
462 }
463
464 fn min_arrival_log_window(&self) -> u64 {
465 self.predictive.min_arrival_log_window()
466 }
467}
468
469/// No-op strategy: idle elevators stay where they stopped.
470///
471/// Use this to disable repositioning for a group while keeping
472/// the repositioning phase active for other groups.
473pub struct NearestIdle;
474
475impl RepositionStrategy for NearestIdle {
476 fn reposition(
477 &mut self,
478 _idle_elevators: &[(EntityId, f64)],
479 _stop_positions: &[(EntityId, f64)],
480 _group: &ElevatorGroup,
481 _world: &World,
482 _out: &mut Vec<(EntityId, EntityId)>,
483 ) {
484 }
485
486 fn builtin_id(&self) -> Option<super::BuiltinReposition> {
487 Some(super::BuiltinReposition::NearestIdle)
488 }
489}
490
491/// Shared greedy-assign step for score-driven parking strategies.
492///
493/// `scored` is the list of `(stop_id, stop_pos, _score)` in descending
494/// priority order (strategies sort/filter upstream). For each stop in
495/// that order, pick the closest still-unassigned idle elevator and
496/// send it there — unless the stop is already covered by a non-idle
497/// car or the closest idle car is already parked on it.
498///
499/// The tuple's third element is ignored here; it exists only to keep
500/// the caller's scoring type visible at the call site.
501fn assign_greedy_by_score<S>(
502 scored: &[(EntityId, f64, S)],
503 idle_elevators: &[(EntityId, f64)],
504 group: &ElevatorGroup,
505 world: &World,
506 out: &mut Vec<(EntityId, EntityId)>,
507) {
508 // Intended resting positions of all non-idle elevators — avoid
509 // parking on top of cars already committed to a stop, whether
510 // currently at that stop or still travelling there. Using
511 // `world.position` alone would miss the second case and allow an
512 // idle car to be assigned to a target another car is already
513 // en route to.
514 let mut occupied: Vec<f64> = group
515 .elevator_entities()
516 .iter()
517 .filter_map(|&eid| {
518 if idle_elevators.iter().any(|(ie, _)| *ie == eid) {
519 return None;
520 }
521 intended_position(eid, world)
522 })
523 .collect();
524
525 let mut assigned: Vec<EntityId> = Vec::new();
526 for (stop_eid, stop_pos, _) in scored {
527 if min_distance_to(*stop_pos, &occupied) < 1e-6 {
528 continue;
529 }
530
531 let closest = idle_elevators
532 .iter()
533 .filter(|(eid, _)| !assigned.contains(eid))
534 .min_by(|a, b| (a.1 - stop_pos).abs().total_cmp(&(b.1 - stop_pos).abs()));
535
536 if let Some(&(elev_eid, elev_pos)) = closest
537 && (elev_pos - stop_pos).abs() > 1e-6
538 {
539 out.push((elev_eid, *stop_eid));
540 assigned.push(elev_eid);
541 occupied.push(*stop_pos);
542 }
543
544 if assigned.len() == idle_elevators.len() {
545 break;
546 }
547 }
548}
549
550/// Where a non-idle elevator is headed — its target-stop position
551/// when one is set, else its current position. Reposition strategies
552/// use this to build the "occupied" list so a car already en route
553/// to stop X is counted as occupying X (not its transient mid-trip
554/// position). Without this, a second car could be sent to the same
555/// X because the first car doesn't yet appear to be "there."
556fn intended_position(eid: EntityId, world: &World) -> Option<f64> {
557 if let Some(car) = world.elevator(eid)
558 && let Some(target) = car.target_stop()
559 && let Some(target_pos) = world.stop_position(target)
560 {
561 return Some(target_pos);
562 }
563 world.position(eid).map(|p| p.value)
564}
565
566/// Minimum distance from `pos` to any value in `others`.
567fn min_distance_to(pos: f64, others: &[f64]) -> f64 {
568 if others.is_empty() {
569 return f64::INFINITY;
570 }
571 others
572 .iter()
573 .map(|&o| (pos - o).abs())
574 .fold(f64::INFINITY, f64::min)
575}