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::{DispatchDecision, DispatchManifest, DispatchStrategy, ElevatorGroup};
16
17/// Estimated Time to Destination (ETD) dispatch algorithm.
18///
19/// Industry-standard algorithm for modern elevator systems. For each
20/// pending call, evaluates every elevator and assigns the one that
21/// minimizes total cost: (time to serve the new rider) + (delay imposed
22/// on all existing riders) + (door/loading overhead).
23///
24/// ## Cost model
25///
26/// `cost = wait_weight * travel_time
27///       + delay_weight * existing_rider_delay
28///       + door_weight * estimated_door_overhead
29///       + direction_bonus`
30///
31/// Rider delay is computed from actual route destinations of riders
32/// currently aboard each elevator.
33pub struct EtdDispatch {
34    /// Weight for travel time to reach the calling stop.
35    pub wait_weight: f64,
36    /// Weight for delay imposed on existing riders.
37    pub delay_weight: f64,
38    /// Weight for door open/close overhead at intermediate stops.
39    pub door_weight: f64,
40}
41
42impl EtdDispatch {
43    /// Create a new `EtdDispatch` with default weights.
44    ///
45    /// Defaults: `wait_weight = 1.0`, `delay_weight = 1.0`, `door_weight = 0.5`.
46    #[must_use]
47    pub const fn new() -> Self {
48        Self {
49            wait_weight: 1.0,
50            delay_weight: 1.0,
51            door_weight: 0.5,
52        }
53    }
54
55    /// Create with a single delay weight (backwards-compatible shorthand).
56    ///
57    /// Sets `wait_weight = 1.0` and `door_weight = 0.5`.
58    #[must_use]
59    pub const fn with_delay_weight(delay_weight: f64) -> Self {
60        Self {
61            wait_weight: 1.0,
62            delay_weight,
63            door_weight: 0.5,
64        }
65    }
66
67    /// Create with fully custom weights.
68    #[must_use]
69    pub const fn with_weights(wait_weight: f64, delay_weight: f64, door_weight: f64) -> Self {
70        Self {
71            wait_weight,
72            delay_weight,
73            door_weight,
74        }
75    }
76}
77
78impl Default for EtdDispatch {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl DispatchStrategy for EtdDispatch {
85    fn decide(
86        &mut self,
87        _elevator: EntityId,
88        _elevator_position: f64,
89        _group: &ElevatorGroup,
90        _manifest: &DispatchManifest,
91        _world: &World,
92    ) -> DispatchDecision {
93        // Not used — decide_all() handles group coordination.
94        DispatchDecision::Idle
95    }
96
97    fn decide_all(
98        &mut self,
99        elevators: &[(EntityId, f64)],
100        group: &ElevatorGroup,
101        manifest: &DispatchManifest,
102        world: &World,
103    ) -> Vec<(EntityId, DispatchDecision)> {
104        // Collect stops needing service.
105        let pending_stops: SmallVec<[(EntityId, f64); 16]> = group
106            .stop_entities()
107            .iter()
108            .filter_map(|&stop_eid| {
109                if manifest.has_demand(stop_eid) {
110                    world.stop_position(stop_eid).map(|pos| (stop_eid, pos))
111                } else {
112                    None
113                }
114            })
115            .collect();
116
117        if pending_stops.is_empty() {
118            return elevators
119                .iter()
120                .map(|(eid, _)| (*eid, DispatchDecision::Idle))
121                .collect();
122        }
123
124        let mut results: Vec<(EntityId, DispatchDecision)> = Vec::new();
125        let mut assigned_elevators: SmallVec<[EntityId; 16]> = SmallVec::new();
126
127        // For each pending stop, find the elevator with minimum ETD cost.
128        for (stop_eid, stop_pos) in &pending_stops {
129            let mut best_elevator: Option<EntityId> = None;
130            let mut best_cost = f64::INFINITY;
131
132            for &(elev_eid, elev_pos) in elevators {
133                if assigned_elevators.contains(&elev_eid) {
134                    continue;
135                }
136
137                let cost = self.compute_cost(
138                    elev_eid,
139                    elev_pos,
140                    *stop_pos,
141                    &pending_stops,
142                    manifest,
143                    world,
144                );
145
146                if cost < best_cost {
147                    best_cost = cost;
148                    best_elevator = Some(elev_eid);
149                }
150            }
151
152            if let Some(elev_eid) = best_elevator {
153                results.push((elev_eid, DispatchDecision::GoToStop(*stop_eid)));
154                assigned_elevators.push(elev_eid);
155            }
156        }
157
158        // Unassigned elevators idle.
159        for (eid, _) in elevators {
160            if !assigned_elevators.contains(eid) {
161                results.push((*eid, DispatchDecision::Idle));
162            }
163        }
164
165        results
166    }
167}
168
169impl EtdDispatch {
170    /// Compute ETD cost for assigning an elevator to serve a stop.
171    ///
172    /// Cost = `wait_weight` * travel\_time + `delay_weight` * existing\_rider\_delay
173    ///      + `door_weight` * door\_overhead + direction\_bonus
174    fn compute_cost(
175        &self,
176        elev_eid: EntityId,
177        elev_pos: f64,
178        target_pos: f64,
179        pending_stops: &[(EntityId, f64)],
180        _manifest: &DispatchManifest,
181        world: &World,
182    ) -> f64 {
183        let Some(car) = world.elevator(elev_eid) else {
184            return f64::INFINITY;
185        };
186
187        // Base travel time: distance / max_speed.
188        let distance = (elev_pos - target_pos).abs();
189        let travel_time = if car.max_speed > 0.0 {
190            distance / car.max_speed
191        } else {
192            return f64::INFINITY;
193        };
194
195        // Door overhead: estimate per-stop overhead from door transitions and dwell.
196        let door_overhead_per_stop = f64::from(car.door_transition_ticks * 2 + car.door_open_ticks);
197
198        // Count intervening pending stops between elevator and target.
199        let (lo, hi) = if elev_pos < target_pos {
200            (elev_pos, target_pos)
201        } else {
202            (target_pos, elev_pos)
203        };
204        let intervening_stops = pending_stops
205            .iter()
206            .filter(|(_, pos)| *pos > lo + 1e-9 && *pos < hi - 1e-9)
207            .count() as f64;
208        let door_cost = intervening_stops * door_overhead_per_stop;
209
210        // Delay to existing riders: each rider aboard heading elsewhere
211        // would be delayed by roughly the detour time.
212        let mut existing_rider_delay = 0.0_f64;
213        for &rider_eid in car.riders() {
214            if let Some(dest) = world.route(rider_eid).and_then(Route::current_destination)
215                && let Some(dest_pos) = world.stop_position(dest)
216            {
217                // The rider wants to go to dest_pos. If the detour to
218                // target_pos takes the elevator away from dest_pos, that's
219                // a delay proportional to the extra distance.
220                let direct_dist = (elev_pos - dest_pos).abs();
221                let detour_dist = (elev_pos - target_pos).abs() + (target_pos - dest_pos).abs();
222                let extra = (detour_dist - direct_dist).max(0.0);
223                if car.max_speed > 0.0 {
224                    existing_rider_delay += extra / car.max_speed;
225                }
226            }
227        }
228
229        // Bonus: if the elevator is already heading toward this stop
230        // (same direction), reduce cost. Both dispatched (`MovingToStop`)
231        // and repositioning cars are redirectable and get the same bonus.
232        let direction_bonus = match car.phase.moving_target() {
233            Some(current_target) => world.stop_position(current_target).map_or(0.0, |ctp| {
234                let moving_up = ctp > elev_pos;
235                let target_is_ahead = if moving_up {
236                    target_pos > elev_pos && target_pos <= ctp
237                } else {
238                    target_pos < elev_pos && target_pos >= ctp
239                };
240                if target_is_ahead {
241                    -travel_time * 0.5
242                } else {
243                    0.0
244                }
245            }),
246            None if car.phase == ElevatorPhase::Idle => -travel_time * 0.3, // Slight bonus for idle elevators.
247            _ => 0.0,
248        };
249
250        self.wait_weight.mul_add(
251            travel_time,
252            self.delay_weight.mul_add(
253                existing_rider_delay,
254                self.door_weight.mul_add(door_cost, direction_bonus),
255            ),
256        )
257    }
258}