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