Skip to main content

elevator_core/dispatch/
etd.rs

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