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