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