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, RankContext, pair_can_do_work};
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    /// Weight for the squared-wait "group-time" fairness bonus. Each
32    /// candidate stop's cost is reduced by this weight times the sum
33    /// of `wait_ticks²` across waiting riders at the stop, so stops
34    /// hosting older calls win ties. Defaults to `0.0` (no bias);
35    /// positive values damp the long-wait tail (Aalto EJOR 2016
36    /// group-time assignment model).
37    pub wait_squared_weight: f64,
38    /// Positions of every demanded stop in the group, cached by
39    /// [`DispatchStrategy::pre_dispatch`] so `rank` avoids rebuilding the
40    /// list for every `(car, stop)` pair.
41    pending_positions: SmallVec<[f64; 16]>,
42}
43
44impl EtdDispatch {
45    /// Create a new `EtdDispatch` with default weights.
46    ///
47    /// Defaults: `wait_weight = 1.0`, `delay_weight = 1.0`,
48    /// `door_weight = 0.5`, `wait_squared_weight = 0.0`.
49    #[must_use]
50    pub fn new() -> Self {
51        Self {
52            wait_weight: 1.0,
53            delay_weight: 1.0,
54            door_weight: 0.5,
55            wait_squared_weight: 0.0,
56            pending_positions: SmallVec::new(),
57        }
58    }
59
60    /// Create with a single delay weight (backwards-compatible shorthand).
61    #[must_use]
62    pub fn with_delay_weight(delay_weight: f64) -> Self {
63        Self {
64            wait_weight: 1.0,
65            delay_weight,
66            door_weight: 0.5,
67            wait_squared_weight: 0.0,
68            pending_positions: SmallVec::new(),
69        }
70    }
71
72    /// Create with fully custom weights.
73    #[must_use]
74    pub fn with_weights(wait_weight: f64, delay_weight: f64, door_weight: f64) -> Self {
75        Self {
76            wait_weight,
77            delay_weight,
78            door_weight,
79            wait_squared_weight: 0.0,
80            pending_positions: SmallVec::new(),
81        }
82    }
83
84    /// Turn on the squared-wait fairness bonus. Higher values prefer
85    /// older waiters more aggressively; `0.0` (the default) disables.
86    ///
87    /// # Panics
88    /// Panics on non-finite or negative weights. A `NaN` weight would
89    /// propagate through `mul_add` and silently disable every dispatch
90    /// rank; a negative weight would invert the fairness ordering.
91    /// Either is a programming error rather than a valid configuration.
92    #[must_use]
93    pub fn with_wait_squared_weight(mut self, weight: f64) -> Self {
94        assert!(
95            weight.is_finite() && weight >= 0.0,
96            "wait_squared_weight must be finite and non-negative, got {weight}"
97        );
98        self.wait_squared_weight = weight;
99        self
100    }
101}
102
103impl Default for EtdDispatch {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl DispatchStrategy for EtdDispatch {
110    fn pre_dispatch(
111        &mut self,
112        group: &ElevatorGroup,
113        manifest: &DispatchManifest,
114        world: &mut World,
115    ) {
116        self.pending_positions.clear();
117        for &s in group.stop_entities() {
118            if manifest.has_demand(s)
119                && let Some(p) = world.stop_position(s)
120            {
121                self.pending_positions.push(p);
122            }
123        }
124    }
125
126    fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
127        // Exclude `(car, stop)` pairs that can't produce any useful work.
128        // Without this guard, a full car whose only candidate stop is a
129        // pickup it lacks capacity to serve collapses to a zero-cost
130        // self-assignment (travel, detour, and door terms are all 0 when
131        // the car is already at the stop). Dispatch then re-selects that
132        // stop every tick — doors cycle open, reject, close, repeat — and
133        // the aboard riders are never carried to their destinations.
134        if !pair_can_do_work(ctx) {
135            return None;
136        }
137        let mut cost = self.compute_cost(ctx.car, ctx.car_position, ctx.stop_position, ctx.world);
138        if self.wait_squared_weight > 0.0 {
139            let wait_sq: f64 = ctx
140                .manifest
141                .waiting_riders_at(ctx.stop)
142                .iter()
143                .map(|r| {
144                    let w = r.wait_ticks as f64;
145                    w * w
146                })
147                .sum();
148            cost = self.wait_squared_weight.mul_add(-wait_sq, cost).max(0.0);
149        }
150        if cost.is_finite() { Some(cost) } else { None }
151    }
152}
153
154impl EtdDispatch {
155    /// Compute ETD cost for assigning an elevator to serve a stop.
156    ///
157    /// Cost = `wait_weight` * travel\_time + `delay_weight` * existing\_rider\_delay
158    ///      + `door_weight` * door\_overhead + direction\_bonus
159    fn compute_cost(
160        &self,
161        elev_eid: EntityId,
162        elev_pos: f64,
163        target_pos: f64,
164        world: &World,
165    ) -> f64 {
166        let Some(car) = world.elevator(elev_eid) else {
167            return f64::INFINITY;
168        };
169
170        let distance = (elev_pos - target_pos).abs();
171        let travel_time = if car.max_speed.value() > 0.0 {
172            distance / car.max_speed.value()
173        } else {
174            return f64::INFINITY;
175        };
176
177        // Door overhead is a seconds-denominated cost so the Hungarian
178        // can compare it apples-to-apples against travel time and
179        // existing-rider delay. Pre-fix, this was summed in ticks,
180        // multiplied by `door_weight` (dimensionless), and added to
181        // seconds-valued terms — giving door cost ~60× the intended
182        // influence at 60 Hz. A single intervening stop could then
183        // outweigh a long travel time and bias ETD toward distant
184        // cars with clear shafts over closer ones with a single
185        // waypoint. Convert with the sim's tick rate (resource-
186        // provided) and fall back to 60 Hz for bare-World contexts
187        // such as unit-test fixtures.
188        let tick_rate = world
189            .resource::<crate::time::TickRate>()
190            .map_or(60.0, |r| r.0);
191        let door_overhead_per_stop =
192            f64::from(car.door_transition_ticks * 2 + car.door_open_ticks) / tick_rate;
193
194        // Intervening pending stops between car and target contribute door overhead.
195        let (lo, hi) = if elev_pos < target_pos {
196            (elev_pos, target_pos)
197        } else {
198            (target_pos, elev_pos)
199        };
200        let intervening_stops = self
201            .pending_positions
202            .iter()
203            .filter(|p| **p > lo + 1e-9 && **p < hi - 1e-9)
204            .count() as f64;
205        let door_cost = intervening_stops * door_overhead_per_stop;
206
207        let mut existing_rider_delay = 0.0_f64;
208        for &rider_eid in car.riders() {
209            if let Some(dest) = world.route(rider_eid).and_then(Route::current_destination)
210                && let Some(dest_pos) = world.stop_position(dest)
211            {
212                let direct_dist = (elev_pos - dest_pos).abs();
213                let detour_dist = (elev_pos - target_pos).abs() + (target_pos - dest_pos).abs();
214                let extra = (detour_dist - direct_dist).max(0.0);
215                if car.max_speed.value() > 0.0 {
216                    existing_rider_delay += extra / car.max_speed.value();
217                }
218            }
219        }
220
221        // Direction bonus: if the car is already heading this way, subtract.
222        // Scoring model requires non-negative costs, so clamp at zero — losing
223        // a small amount of discriminative power vs. a pure free-for-all when
224        // two assignments tie.
225        let direction_bonus = match car.phase.moving_target() {
226            Some(current_target) => world.stop_position(current_target).map_or(0.0, |ctp| {
227                let moving_up = ctp > elev_pos;
228                let target_is_ahead = if moving_up {
229                    target_pos > elev_pos && target_pos <= ctp
230                } else {
231                    target_pos < elev_pos && target_pos >= ctp
232                };
233                if target_is_ahead {
234                    -travel_time * 0.5
235                } else {
236                    0.0
237                }
238            }),
239            None if car.phase == ElevatorPhase::Idle => -travel_time * 0.3,
240            _ => 0.0,
241        };
242
243        let raw = self.wait_weight.mul_add(
244            travel_time,
245            self.delay_weight.mul_add(
246                existing_rider_delay,
247                self.door_weight.mul_add(door_cost, direction_bonus),
248            ),
249        );
250        raw.max(0.0)
251    }
252}