elevator_core/dispatch/destination.rs
1//! Hall-call destination dispatch ("DCS").
2//!
3//! Destination dispatch assigns each rider to a specific car at hall-call
4//! time (when their destination is first known) and the assignment is
5//! **sticky** — it never changes for the rider's lifetime, and no other car
6//! will pick them up. The controller minimizes each rider's own travel time,
7//! using a simple cost model:
8//!
9//! ```text
10//! J(C) = pickup_time(C, origin)
11//! + ride_time(origin, dest)
12//! + stop_penalty * new_stops_added(C, origin, dest)
13//! ```
14//!
15//! Assignments are recorded as an [`AssignedCar`] extension component on the
16//! rider; the loading filter in `crate::systems::loading` consults this to
17//! enforce the stickiness invariant.
18//!
19//! This is a sim — not a faithful reproduction of any vendor's controller.
20//! Each assigned car's [`DestinationQueue`](crate::components::DestinationQueue)
21//! is rebuilt every dispatch tick from the set of live sticky commitments
22//! (waiting riders contribute origin + dest; riding riders contribute dest)
23//! and arranged into a direction-aware two-run (plus fallback third-run)
24//! monotone sequence so the car visits stops in sweep order rather than
25//! in the order assignments arrived.
26
27use std::collections::HashSet;
28
29use serde::{Deserialize, Serialize};
30
31use crate::components::{DestinationQueue, Direction, ElevatorPhase};
32use crate::entity::EntityId;
33use crate::world::{ExtKey, World};
34
35use super::{DispatchManifest, DispatchStrategy, ElevatorGroup, RankContext, pair_can_do_work};
36
37/// Sticky rider → car assignment produced by [`DestinationDispatch`].
38///
39/// Stored as an extension component on the rider entity. Once set, the
40/// assignment is never mutated; the loading phase uses it to enforce
41/// that only the assigned car may board the rider.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub struct AssignedCar(pub EntityId);
44
45/// Typed extension key for [`AssignedCar`] storage.
46pub const ASSIGNED_CAR_KEY: ExtKey<AssignedCar> = ExtKey::new("assigned_car");
47
48/// Hall-call destination dispatch (DCS).
49///
50/// ## API shape
51///
52/// Uses [`DispatchStrategy::pre_dispatch`] to write sticky
53/// [`AssignedCar`] extensions and rebuild each car's committed stop
54/// queue during a `&mut World` phase. [`DispatchStrategy::rank`] then
55/// routes each car to its own queue front and returns `None` for every
56/// other stop, so the group-wide Hungarian assignment trivially pairs
57/// each car with the stop it has already committed to.
58pub struct DestinationDispatch {
59 /// Weight for per-stop door overhead in the cost function. A positive
60 /// value biases assignments toward cars whose route change adds no
61 /// fresh stops; set via [`with_stop_penalty`](Self::with_stop_penalty).
62 ///
63 /// Units: ticks per newly-added stop. `None` ⇒ derive from the car's
64 /// own door timings (~`open + 2 * transition`).
65 stop_penalty: Option<f64>,
66 /// Deferred-commitment window. When `Some(window)`, a rider's
67 /// sticky assignment is re-evaluated each pass until the assigned
68 /// car is within `window` ticks of the rider's origin — modelling
69 /// KONE Polaris's two-button reallocation regime (DCS calls fix on
70 /// press; two-button hall calls re-allocate continuously until
71 /// commitment). `None` ⇒ immediate sticky (the default), matching
72 /// fixed-on-press DCS behavior.
73 commitment_window_ticks: Option<u64>,
74}
75
76impl DestinationDispatch {
77 /// Create a new `DestinationDispatch` with defaults (immediate sticky,
78 /// no commitment window).
79 #[must_use]
80 pub const fn new() -> Self {
81 Self {
82 stop_penalty: None,
83 commitment_window_ticks: None,
84 }
85 }
86
87 /// Override the fresh-stop penalty (ticks per new stop added to a
88 /// car's committed route when it picks this rider up).
89 #[must_use]
90 pub const fn with_stop_penalty(mut self, penalty: f64) -> Self {
91 self.stop_penalty = Some(penalty);
92 self
93 }
94
95 /// Enable deferred commitment: riders' sticky assignments are
96 /// re-evaluated each pass until the currently-assigned car is
97 /// within `window` ticks of the rider's origin. At that point the
98 /// commitment latches and later ticks leave the assignment alone.
99 #[must_use]
100 pub const fn with_commitment_window_ticks(mut self, window: u64) -> Self {
101 self.commitment_window_ticks = Some(window);
102 self
103 }
104}
105
106impl Default for DestinationDispatch {
107 fn default() -> Self {
108 Self::new()
109 }
110}
111
112impl DispatchStrategy for DestinationDispatch {
113 #[allow(clippy::too_many_lines)]
114 fn pre_dispatch(
115 &mut self,
116 group: &ElevatorGroup,
117 manifest: &DispatchManifest,
118 world: &mut World,
119 ) {
120 // DCS requires the group to be in `HallCallMode::Destination` — that
121 // mode is what makes the kiosk-style "rider announces destination
122 // at press time" assumption hold. In Classic collective-control
123 // mode destinations aren't known until riders board, so running
124 // DCS there would commit assignments based on information a real
125 // controller wouldn't have. Early-return makes DCS a no-op for
126 // misconfigured groups; pair it with the right mode to activate.
127 if group.hall_call_mode() != super::HallCallMode::Destination {
128 return;
129 }
130
131 // Candidate cars in this group that are operable for dispatch.
132 let candidate_cars: Vec<EntityId> = group
133 .elevator_entities()
134 .iter()
135 .copied()
136 .filter(|eid| !world.is_disabled(*eid))
137 .filter(|eid| {
138 !world
139 .service_mode(*eid)
140 .is_some_and(|m| m.is_dispatch_excluded())
141 })
142 .filter(|eid| world.elevator(*eid).is_some())
143 .collect();
144
145 if candidate_cars.is_empty() {
146 return;
147 }
148
149 // Collect unassigned waiting riders in this group. A sticky
150 // assignment whose target car is dead or disabled is treated as
151 // void — re-assign rather than strand. (Lifecycle hooks in
152 // `disable`/`remove_elevator` normally clear these; this is the
153 // defense layer if cleanup is ever missed.)
154 let mut stale_assignments: Vec<EntityId> = Vec::new();
155 let mut pending: Vec<(EntityId, EntityId, EntityId, f64)> = Vec::new();
156 for (_, riders) in manifest.iter_waiting_stops() {
157 for info in riders {
158 if let Some(AssignedCar(c)) = world.ext::<AssignedCar>(info.id) {
159 // An assignment stays sticky only when the target
160 // car is still alive and (no commitment window is
161 // configured, or the car is already inside the
162 // latch window). Otherwise strip it so the rider
163 // re-competes below.
164 let alive = world.elevator(c).is_some() && !world.is_disabled(c);
165 let latched = self
166 .commitment_window_ticks
167 .is_none_or(|w| assigned_car_within_window(world, info.id, c, w));
168 if alive && latched {
169 continue; // sticky and live
170 }
171 stale_assignments.push(info.id);
172 }
173 let Some(dest) = info.destination else {
174 continue;
175 };
176 let Some(route) = world.route(info.id) else {
177 continue;
178 };
179 let Some(leg) = route.current() else {
180 continue;
181 };
182 if !group.accepts_leg(leg) {
183 continue;
184 }
185 pending.push((info.id, leg.from, dest, info.weight.value()));
186 }
187 }
188 pending.sort_by_key(|(rid, ..)| *rid);
189 // Drop stale extensions so subsequent ticks see them as unassigned.
190 for rid in stale_assignments {
191 world.remove_ext::<AssignedCar>(rid);
192 }
193
194 // Pre-compute committed-load per candidate car: aboard total
195 // (`current_load`) plus Waiting riders sticky-assigned to it.
196 // Terminal-phase riders whose `AssignedCar` was not cleaned up
197 // are filtered by the `RiderPhase::Waiting` check below.
198 let mut committed_load: std::collections::BTreeMap<EntityId, f64> =
199 std::collections::BTreeMap::new();
200 for &eid in &candidate_cars {
201 if let Some(car) = world.elevator(eid) {
202 committed_load.insert(eid, car.current_load().value());
203 }
204 }
205 let waiting_assignments: Vec<(EntityId, EntityId)> = world
206 .ext_map::<AssignedCar>()
207 .map(|m| m.iter().map(|(rid, AssignedCar(c))| (rid, *c)).collect())
208 .unwrap_or_default();
209 for (rid, car) in waiting_assignments {
210 if let Some(rider) = world.rider(rid)
211 && rider.phase() == crate::components::RiderPhase::Waiting
212 {
213 *committed_load.entry(car).or_insert(0.0) += rider.weight.value();
214 }
215 }
216
217 for (rid, origin, dest, weight) in pending {
218 let best = candidate_cars
219 .iter()
220 .filter_map(|&eid| {
221 let car = world.elevator(eid)?;
222 if car.restricted_stops().contains(&dest)
223 || car.restricted_stops().contains(&origin)
224 {
225 return None;
226 }
227 if car.weight_capacity().value() > 0.0 && weight > car.weight_capacity().value()
228 {
229 return None;
230 }
231 let com = committed_load.get(&eid).copied().unwrap_or(0.0);
232 let cost = self.compute_cost(eid, origin, dest, world, com);
233 if cost.is_finite() {
234 Some((eid, cost))
235 } else {
236 None
237 }
238 })
239 .min_by(|a, b| a.1.total_cmp(&b.1))
240 .map(|(eid, _)| eid);
241
242 let Some(car_eid) = best else {
243 continue;
244 };
245 world.insert_ext(rid, AssignedCar(car_eid), ASSIGNED_CAR_KEY);
246 *committed_load.entry(car_eid).or_insert(0.0) += weight;
247 }
248
249 // Rebuild each candidate car's destination queue from the current
250 // set of sticky commitments, arranged in direction-aware two-run
251 // monotone order. This is the source of truth per tick and avoids
252 // incremental-insertion drift (duplicates, orphaned entries).
253 for &car_eid in &candidate_cars {
254 rebuild_car_queue(world, car_eid);
255 }
256 }
257
258 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
259 // The queue is the source of truth — route each car strictly to
260 // its own queue front. Every other stop is unavailable for this
261 // car, so the Hungarian assignment reduces to the identity match
262 // between each car and the stop it has already committed to.
263 //
264 // The `pair_can_do_work` gate guards against the same full-car
265 // self-assign stall the other built-ins close: a sticky DCS
266 // assignment whose car has filled up with earlier riders and
267 // whose queue front is still the *pickup* for an un-boarded
268 // rider would otherwise rank 0.0, win the Hungarian every tick,
269 // and cycle doors forever.
270 let front = ctx
271 .world
272 .destination_queue(ctx.car)
273 .and_then(DestinationQueue::front)?;
274 if front == ctx.stop && pair_can_do_work(ctx) {
275 Some(0.0)
276 } else {
277 None
278 }
279 }
280}
281
282impl DestinationDispatch {
283 /// Compute the assignment cost of sending car `eid` to pick up a rider
284 /// whose route is `origin → dest`.
285 fn compute_cost(
286 &self,
287 eid: EntityId,
288 origin: EntityId,
289 dest: EntityId,
290 world: &World,
291 committed_load: f64,
292 ) -> f64 {
293 let Some(car) = world.elevator(eid) else {
294 return f64::INFINITY;
295 };
296 if car.max_speed().value() <= 0.0 {
297 return f64::INFINITY;
298 }
299
300 let Some(car_pos) = world.position(eid).map(|p| p.value) else {
301 return f64::INFINITY;
302 };
303 let Some(origin_pos) = world.stop_position(origin) else {
304 return f64::INFINITY;
305 };
306 let Some(dest_pos) = world.stop_position(dest) else {
307 return f64::INFINITY;
308 };
309
310 let door_overhead = f64::from(car.door_transition_ticks() * 2 + car.door_open_ticks());
311 let penalty = self.stop_penalty.unwrap_or_else(|| door_overhead.max(1.0));
312
313 // Pickup time: direct distance + per-stop door overhead for each
314 // committed stop that lies between the car and the origin.
315 let pickup_dist = (car_pos - origin_pos).abs();
316 let pickup_travel = pickup_dist / car.max_speed().value();
317 let intervening_committed = world.destination_queue(eid).map_or(0usize, |q| {
318 let (lo, hi) = if car_pos < origin_pos {
319 (car_pos, origin_pos)
320 } else {
321 (origin_pos, car_pos)
322 };
323 q.queue()
324 .iter()
325 .filter_map(|s| world.stop_position(*s))
326 .filter(|p| *p > lo + 1e-9 && *p < hi - 1e-9)
327 .count()
328 });
329 let pickup_time = (intervening_committed as f64).mul_add(door_overhead, pickup_travel);
330
331 // Ride time: origin → dest travel + door overhead at origin pickup.
332 let ride_dist = (origin_pos - dest_pos).abs();
333 let ride_time = ride_dist / car.max_speed().value() + door_overhead;
334
335 // Fresh stops added: 0, 1, or 2 depending on whether origin/dest
336 // are already queued for this car. Probe the queue slice directly
337 // instead of cloning it — `compute_cost` runs once per
338 // (car, candidate-rider) pair each DCS tick, and at the scale of a
339 // busy commercial group the Vec clone was the dominant allocation
340 // in `pre_dispatch`.
341 let queue_contains = |s: EntityId| {
342 world
343 .destination_queue(eid)
344 .is_some_and(|q| q.queue().contains(&s))
345 };
346 let mut new_stops = 0f64;
347 if !queue_contains(origin) {
348 new_stops += 1.0;
349 }
350 if dest != origin && !queue_contains(dest) {
351 new_stops += 1.0;
352 }
353
354 // Idle bias: empty cars get a small bonus so the load spreads.
355 let idle_bonus = if car.phase() == ElevatorPhase::Idle && car.riders().is_empty() {
356 -0.1 * pickup_travel
357 } else {
358 0.0
359 };
360
361 // Load bias: include both aboard and already-assigned-but-waiting
362 // riders so dispatch spreads load even before any boarding happens.
363 let load_penalty = if car.weight_capacity().value() > 0.0 {
364 let effective = car.current_load().value().max(committed_load);
365 let ratio = (effective / car.weight_capacity().value()).min(2.0);
366 ratio * door_overhead * 4.0
367 } else {
368 0.0
369 };
370
371 pickup_time + ride_time + penalty * new_stops + idle_bonus + load_penalty
372 }
373}
374
375/// True when the `car` assigned to `rider` is within `window` ticks of
376/// the rider's origin, measured by raw distance / `max_speed`. Used to
377/// decide whether a deferred commitment has latched.
378fn assigned_car_within_window(
379 world: &crate::world::World,
380 rider: EntityId,
381 car: EntityId,
382 window: u64,
383) -> bool {
384 let Some(leg) = world.route(rider).and_then(|r| r.current()) else {
385 return false;
386 };
387 let Some(origin_pos) = world.stop_position(leg.from) else {
388 return false;
389 };
390 let Some(car_pos) = world.position(car).map(|p| p.value) else {
391 return false;
392 };
393 let Some(car_data) = world.elevator(car) else {
394 return false;
395 };
396 let speed = car_data.max_speed().value();
397 if !speed.is_finite() || speed <= 0.0 {
398 return false;
399 }
400 // `distance / speed` is seconds (speed is distance/second); convert
401 // to ticks so `window` is apples-to-apples. Same class of unit fix
402 // as ETD's door-cost conversion (see `etd.rs`). Fall back to 60 Hz
403 // for bare-World fixtures that don't seat a `TickRate` resource.
404 let tick_rate = world
405 .resource::<crate::time::TickRate>()
406 .map_or(60.0, |r| r.0);
407 let eta_ticks = (car_pos - origin_pos).abs() / speed * tick_rate;
408 // A non-finite ETA (NaN from corrupted position) would saturate
409 // the `as u64` cast to 0 and erroneously latch the commitment —
410 // refuse to latch instead.
411 if !eta_ticks.is_finite() {
412 return false;
413 }
414 eta_ticks.round() as u64 <= window
415}
416
417/// Drop every sticky [`AssignedCar`] assignment that points at `car_eid`.
418///
419/// Called by `Simulation::disable` and `Simulation::remove_elevator` when an
420/// elevator leaves service, so DCS-routed riders are not stranded behind a
421/// dead reference.
422pub fn clear_assignments_to(world: &mut crate::world::World, car_eid: EntityId) {
423 let stale: Vec<EntityId> = world
424 .ext_map::<AssignedCar>()
425 .map(|m| {
426 m.iter()
427 .filter_map(|(rid, AssignedCar(c))| (*c == car_eid).then_some(rid))
428 .collect()
429 })
430 .unwrap_or_default();
431 for rid in stale {
432 world.remove_ext::<AssignedCar>(rid);
433 }
434}
435
436/// Rebuild `car_eid`'s destination queue from all live sticky commitments.
437///
438/// Scans all riders assigned to this car and collects the set of stops it
439/// must visit:
440/// - waiting riders contribute both their origin and destination,
441/// - riding/boarding riders contribute just their destination (origin
442/// already visited).
443///
444/// The stops are then arranged into a two-run monotone sequence: the
445/// current sweep (in the car's current direction) followed by the reverse
446/// sweep. A third run is appended when a rider's trip reverses the sweep
447/// twice (origin behind, dest ahead of origin in the original sweep).
448#[allow(clippy::too_many_lines)]
449fn rebuild_car_queue(world: &mut crate::world::World, car_eid: EntityId) {
450 use crate::components::RiderPhase;
451
452 // Local type for gathered (origin?, dest) trips.
453 struct Trip {
454 origin: Option<EntityId>,
455 dest: EntityId,
456 }
457
458 let Some(car) = world.elevator(car_eid) else {
459 return;
460 };
461 let car_pos = world.position(car_eid).map_or(0.0, |p| p.value);
462 let sweep_up = match car.direction() {
463 Direction::Up | Direction::Either => true,
464 Direction::Down => false,
465 };
466
467 // Skip inserting a stop the car is currently parked at and loading.
468 let at_stop_loading: Option<EntityId> = {
469 let stopped_here = !matches!(
470 car.phase(),
471 ElevatorPhase::MovingToStop(_) | ElevatorPhase::Repositioning(_)
472 );
473 if stopped_here {
474 world.find_stop_at_position(car_pos)
475 } else {
476 None
477 }
478 };
479
480 // Gather (origin?, dest) pairs from sticky-assigned riders on this car.
481 let assigned_ids: Vec<EntityId> = world
482 .ext_map::<AssignedCar>()
483 .map(|m| {
484 m.iter()
485 .filter_map(|(rid, AssignedCar(c))| (*c == car_eid).then_some(rid))
486 .collect()
487 })
488 .unwrap_or_default();
489
490 let mut trips: Vec<Trip> = Vec::new();
491 for rid in assigned_ids {
492 let Some(rider) = world.rider(rid) else {
493 continue;
494 };
495 let Some(dest) = world
496 .route(rid)
497 .and_then(crate::components::Route::current_destination)
498 else {
499 continue;
500 };
501 match rider.phase() {
502 RiderPhase::Waiting => {
503 let origin = world
504 .route(rid)
505 .and_then(|r| r.current().map(|leg| leg.from));
506 // Strip origin if car is parked at it right now.
507 let origin = origin.filter(|o| Some(*o) != at_stop_loading);
508 trips.push(Trip { origin, dest });
509 }
510 RiderPhase::Boarding(_) | RiderPhase::Riding(_) => {
511 trips.push(Trip { origin: None, dest });
512 }
513 _ => {}
514 }
515 }
516
517 if trips.is_empty() {
518 if let Some(q) = world.destination_queue_mut(car_eid) {
519 q.clear();
520 }
521 return;
522 }
523
524 // Bucket each stop into up to three runs based on the car's direction:
525 // run1 = current sweep (same direction as car)
526 // run2 = reverse sweep
527 // run3 = second sweep in the original direction (for trips whose
528 // origin is behind the sweep but dest is further in it)
529 let mut run1: Vec<(EntityId, f64)> = Vec::new();
530 let mut run2: Vec<(EntityId, f64)> = Vec::new();
531 let mut run3: Vec<(EntityId, f64)> = Vec::new();
532
533 let in_run1 = |sp: f64| -> bool {
534 if sweep_up {
535 sp >= car_pos - 1e-9
536 } else {
537 sp <= car_pos + 1e-9
538 }
539 };
540
541 let push_unique = |v: &mut Vec<(EntityId, f64)>, s: EntityId, p: f64| {
542 if !v.iter().any(|(e, _)| *e == s) {
543 v.push((s, p));
544 }
545 };
546
547 for trip in &trips {
548 let dp = world.stop_position(trip.dest).unwrap_or(car_pos);
549 if let Some(o) = trip.origin {
550 let op = world.stop_position(o).unwrap_or(car_pos);
551 let o_in_run1 = in_run1(op);
552 let d_in_run1 = in_run1(dp);
553 if o_in_run1 {
554 push_unique(&mut run1, o, op);
555 if d_in_run1 {
556 // Both in run1: dest must be further in sweep than origin.
557 let d_fits = if sweep_up {
558 dp >= op - 1e-9
559 } else {
560 dp <= op + 1e-9
561 };
562 if d_fits {
563 push_unique(&mut run1, trip.dest, dp);
564 } else {
565 // Dest is behind origin in sweep: needs reverse run.
566 push_unique(&mut run2, trip.dest, dp);
567 }
568 } else {
569 push_unique(&mut run2, trip.dest, dp);
570 }
571 } else {
572 // Origin is behind sweep: both go in reverse/second run.
573 push_unique(&mut run2, o, op);
574 if d_in_run1 {
575 // Origin behind, dest ahead: need a third sweep.
576 push_unique(&mut run3, trip.dest, dp);
577 } else {
578 // Both behind sweep. Within reverse run, order dest
579 // after origin (dest further into reverse direction).
580 let d_further = if sweep_up {
581 dp <= op + 1e-9
582 } else {
583 dp >= op - 1e-9
584 };
585 if d_further {
586 push_unique(&mut run2, trip.dest, dp);
587 } else {
588 push_unique(&mut run3, trip.dest, dp);
589 }
590 }
591 }
592 } else {
593 // No origin: just drop off. Place dest in whichever run contains it.
594 if in_run1(dp) {
595 push_unique(&mut run1, trip.dest, dp);
596 } else {
597 push_unique(&mut run2, trip.dest, dp);
598 }
599 }
600 }
601
602 // Sort each run monotonically.
603 if sweep_up {
604 run1.sort_by(|a, b| a.1.total_cmp(&b.1));
605 run2.sort_by(|a, b| b.1.total_cmp(&a.1));
606 run3.sort_by(|a, b| a.1.total_cmp(&b.1));
607 } else {
608 run1.sort_by(|a, b| b.1.total_cmp(&a.1));
609 run2.sort_by(|a, b| a.1.total_cmp(&b.1));
610 run3.sort_by(|a, b| b.1.total_cmp(&a.1));
611 }
612
613 let mut out: Vec<EntityId> = Vec::with_capacity(run1.len() + run2.len() + run3.len());
614 out.extend(run1.into_iter().map(|(e, _)| e));
615 out.extend(run2.into_iter().map(|(e, _)| e));
616 out.extend(run3.into_iter().map(|(e, _)| e));
617 let mut seen = HashSet::with_capacity(out.len());
618 out.retain(|e| seen.insert(*e));
619
620 if let Some(q) = world.destination_queue_mut(car_eid) {
621 q.replace(out);
622 }
623}