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, TransportMode};
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}
67
68impl DestinationDispatch {
69 /// Create a new `DestinationDispatch` with defaults.
70 #[must_use]
71 pub const fn new() -> Self {
72 Self { stop_penalty: None }
73 }
74
75 /// Override the fresh-stop penalty (ticks per new stop added to a
76 /// car's committed route when it picks this rider up).
77 #[must_use]
78 pub const fn with_stop_penalty(mut self, penalty: f64) -> Self {
79 self.stop_penalty = Some(penalty);
80 self
81 }
82}
83
84impl Default for DestinationDispatch {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90impl DispatchStrategy for DestinationDispatch {
91 fn pre_dispatch(
92 &mut self,
93 group: &ElevatorGroup,
94 manifest: &DispatchManifest,
95 world: &mut World,
96 ) {
97 // DCS requires the group to be in `HallCallMode::Destination` — that
98 // mode is what makes the kiosk-style "rider announces destination
99 // at press time" assumption hold. In Classic collective-control
100 // mode destinations aren't known until riders board, so running
101 // DCS there would commit assignments based on information a real
102 // controller wouldn't have. Early-return makes DCS a no-op for
103 // misconfigured groups; pair it with the right mode to activate.
104 if group.hall_call_mode() != super::HallCallMode::Destination {
105 return;
106 }
107
108 // Candidate cars in this group that are operable for dispatch.
109 let candidate_cars: Vec<EntityId> = group
110 .elevator_entities()
111 .iter()
112 .copied()
113 .filter(|eid| !world.is_disabled(*eid))
114 .filter(|eid| {
115 !world
116 .service_mode(*eid)
117 .is_some_and(|m| m.is_dispatch_excluded())
118 })
119 .filter(|eid| world.elevator(*eid).is_some())
120 .collect();
121
122 if candidate_cars.is_empty() {
123 return;
124 }
125
126 // Collect unassigned waiting riders in this group. A sticky
127 // assignment whose target car is dead or disabled is treated as
128 // void — re-assign rather than strand. (Lifecycle hooks in
129 // `disable`/`remove_elevator` normally clear these; this is the
130 // defense layer if cleanup is ever missed.)
131 let mut stale_assignments: Vec<EntityId> = Vec::new();
132 let mut pending: Vec<(EntityId, EntityId, EntityId, f64)> = Vec::new();
133 for (_, riders) in manifest.iter_waiting_stops() {
134 for info in riders {
135 if let Some(AssignedCar(c)) = world.ext::<AssignedCar>(info.id) {
136 if world.elevator(c).is_some() && !world.is_disabled(c) {
137 continue; // sticky and live
138 }
139 stale_assignments.push(info.id);
140 }
141 let Some(dest) = info.destination else {
142 continue;
143 };
144 let Some(route) = world.route(info.id) else {
145 continue;
146 };
147 let Some(leg) = route.current() else {
148 continue;
149 };
150 let group_ok = match leg.via {
151 TransportMode::Group(g) => g == group.id(),
152 TransportMode::Line(l) => group.lines().iter().any(|li| li.entity() == l),
153 TransportMode::Walk => false,
154 };
155 if !group_ok {
156 continue;
157 }
158 pending.push((info.id, leg.from, dest, info.weight.value()));
159 }
160 }
161 pending.sort_by_key(|(rid, ..)| *rid);
162 // Drop stale extensions so subsequent ticks see them as unassigned.
163 for rid in stale_assignments {
164 world.remove_ext::<AssignedCar>(rid);
165 }
166
167 // Pre-compute committed-load per car (riders aboard + already-
168 // assigned waiting riders not yet boarded). Used by cost function
169 // to discourage piling more riders onto an already-full car.
170 let mut committed_load: std::collections::BTreeMap<EntityId, f64> =
171 std::collections::BTreeMap::new();
172 for (rid, rider) in world.iter_riders() {
173 use crate::components::RiderPhase;
174 // Count riders whose weight is "committed" to a specific car:
175 // actively aboard (Boarding/Riding) or still-Waiting with a
176 // sticky assignment. Terminal phases (Exiting, Arrived,
177 // Abandoned, Resident, Walking) must not contribute — they no
178 // longer need elevator service, and stale `AssignedCar`
179 // extensions on them would inflate the former car's committed
180 // load until cleared.
181 let car = match rider.phase() {
182 RiderPhase::Riding(c) | RiderPhase::Boarding(c) => Some(c),
183 RiderPhase::Waiting => world.ext::<AssignedCar>(rid).map(|AssignedCar(c)| c),
184 _ => None,
185 };
186 if let Some(c) = car {
187 *committed_load.entry(c).or_insert(0.0) += rider.weight.value();
188 }
189 }
190
191 for (rid, origin, dest, weight) in pending {
192 let best = candidate_cars
193 .iter()
194 .filter_map(|&eid| {
195 let car = world.elevator(eid)?;
196 if car.restricted_stops().contains(&dest)
197 || car.restricted_stops().contains(&origin)
198 {
199 return None;
200 }
201 if car.weight_capacity().value() > 0.0 && weight > car.weight_capacity().value()
202 {
203 return None;
204 }
205 let com = committed_load.get(&eid).copied().unwrap_or(0.0);
206 let cost = self.compute_cost(eid, origin, dest, world, com);
207 if cost.is_finite() {
208 Some((eid, cost))
209 } else {
210 None
211 }
212 })
213 .min_by(|a, b| a.1.total_cmp(&b.1))
214 .map(|(eid, _)| eid);
215
216 let Some(car_eid) = best else {
217 continue;
218 };
219 world.insert_ext(rid, AssignedCar(car_eid), ASSIGNED_CAR_KEY);
220 *committed_load.entry(car_eid).or_insert(0.0) += weight;
221 }
222
223 // Rebuild each candidate car's destination queue from the current
224 // set of sticky commitments, arranged in direction-aware two-run
225 // monotone order. This is the source of truth per tick and avoids
226 // incremental-insertion drift (duplicates, orphaned entries).
227 for &car_eid in &candidate_cars {
228 rebuild_car_queue(world, car_eid);
229 }
230 }
231
232 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
233 // The queue is the source of truth — route each car strictly to
234 // its own queue front. Every other stop is unavailable for this
235 // car, so the Hungarian assignment reduces to the identity match
236 // between each car and the stop it has already committed to.
237 //
238 // The `pair_can_do_work` gate guards against the same full-car
239 // self-assign stall the other built-ins close: a sticky DCS
240 // assignment whose car has filled up with earlier riders and
241 // whose queue front is still the *pickup* for an un-boarded
242 // rider would otherwise rank 0.0, win the Hungarian every tick,
243 // and cycle doors forever.
244 let front = ctx
245 .world
246 .destination_queue(ctx.car)
247 .and_then(DestinationQueue::front)?;
248 if front == ctx.stop && pair_can_do_work(ctx) {
249 Some(0.0)
250 } else {
251 None
252 }
253 }
254}
255
256impl DestinationDispatch {
257 /// Compute the assignment cost of sending car `eid` to pick up a rider
258 /// whose route is `origin → dest`.
259 fn compute_cost(
260 &self,
261 eid: EntityId,
262 origin: EntityId,
263 dest: EntityId,
264 world: &World,
265 committed_load: f64,
266 ) -> f64 {
267 let Some(car) = world.elevator(eid) else {
268 return f64::INFINITY;
269 };
270 if car.max_speed().value() <= 0.0 {
271 return f64::INFINITY;
272 }
273
274 let Some(car_pos) = world.position(eid).map(|p| p.value) else {
275 return f64::INFINITY;
276 };
277 let Some(origin_pos) = world.stop_position(origin) else {
278 return f64::INFINITY;
279 };
280 let Some(dest_pos) = world.stop_position(dest) else {
281 return f64::INFINITY;
282 };
283
284 let door_overhead = f64::from(car.door_transition_ticks() * 2 + car.door_open_ticks());
285 let penalty = self.stop_penalty.unwrap_or_else(|| door_overhead.max(1.0));
286
287 // Pickup time: direct distance + per-stop door overhead for each
288 // committed stop that lies between the car and the origin.
289 let pickup_dist = (car_pos - origin_pos).abs();
290 let pickup_travel = pickup_dist / car.max_speed().value();
291 let intervening_committed = world.destination_queue(eid).map_or(0usize, |q| {
292 let (lo, hi) = if car_pos < origin_pos {
293 (car_pos, origin_pos)
294 } else {
295 (origin_pos, car_pos)
296 };
297 q.queue()
298 .iter()
299 .filter_map(|s| world.stop_position(*s))
300 .filter(|p| *p > lo + 1e-9 && *p < hi - 1e-9)
301 .count()
302 });
303 let pickup_time = (intervening_committed as f64).mul_add(door_overhead, pickup_travel);
304
305 // Ride time: origin → dest travel + door overhead at origin pickup.
306 let ride_dist = (origin_pos - dest_pos).abs();
307 let ride_time = ride_dist / car.max_speed().value() + door_overhead;
308
309 // Fresh stops added: 0, 1, or 2 depending on whether origin/dest
310 // are already queued for this car.
311 let existing: Vec<EntityId> = world
312 .destination_queue(eid)
313 .map_or_else(Vec::new, |q| q.queue().to_vec());
314 let mut new_stops = 0f64;
315 if !existing.contains(&origin) {
316 new_stops += 1.0;
317 }
318 if !existing.contains(&dest) && dest != origin {
319 new_stops += 1.0;
320 }
321
322 // Idle bias: empty cars get a small bonus so the load spreads.
323 let idle_bonus = if car.phase() == ElevatorPhase::Idle && car.riders().is_empty() {
324 -0.1 * pickup_travel
325 } else {
326 0.0
327 };
328
329 // Load bias: include both aboard and already-assigned-but-waiting
330 // riders so dispatch spreads load even before any boarding happens.
331 let load_penalty = if car.weight_capacity().value() > 0.0 {
332 let effective = car.current_load().value().max(committed_load);
333 let ratio = (effective / car.weight_capacity().value()).min(2.0);
334 ratio * door_overhead * 4.0
335 } else {
336 0.0
337 };
338
339 pickup_time + ride_time + penalty * new_stops + idle_bonus + load_penalty
340 }
341}
342
343/// Drop every sticky [`AssignedCar`] assignment that points at `car_eid`.
344///
345/// Called by `Simulation::disable` and `Simulation::remove_elevator` when an
346/// elevator leaves service so DCS-routed riders are not stranded behind a
347/// dead reference. Assignments are sticky by design — if no one clears them,
348/// no other car will pick the rider up — so the lifecycle layer is responsible
349/// for invoking this helper at car-loss boundaries.
350pub fn clear_assignments_to(world: &mut crate::world::World, car_eid: EntityId) {
351 let stale: Vec<EntityId> = world
352 .iter_riders()
353 .filter_map(|(rid, _)| match world.ext::<AssignedCar>(rid) {
354 Some(AssignedCar(c)) if c == car_eid => Some(rid),
355 _ => None,
356 })
357 .collect();
358 for rid in stale {
359 world.remove_ext::<AssignedCar>(rid);
360 }
361}
362
363/// Rebuild `car_eid`'s destination queue from all live sticky commitments.
364///
365/// Scans all riders assigned to this car and collects the set of stops it
366/// must visit:
367/// - waiting riders contribute both their origin and destination,
368/// - riding/boarding riders contribute just their destination (origin
369/// already visited).
370///
371/// The stops are then arranged into a two-run monotone sequence: the
372/// current sweep (in the car's current direction) followed by the reverse
373/// sweep. A third run is appended when a rider's trip reverses the sweep
374/// twice (origin behind, dest ahead of origin in the original sweep).
375#[allow(clippy::too_many_lines)]
376fn rebuild_car_queue(world: &mut crate::world::World, car_eid: EntityId) {
377 use crate::components::RiderPhase;
378
379 // Local type for gathered (origin?, dest) trips.
380 struct Trip {
381 origin: Option<EntityId>,
382 dest: EntityId,
383 }
384
385 let Some(car) = world.elevator(car_eid) else {
386 return;
387 };
388 let car_pos = world.position(car_eid).map_or(0.0, |p| p.value);
389 let sweep_up = match car.direction() {
390 Direction::Up | Direction::Either => true,
391 Direction::Down => false,
392 };
393
394 // Skip inserting a stop the car is currently parked at and loading.
395 let at_stop_loading: Option<EntityId> = {
396 let stopped_here = !matches!(
397 car.phase(),
398 ElevatorPhase::MovingToStop(_) | ElevatorPhase::Repositioning(_)
399 );
400 if stopped_here {
401 world.find_stop_at_position(car_pos)
402 } else {
403 None
404 }
405 };
406
407 // Gather (origin?, dest) pairs from all sticky-assigned riders for this car.
408 let mut trips: Vec<Trip> = Vec::new();
409 for (rid, rider) in world.iter_riders() {
410 let Some(AssignedCar(assigned)) = world.ext::<AssignedCar>(rid) else {
411 continue;
412 };
413 if assigned != car_eid {
414 continue;
415 }
416 let Some(dest) = world
417 .route(rid)
418 .and_then(crate::components::Route::current_destination)
419 else {
420 continue;
421 };
422 match rider.phase() {
423 RiderPhase::Waiting => {
424 let origin = world
425 .route(rid)
426 .and_then(|r| r.current().map(|leg| leg.from));
427 // Strip origin if car is parked at it right now.
428 let origin = origin.filter(|o| Some(*o) != at_stop_loading);
429 trips.push(Trip { origin, dest });
430 }
431 RiderPhase::Boarding(_) | RiderPhase::Riding(_) => {
432 trips.push(Trip { origin: None, dest });
433 }
434 _ => {}
435 }
436 }
437
438 if trips.is_empty() {
439 if let Some(q) = world.destination_queue_mut(car_eid) {
440 q.clear();
441 }
442 return;
443 }
444
445 // Bucket each stop into up to three runs based on the car's direction:
446 // run1 = current sweep (same direction as car)
447 // run2 = reverse sweep
448 // run3 = second sweep in the original direction (for trips whose
449 // origin is behind the sweep but dest is further in it)
450 let mut run1: Vec<(EntityId, f64)> = Vec::new();
451 let mut run2: Vec<(EntityId, f64)> = Vec::new();
452 let mut run3: Vec<(EntityId, f64)> = Vec::new();
453
454 let in_run1 = |sp: f64| -> bool {
455 if sweep_up {
456 sp >= car_pos - 1e-9
457 } else {
458 sp <= car_pos + 1e-9
459 }
460 };
461
462 let push_unique = |v: &mut Vec<(EntityId, f64)>, s: EntityId, p: f64| {
463 if !v.iter().any(|(e, _)| *e == s) {
464 v.push((s, p));
465 }
466 };
467
468 for trip in &trips {
469 let dp = world.stop_position(trip.dest).unwrap_or(car_pos);
470 if let Some(o) = trip.origin {
471 let op = world.stop_position(o).unwrap_or(car_pos);
472 let o_in_run1 = in_run1(op);
473 let d_in_run1 = in_run1(dp);
474 if o_in_run1 {
475 push_unique(&mut run1, o, op);
476 if d_in_run1 {
477 // Both in run1: dest must be further in sweep than origin.
478 let d_fits = if sweep_up {
479 dp >= op - 1e-9
480 } else {
481 dp <= op + 1e-9
482 };
483 if d_fits {
484 push_unique(&mut run1, trip.dest, dp);
485 } else {
486 // Dest is behind origin in sweep: needs reverse run.
487 push_unique(&mut run2, trip.dest, dp);
488 }
489 } else {
490 push_unique(&mut run2, trip.dest, dp);
491 }
492 } else {
493 // Origin is behind sweep: both go in reverse/second run.
494 push_unique(&mut run2, o, op);
495 if d_in_run1 {
496 // Origin behind, dest ahead: need a third sweep.
497 push_unique(&mut run3, trip.dest, dp);
498 } else {
499 // Both behind sweep. Within reverse run, order dest
500 // after origin (dest further into reverse direction).
501 let d_further = if sweep_up {
502 dp <= op + 1e-9
503 } else {
504 dp >= op - 1e-9
505 };
506 if d_further {
507 push_unique(&mut run2, trip.dest, dp);
508 } else {
509 push_unique(&mut run3, trip.dest, dp);
510 }
511 }
512 }
513 } else {
514 // No origin: just drop off. Place dest in whichever run contains it.
515 if in_run1(dp) {
516 push_unique(&mut run1, trip.dest, dp);
517 } else {
518 push_unique(&mut run2, trip.dest, dp);
519 }
520 }
521 }
522
523 // Sort each run monotonically.
524 if sweep_up {
525 run1.sort_by(|a, b| a.1.total_cmp(&b.1));
526 run2.sort_by(|a, b| b.1.total_cmp(&a.1));
527 run3.sort_by(|a, b| a.1.total_cmp(&b.1));
528 } else {
529 run1.sort_by(|a, b| b.1.total_cmp(&a.1));
530 run2.sort_by(|a, b| a.1.total_cmp(&b.1));
531 run3.sort_by(|a, b| b.1.total_cmp(&a.1));
532 }
533
534 let mut out: Vec<EntityId> = Vec::with_capacity(run1.len() + run2.len() + run3.len());
535 out.extend(run1.into_iter().map(|(e, _)| e));
536 out.extend(run2.into_iter().map(|(e, _)| e));
537 out.extend(run3.into_iter().map(|(e, _)| e));
538 let mut seen = HashSet::with_capacity(out.len());
539 out.retain(|e| seen.insert(*e));
540
541 if let Some(q) = world.destination_queue_mut(car_eid) {
542 q.replace(out);
543 }
544}