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 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};
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        let front = ctx
238            .world
239            .destination_queue(ctx.car)
240            .and_then(DestinationQueue::front)?;
241        if front == ctx.stop { Some(0.0) } else { None }
242    }
243}
244
245impl DestinationDispatch {
246    /// Compute the assignment cost of sending car `eid` to pick up a rider
247    /// whose route is `origin → dest`.
248    fn compute_cost(
249        &self,
250        eid: EntityId,
251        origin: EntityId,
252        dest: EntityId,
253        world: &World,
254        committed_load: f64,
255    ) -> f64 {
256        let Some(car) = world.elevator(eid) else {
257            return f64::INFINITY;
258        };
259        if car.max_speed().value() <= 0.0 {
260            return f64::INFINITY;
261        }
262
263        let Some(car_pos) = world.position(eid).map(|p| p.value) else {
264            return f64::INFINITY;
265        };
266        let Some(origin_pos) = world.stop_position(origin) else {
267            return f64::INFINITY;
268        };
269        let Some(dest_pos) = world.stop_position(dest) else {
270            return f64::INFINITY;
271        };
272
273        let door_overhead = f64::from(car.door_transition_ticks() * 2 + car.door_open_ticks());
274        let penalty = self.stop_penalty.unwrap_or_else(|| door_overhead.max(1.0));
275
276        // Pickup time: direct distance + per-stop door overhead for each
277        // committed stop that lies between the car and the origin.
278        let pickup_dist = (car_pos - origin_pos).abs();
279        let pickup_travel = pickup_dist / car.max_speed().value();
280        let intervening_committed = world.destination_queue(eid).map_or(0usize, |q| {
281            let (lo, hi) = if car_pos < origin_pos {
282                (car_pos, origin_pos)
283            } else {
284                (origin_pos, car_pos)
285            };
286            q.queue()
287                .iter()
288                .filter_map(|s| world.stop_position(*s))
289                .filter(|p| *p > lo + 1e-9 && *p < hi - 1e-9)
290                .count()
291        });
292        let pickup_time = (intervening_committed as f64).mul_add(door_overhead, pickup_travel);
293
294        // Ride time: origin → dest travel + door overhead at origin pickup.
295        let ride_dist = (origin_pos - dest_pos).abs();
296        let ride_time = ride_dist / car.max_speed().value() + door_overhead;
297
298        // Fresh stops added: 0, 1, or 2 depending on whether origin/dest
299        // are already queued for this car.
300        let existing: Vec<EntityId> = world
301            .destination_queue(eid)
302            .map_or_else(Vec::new, |q| q.queue().to_vec());
303        let mut new_stops = 0f64;
304        if !existing.contains(&origin) {
305            new_stops += 1.0;
306        }
307        if !existing.contains(&dest) && dest != origin {
308            new_stops += 1.0;
309        }
310
311        // Idle bias: empty cars get a small bonus so the load spreads.
312        let idle_bonus = if car.phase() == ElevatorPhase::Idle && car.riders().is_empty() {
313            -0.1 * pickup_travel
314        } else {
315            0.0
316        };
317
318        // Load bias: include both aboard and already-assigned-but-waiting
319        // riders so dispatch spreads load even before any boarding happens.
320        let load_penalty = if car.weight_capacity().value() > 0.0 {
321            let effective = car.current_load().value().max(committed_load);
322            let ratio = (effective / car.weight_capacity().value()).min(2.0);
323            ratio * door_overhead * 4.0
324        } else {
325            0.0
326        };
327
328        pickup_time + ride_time + penalty * new_stops + idle_bonus + load_penalty
329    }
330}
331
332/// Drop every sticky [`AssignedCar`] assignment that points at `car_eid`.
333///
334/// Called by `Simulation::disable` and `Simulation::remove_elevator` when an
335/// elevator leaves service so DCS-routed riders are not stranded behind a
336/// dead reference. Assignments are sticky by design — if no one clears them,
337/// no other car will pick the rider up — so the lifecycle layer is responsible
338/// for invoking this helper at car-loss boundaries.
339pub fn clear_assignments_to(world: &mut crate::world::World, car_eid: EntityId) {
340    let stale: Vec<EntityId> = world
341        .iter_riders()
342        .filter_map(|(rid, _)| match world.ext::<AssignedCar>(rid) {
343            Some(AssignedCar(c)) if c == car_eid => Some(rid),
344            _ => None,
345        })
346        .collect();
347    for rid in stale {
348        world.remove_ext::<AssignedCar>(rid);
349    }
350}
351
352/// Rebuild `car_eid`'s destination queue from all live sticky commitments.
353///
354/// Scans all riders assigned to this car and collects the set of stops it
355/// must visit:
356///   - waiting riders contribute both their origin and destination,
357///   - riding/boarding riders contribute just their destination (origin
358///     already visited).
359///
360/// The stops are then arranged into a two-run monotone sequence: the
361/// current sweep (in the car's current direction) followed by the reverse
362/// sweep. A third run is appended when a rider's trip reverses the sweep
363/// twice (origin behind, dest ahead of origin in the original sweep).
364#[allow(clippy::too_many_lines)]
365fn rebuild_car_queue(world: &mut crate::world::World, car_eid: EntityId) {
366    use crate::components::RiderPhase;
367
368    // Local type for gathered (origin?, dest) trips.
369    struct Trip {
370        origin: Option<EntityId>,
371        dest: EntityId,
372    }
373
374    let Some(car) = world.elevator(car_eid) else {
375        return;
376    };
377    let car_pos = world.position(car_eid).map_or(0.0, |p| p.value);
378    let sweep_up = match car.direction() {
379        Direction::Up | Direction::Either => true,
380        Direction::Down => false,
381    };
382
383    // Skip inserting a stop the car is currently parked at and loading.
384    let at_stop_loading: Option<EntityId> = {
385        let stopped_here = !matches!(
386            car.phase(),
387            ElevatorPhase::MovingToStop(_) | ElevatorPhase::Repositioning(_)
388        );
389        if stopped_here {
390            world.find_stop_at_position(car_pos)
391        } else {
392            None
393        }
394    };
395
396    // Gather (origin?, dest) pairs from all sticky-assigned riders for this car.
397    let mut trips: Vec<Trip> = Vec::new();
398    for (rid, rider) in world.iter_riders() {
399        let Some(AssignedCar(assigned)) = world.ext::<AssignedCar>(rid) else {
400            continue;
401        };
402        if assigned != car_eid {
403            continue;
404        }
405        let Some(dest) = world
406            .route(rid)
407            .and_then(crate::components::Route::current_destination)
408        else {
409            continue;
410        };
411        match rider.phase() {
412            RiderPhase::Waiting => {
413                let origin = world
414                    .route(rid)
415                    .and_then(|r| r.current().map(|leg| leg.from));
416                // Strip origin if car is parked at it right now.
417                let origin = origin.filter(|o| Some(*o) != at_stop_loading);
418                trips.push(Trip { origin, dest });
419            }
420            RiderPhase::Boarding(_) | RiderPhase::Riding(_) => {
421                trips.push(Trip { origin: None, dest });
422            }
423            _ => {}
424        }
425    }
426
427    if trips.is_empty() {
428        if let Some(q) = world.destination_queue_mut(car_eid) {
429            q.clear();
430        }
431        return;
432    }
433
434    // Bucket each stop into up to three runs based on the car's direction:
435    //   run1 = current sweep (same direction as car)
436    //   run2 = reverse sweep
437    //   run3 = second sweep in the original direction (for trips whose
438    //          origin is behind the sweep but dest is further in it)
439    let mut run1: Vec<(EntityId, f64)> = Vec::new();
440    let mut run2: Vec<(EntityId, f64)> = Vec::new();
441    let mut run3: Vec<(EntityId, f64)> = Vec::new();
442
443    let in_run1 = |sp: f64| -> bool {
444        if sweep_up {
445            sp >= car_pos - 1e-9
446        } else {
447            sp <= car_pos + 1e-9
448        }
449    };
450
451    let push_unique = |v: &mut Vec<(EntityId, f64)>, s: EntityId, p: f64| {
452        if !v.iter().any(|(e, _)| *e == s) {
453            v.push((s, p));
454        }
455    };
456
457    for trip in &trips {
458        let dp = world.stop_position(trip.dest).unwrap_or(car_pos);
459        if let Some(o) = trip.origin {
460            let op = world.stop_position(o).unwrap_or(car_pos);
461            let o_in_run1 = in_run1(op);
462            let d_in_run1 = in_run1(dp);
463            if o_in_run1 {
464                push_unique(&mut run1, o, op);
465                if d_in_run1 {
466                    // Both in run1: dest must be further in sweep than origin.
467                    let d_fits = if sweep_up {
468                        dp >= op - 1e-9
469                    } else {
470                        dp <= op + 1e-9
471                    };
472                    if d_fits {
473                        push_unique(&mut run1, trip.dest, dp);
474                    } else {
475                        // Dest is behind origin in sweep: needs reverse run.
476                        push_unique(&mut run2, trip.dest, dp);
477                    }
478                } else {
479                    push_unique(&mut run2, trip.dest, dp);
480                }
481            } else {
482                // Origin is behind sweep: both go in reverse/second run.
483                push_unique(&mut run2, o, op);
484                if d_in_run1 {
485                    // Origin behind, dest ahead: need a third sweep.
486                    push_unique(&mut run3, trip.dest, dp);
487                } else {
488                    // Both behind sweep. Within reverse run, order dest
489                    // after origin (dest further into reverse direction).
490                    let d_further = if sweep_up {
491                        dp <= op + 1e-9
492                    } else {
493                        dp >= op - 1e-9
494                    };
495                    if d_further {
496                        push_unique(&mut run2, trip.dest, dp);
497                    } else {
498                        push_unique(&mut run3, trip.dest, dp);
499                    }
500                }
501            }
502        } else {
503            // No origin: just drop off. Place dest in whichever run contains it.
504            if in_run1(dp) {
505                push_unique(&mut run1, trip.dest, dp);
506            } else {
507                push_unique(&mut run2, trip.dest, dp);
508            }
509        }
510    }
511
512    // Sort each run monotonically.
513    if sweep_up {
514        run1.sort_by(|a, b| a.1.total_cmp(&b.1));
515        run2.sort_by(|a, b| b.1.total_cmp(&a.1));
516        run3.sort_by(|a, b| a.1.total_cmp(&b.1));
517    } else {
518        run1.sort_by(|a, b| b.1.total_cmp(&a.1));
519        run2.sort_by(|a, b| a.1.total_cmp(&b.1));
520        run3.sort_by(|a, b| b.1.total_cmp(&a.1));
521    }
522
523    let mut out: Vec<EntityId> = Vec::with_capacity(run1.len() + run2.len() + run3.len());
524    out.extend(run1.into_iter().map(|(e, _)| e));
525    out.extend(run2.into_iter().map(|(e, _)| e));
526    out.extend(run3.into_iter().map(|(e, _)| e));
527    let mut seen = HashSet::with_capacity(out.len());
528    out.retain(|e| seen.insert(*e));
529
530    if let Some(q) = world.destination_queue_mut(car_eid) {
531        q.replace(out);
532    }
533}