Skip to main content

elevator_core/dispatch/
etd.rs

1//! Estimated Time to Destination (ETD) dispatch algorithm.
2//!
3//! The per-call cost-minimization approach is drawn from Barney, G. C. &
4//! dos Santos, S. M., *Elevator Traffic Analysis, Design and Control* (2nd
5//! ed., 1985). Commercial controllers (Otis Elevonic, KONE Polaris, etc.)
6//! use variants of the same idea; this implementation is a simplified
7//! educational model, not a faithful reproduction of any vendor's system.
8
9use smallvec::SmallVec;
10
11use crate::components::{ElevatorPhase, Route};
12use crate::entity::EntityId;
13use crate::world::World;
14
15use super::{DispatchManifest, DispatchStrategy, ElevatorGroup};
16
17/// Estimated Time to Destination (ETD) dispatch algorithm.
18///
19/// For each `(car, stop)` pair the rank is a cost estimate combining
20/// travel time, delay imposed on riders already aboard, door-overhead
21/// for intervening stops, and a small bonus for cars already heading
22/// toward the stop. The dispatch system runs an optimal assignment
23/// across all pairs so the globally best matching is chosen.
24pub struct EtdDispatch {
25    /// Weight for travel time to reach the calling stop.
26    pub wait_weight: f64,
27    /// Weight for delay imposed on existing riders.
28    pub delay_weight: f64,
29    /// Weight for door open/close overhead at intermediate stops.
30    pub door_weight: f64,
31    /// Positions of every demanded stop in the group, cached by
32    /// [`DispatchStrategy::pre_dispatch`] so `rank` avoids rebuilding the
33    /// list for every `(car, stop)` pair.
34    pending_positions: SmallVec<[f64; 16]>,
35}
36
37impl EtdDispatch {
38    /// Create a new `EtdDispatch` with default weights.
39    ///
40    /// Defaults: `wait_weight = 1.0`, `delay_weight = 1.0`, `door_weight = 0.5`.
41    #[must_use]
42    pub fn new() -> Self {
43        Self {
44            wait_weight: 1.0,
45            delay_weight: 1.0,
46            door_weight: 0.5,
47            pending_positions: SmallVec::new(),
48        }
49    }
50
51    /// Create with a single delay weight (backwards-compatible shorthand).
52    #[must_use]
53    pub fn with_delay_weight(delay_weight: f64) -> Self {
54        Self {
55            wait_weight: 1.0,
56            delay_weight,
57            door_weight: 0.5,
58            pending_positions: SmallVec::new(),
59        }
60    }
61
62    /// Create with fully custom weights.
63    #[must_use]
64    pub fn with_weights(wait_weight: f64, delay_weight: f64, door_weight: f64) -> Self {
65        Self {
66            wait_weight,
67            delay_weight,
68            door_weight,
69            pending_positions: SmallVec::new(),
70        }
71    }
72}
73
74impl Default for EtdDispatch {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl DispatchStrategy for EtdDispatch {
81    fn pre_dispatch(
82        &mut self,
83        group: &ElevatorGroup,
84        manifest: &DispatchManifest,
85        world: &mut World,
86    ) {
87        self.pending_positions.clear();
88        for &s in group.stop_entities() {
89            if manifest.has_demand(s)
90                && let Some(p) = world.stop_position(s)
91            {
92                self.pending_positions.push(p);
93            }
94        }
95    }
96
97    fn rank(
98        &mut self,
99        car: EntityId,
100        car_position: f64,
101        _stop: EntityId,
102        stop_position: f64,
103        _group: &ElevatorGroup,
104        _manifest: &DispatchManifest,
105        world: &World,
106    ) -> Option<f64> {
107        let cost = self.compute_cost(car, car_position, stop_position, world);
108        if cost.is_finite() { Some(cost) } else { None }
109    }
110}
111
112impl EtdDispatch {
113    /// Compute ETD cost for assigning an elevator to serve a stop.
114    ///
115    /// Cost = `wait_weight` * travel\_time + `delay_weight` * existing\_rider\_delay
116    ///      + `door_weight` * door\_overhead + direction\_bonus
117    fn compute_cost(
118        &self,
119        elev_eid: EntityId,
120        elev_pos: f64,
121        target_pos: f64,
122        world: &World,
123    ) -> f64 {
124        let Some(car) = world.elevator(elev_eid) else {
125            return f64::INFINITY;
126        };
127
128        let distance = (elev_pos - target_pos).abs();
129        let travel_time = if car.max_speed > 0.0 {
130            distance / car.max_speed
131        } else {
132            return f64::INFINITY;
133        };
134
135        let door_overhead_per_stop = f64::from(car.door_transition_ticks * 2 + car.door_open_ticks);
136
137        // Intervening pending stops between car and target contribute door overhead.
138        let (lo, hi) = if elev_pos < target_pos {
139            (elev_pos, target_pos)
140        } else {
141            (target_pos, elev_pos)
142        };
143        let intervening_stops = self
144            .pending_positions
145            .iter()
146            .filter(|p| **p > lo + 1e-9 && **p < hi - 1e-9)
147            .count() as f64;
148        let door_cost = intervening_stops * door_overhead_per_stop;
149
150        let mut existing_rider_delay = 0.0_f64;
151        for &rider_eid in car.riders() {
152            if let Some(dest) = world.route(rider_eid).and_then(Route::current_destination)
153                && let Some(dest_pos) = world.stop_position(dest)
154            {
155                let direct_dist = (elev_pos - dest_pos).abs();
156                let detour_dist = (elev_pos - target_pos).abs() + (target_pos - dest_pos).abs();
157                let extra = (detour_dist - direct_dist).max(0.0);
158                if car.max_speed > 0.0 {
159                    existing_rider_delay += extra / car.max_speed;
160                }
161            }
162        }
163
164        // Direction bonus: if the car is already heading this way, subtract.
165        // Scoring model requires non-negative costs, so clamp at zero — losing
166        // a small amount of discriminative power vs. a pure free-for-all when
167        // two assignments tie.
168        let direction_bonus = match car.phase.moving_target() {
169            Some(current_target) => world.stop_position(current_target).map_or(0.0, |ctp| {
170                let moving_up = ctp > elev_pos;
171                let target_is_ahead = if moving_up {
172                    target_pos > elev_pos && target_pos <= ctp
173                } else {
174                    target_pos < elev_pos && target_pos >= ctp
175                };
176                if target_is_ahead {
177                    -travel_time * 0.5
178                } else {
179                    0.0
180                }
181            }),
182            None if car.phase == ElevatorPhase::Idle => -travel_time * 0.3,
183            _ => 0.0,
184        };
185
186        let raw = self.wait_weight.mul_add(
187            travel_time,
188            self.delay_weight.mul_add(
189                existing_rider_delay,
190                self.door_weight.mul_add(door_cost, direction_bonus),
191            ),
192        );
193        raw.max(0.0)
194    }
195}