Skip to main content

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};
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.waiting_at_stop.values() {
127            for info in riders {
128                if world.get_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));
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.get_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;
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() > 0.0 && weight > car.weight_capacity() {
187                        return None;
188                    }
189                    let com = committed_load.get(&eid).copied().unwrap_or(0.0);
190                    let cost = self.compute_cost(eid, origin, dest, world, com);
191                    if cost.is_finite() {
192                        Some((eid, cost))
193                    } else {
194                        None
195                    }
196                })
197                .min_by(|a, b| a.1.total_cmp(&b.1))
198                .map(|(eid, _)| eid);
199
200            let Some(car_eid) = best else {
201                continue;
202            };
203            world.insert_ext(rid, AssignedCar(car_eid), ASSIGNED_CAR_KEY);
204            *committed_load.entry(car_eid).or_insert(0.0) += weight;
205        }
206
207        // Rebuild each candidate car's destination queue from the current
208        // set of sticky commitments, arranged in direction-aware two-run
209        // monotone order. This is the source of truth per tick and avoids
210        // incremental-insertion drift (duplicates, orphaned entries).
211        for &car_eid in &candidate_cars {
212            rebuild_car_queue(world, car_eid);
213        }
214    }
215
216    fn rank(
217        &mut self,
218        car: EntityId,
219        _car_position: f64,
220        stop: EntityId,
221        _stop_position: f64,
222        _group: &ElevatorGroup,
223        _manifest: &DispatchManifest,
224        world: &World,
225    ) -> Option<f64> {
226        // The queue is the source of truth — route each car strictly to
227        // its own queue front. Every other stop is unavailable for this
228        // car, so the Hungarian assignment reduces to the identity match
229        // between each car and the stop it has already committed to.
230        let front = world
231            .destination_queue(car)
232            .and_then(DestinationQueue::front)?;
233        if front == stop { Some(0.0) } else { None }
234    }
235}
236
237impl DestinationDispatch {
238    /// Compute the assignment cost of sending car `eid` to pick up a rider
239    /// whose route is `origin → dest`.
240    fn compute_cost(
241        &self,
242        eid: EntityId,
243        origin: EntityId,
244        dest: EntityId,
245        world: &World,
246        committed_load: f64,
247    ) -> f64 {
248        let Some(car) = world.elevator(eid) else {
249            return f64::INFINITY;
250        };
251        if car.max_speed() <= 0.0 {
252            return f64::INFINITY;
253        }
254
255        let Some(car_pos) = world.position(eid).map(|p| p.value) else {
256            return f64::INFINITY;
257        };
258        let Some(origin_pos) = world.stop_position(origin) else {
259            return f64::INFINITY;
260        };
261        let Some(dest_pos) = world.stop_position(dest) else {
262            return f64::INFINITY;
263        };
264
265        let door_overhead = f64::from(car.door_transition_ticks() * 2 + car.door_open_ticks());
266        let penalty = self.stop_penalty.unwrap_or_else(|| door_overhead.max(1.0));
267
268        // Pickup time: direct distance + per-stop door overhead for each
269        // committed stop that lies between the car and the origin.
270        let pickup_dist = (car_pos - origin_pos).abs();
271        let pickup_travel = pickup_dist / car.max_speed();
272        let intervening_committed = world.destination_queue(eid).map_or(0usize, |q| {
273            let (lo, hi) = if car_pos < origin_pos {
274                (car_pos, origin_pos)
275            } else {
276                (origin_pos, car_pos)
277            };
278            q.queue()
279                .iter()
280                .filter_map(|s| world.stop_position(*s))
281                .filter(|p| *p > lo + 1e-9 && *p < hi - 1e-9)
282                .count()
283        });
284        let pickup_time = (intervening_committed as f64).mul_add(door_overhead, pickup_travel);
285
286        // Ride time: origin → dest travel + door overhead at origin pickup.
287        let ride_dist = (origin_pos - dest_pos).abs();
288        let ride_time = ride_dist / car.max_speed() + door_overhead;
289
290        // Fresh stops added: 0, 1, or 2 depending on whether origin/dest
291        // are already queued for this car.
292        let existing: Vec<EntityId> = world
293            .destination_queue(eid)
294            .map_or_else(Vec::new, |q| q.queue().to_vec());
295        let mut new_stops = 0f64;
296        if !existing.contains(&origin) {
297            new_stops += 1.0;
298        }
299        if !existing.contains(&dest) && dest != origin {
300            new_stops += 1.0;
301        }
302
303        // Idle bias: empty cars get a small bonus so the load spreads.
304        let idle_bonus = if car.phase() == ElevatorPhase::Idle && car.riders().is_empty() {
305            -0.1 * pickup_travel
306        } else {
307            0.0
308        };
309
310        // Load bias: include both aboard and already-assigned-but-waiting
311        // riders so dispatch spreads load even before any boarding happens.
312        let load_penalty = if car.weight_capacity() > 0.0 {
313            let effective = car.current_load().max(committed_load);
314            let ratio = (effective / car.weight_capacity()).min(2.0);
315            ratio * door_overhead * 4.0
316        } else {
317            0.0
318        };
319
320        pickup_time + ride_time + penalty * new_stops + idle_bonus + load_penalty
321    }
322}
323
324/// Rebuild `car_eid`'s destination queue from all live sticky commitments.
325///
326/// Scans all riders assigned to this car and collects the set of stops it
327/// must visit:
328///   - waiting riders contribute both their origin and destination,
329///   - riding/boarding riders contribute just their destination (origin
330///     already visited).
331///
332/// The stops are then arranged into a two-run monotone sequence: the
333/// current sweep (in the car's current direction) followed by the reverse
334/// sweep. A third run is appended when a rider's trip reverses the sweep
335/// twice (origin behind, dest ahead of origin in the original sweep).
336#[allow(clippy::too_many_lines)]
337fn rebuild_car_queue(world: &mut crate::world::World, car_eid: EntityId) {
338    use crate::components::RiderPhase;
339
340    // Local type for gathered (origin?, dest) trips.
341    struct Trip {
342        origin: Option<EntityId>,
343        dest: EntityId,
344    }
345
346    let Some(car) = world.elevator(car_eid) else {
347        return;
348    };
349    let car_pos = world.position(car_eid).map_or(0.0, |p| p.value);
350    let sweep_up = match car.direction() {
351        Direction::Up | Direction::Either => true,
352        Direction::Down => false,
353    };
354
355    // Skip inserting a stop the car is currently parked at and loading.
356    let at_stop_loading: Option<EntityId> = {
357        let stopped_here = !matches!(
358            car.phase(),
359            ElevatorPhase::MovingToStop(_) | ElevatorPhase::Repositioning(_)
360        );
361        if stopped_here {
362            world.find_stop_at_position(car_pos)
363        } else {
364            None
365        }
366    };
367
368    // Gather (origin?, dest) pairs from all sticky-assigned riders for this car.
369    let mut trips: Vec<Trip> = Vec::new();
370    for (rid, rider) in world.iter_riders() {
371        let Some(AssignedCar(assigned)) = world.get_ext::<AssignedCar>(rid) else {
372            continue;
373        };
374        if assigned != car_eid {
375            continue;
376        }
377        let Some(dest) = world
378            .route(rid)
379            .and_then(crate::components::Route::current_destination)
380        else {
381            continue;
382        };
383        match rider.phase() {
384            RiderPhase::Waiting => {
385                let origin = world
386                    .route(rid)
387                    .and_then(|r| r.current().map(|leg| leg.from));
388                // Strip origin if car is parked at it right now.
389                let origin = origin.filter(|o| Some(*o) != at_stop_loading);
390                trips.push(Trip { origin, dest });
391            }
392            RiderPhase::Boarding(_) | RiderPhase::Riding(_) => {
393                trips.push(Trip { origin: None, dest });
394            }
395            _ => {}
396        }
397    }
398
399    if trips.is_empty() {
400        if let Some(q) = world.destination_queue_mut(car_eid) {
401            q.clear();
402        }
403        return;
404    }
405
406    // Bucket each stop into up to three runs based on the car's direction:
407    //   run1 = current sweep (same direction as car)
408    //   run2 = reverse sweep
409    //   run3 = second sweep in the original direction (for trips whose
410    //          origin is behind the sweep but dest is further in it)
411    let mut run1: Vec<(EntityId, f64)> = Vec::new();
412    let mut run2: Vec<(EntityId, f64)> = Vec::new();
413    let mut run3: Vec<(EntityId, f64)> = Vec::new();
414
415    let in_run1 = |sp: f64| -> bool {
416        if sweep_up {
417            sp >= car_pos - 1e-9
418        } else {
419            sp <= car_pos + 1e-9
420        }
421    };
422
423    let push_unique = |v: &mut Vec<(EntityId, f64)>, s: EntityId, p: f64| {
424        if !v.iter().any(|(e, _)| *e == s) {
425            v.push((s, p));
426        }
427    };
428
429    for trip in &trips {
430        let dp = world.stop_position(trip.dest).unwrap_or(car_pos);
431        if let Some(o) = trip.origin {
432            let op = world.stop_position(o).unwrap_or(car_pos);
433            let o_in_run1 = in_run1(op);
434            let d_in_run1 = in_run1(dp);
435            if o_in_run1 {
436                push_unique(&mut run1, o, op);
437                if d_in_run1 {
438                    // Both in run1: dest must be further in sweep than origin.
439                    let d_fits = if sweep_up {
440                        dp >= op - 1e-9
441                    } else {
442                        dp <= op + 1e-9
443                    };
444                    if d_fits {
445                        push_unique(&mut run1, trip.dest, dp);
446                    } else {
447                        // Dest is behind origin in sweep: needs reverse run.
448                        push_unique(&mut run2, trip.dest, dp);
449                    }
450                } else {
451                    push_unique(&mut run2, trip.dest, dp);
452                }
453            } else {
454                // Origin is behind sweep: both go in reverse/second run.
455                push_unique(&mut run2, o, op);
456                if d_in_run1 {
457                    // Origin behind, dest ahead: need a third sweep.
458                    push_unique(&mut run3, trip.dest, dp);
459                } else {
460                    // Both behind sweep. Within reverse run, order dest
461                    // after origin (dest further into reverse direction).
462                    let d_further = if sweep_up {
463                        dp <= op + 1e-9
464                    } else {
465                        dp >= op - 1e-9
466                    };
467                    if d_further {
468                        push_unique(&mut run2, trip.dest, dp);
469                    } else {
470                        push_unique(&mut run3, trip.dest, dp);
471                    }
472                }
473            }
474        } else {
475            // No origin: just drop off. Place dest in whichever run contains it.
476            if in_run1(dp) {
477                push_unique(&mut run1, trip.dest, dp);
478            } else {
479                push_unique(&mut run2, trip.dest, dp);
480            }
481        }
482    }
483
484    // Sort each run monotonically.
485    if sweep_up {
486        run1.sort_by(|a, b| a.1.total_cmp(&b.1));
487        run2.sort_by(|a, b| b.1.total_cmp(&a.1));
488        run3.sort_by(|a, b| a.1.total_cmp(&b.1));
489    } else {
490        run1.sort_by(|a, b| b.1.total_cmp(&a.1));
491        run2.sort_by(|a, b| a.1.total_cmp(&b.1));
492        run3.sort_by(|a, b| b.1.total_cmp(&a.1));
493    }
494
495    let mut out: Vec<EntityId> = Vec::with_capacity(run1.len() + run2.len() + run3.len());
496    out.extend(run1.into_iter().map(|(e, _)| e));
497    out.extend(run2.into_iter().map(|(e, _)| e));
498    out.extend(run3.into_iter().map(|(e, _)| e));
499    out.dedup();
500
501    if let Some(q) = world.destination_queue_mut(car_eid) {
502        q.replace(out);
503    }
504}