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