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_is_useful};
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.
24#[derive(serde::Serialize, serde::Deserialize)]
25pub struct EtdDispatch {
26    /// Weight for travel time to reach the calling stop.
27    pub wait_weight: f64,
28    /// Weight for delay imposed on existing riders.
29    pub delay_weight: f64,
30    /// Weight for door open/close overhead at intermediate stops.
31    pub door_weight: f64,
32    /// Weight for the squared-wait "group-time" fairness bonus. Each
33    /// candidate stop's cost is reduced by this weight times the sum
34    /// of `wait_ticks²` across waiting riders at the stop, so stops
35    /// hosting older calls win ties. Defaults to `0.0` (no bias);
36    /// positive values damp the long-wait tail (Aalto EJOR 2016
37    /// group-time assignment model).
38    pub wait_squared_weight: f64,
39    /// Weight for the linear waiting-age fairness term. Each candidate
40    /// stop's cost is reduced by this weight times the sum of
41    /// `wait_ticks` across waiting riders at the stop, so stops hosting
42    /// older calls win ties without the quadratic blow-up of
43    /// [`wait_squared_weight`](Self::wait_squared_weight). Defaults to
44    /// `0.0` (no bias); positive values implement the linear
45    /// collective-group-control fairness term from Lim 1983 /
46    /// Barney–dos Santos 1985 CGC.
47    ///
48    /// Composes additively with `wait_squared_weight`: users wanting
49    /// the full CGC shape can set both (`k·Σw + λ·Σw²`).
50    pub age_linear_weight: f64,
51    /// Positions of every demanded stop in the group, cached by
52    /// [`DispatchStrategy::pre_dispatch`] so `rank` avoids rebuilding the
53    /// list for every `(car, stop)` pair. Per-pass scratch — excluded
54    /// from [`snapshot_config`](DispatchStrategy::snapshot_config) since
55    /// `pre_dispatch` rebuilds it on every pass.
56    #[serde(skip)]
57    pending_positions: SmallVec<[f64; 16]>,
58}
59
60impl EtdDispatch {
61    /// Create a new `EtdDispatch` with the baseline weights.
62    ///
63    /// Defaults: `wait_weight = 1.0`, `delay_weight = 1.0`,
64    /// `door_weight = 0.5`, `wait_squared_weight = 0.0`,
65    /// `age_linear_weight = 0.0`.
66    ///
67    /// This is the **baseline** constructor — the fairness terms
68    /// (`wait_squared_weight`, `age_linear_weight`) are off, so behaviour
69    /// matches ETD as originally shipped. Mutant/unit tests that
70    /// measure a single term in isolation (`new().with_age_linear_weight(…)`)
71    /// rely on this contract.
72    ///
73    /// For the opinionated "pick ETD from the dropdown" configuration
74    /// used by [`BuiltinStrategy::Etd`](super::BuiltinStrategy::Etd),
75    /// call [`EtdDispatch::default`] instead — that ships the
76    /// linear-age fairness term active to bound the max-wait tail
77    /// under sustained peak traffic.
78    #[must_use]
79    pub fn new() -> Self {
80        Self {
81            wait_weight: 1.0,
82            delay_weight: 1.0,
83            door_weight: 0.5,
84            wait_squared_weight: 0.0,
85            age_linear_weight: 0.0,
86            pending_positions: SmallVec::new(),
87        }
88    }
89
90    /// Return the opinionated tuned configuration — equivalent to
91    /// [`Default::default`].
92    ///
93    /// Same dispatch shape as [`new`](Self::new) but with the linear
94    /// waiting-age fairness term active:
95    /// `age_linear_weight = 0.005` (seconds of cost-reduction per
96    /// waiting-tick summed across riders at the stop). That value is
97    /// calibrated against the `playground_audit` harness: a stop
98    /// hosting three 30-second waiters sees a ≈27s fairness bonus,
99    /// roughly equal to a short-trip ETA, which is strong enough to
100    /// break ties toward older waiters without overriding travel
101    /// dominance on fresh demand.
102    ///
103    /// Without the age term, ETD's rank is age-agnostic and a stream
104    /// of fresh lobby-side demand can indefinitely preempt a single
105    /// old waiter on an upper floor — exactly the tail-starvation
106    /// pattern showing up as ETD's `max_wait` lagging SCAN's by
107    /// 40-50% in the `playground_audit`. The linear term (from the
108    /// Lim 1983 / Barney–dos Santos CGC lineage) is the established
109    /// fix for that shape.
110    #[must_use]
111    pub fn tuned() -> Self {
112        Self {
113            wait_weight: 1.0,
114            delay_weight: 1.0,
115            door_weight: 0.5,
116            wait_squared_weight: 0.0,
117            age_linear_weight: 0.005,
118            pending_positions: SmallVec::new(),
119        }
120    }
121
122    /// Create with a single delay weight (backwards-compatible shorthand).
123    #[must_use]
124    pub fn with_delay_weight(delay_weight: f64) -> Self {
125        Self {
126            wait_weight: 1.0,
127            delay_weight,
128            door_weight: 0.5,
129            wait_squared_weight: 0.0,
130            age_linear_weight: 0.0,
131            pending_positions: SmallVec::new(),
132        }
133    }
134
135    /// Create with fully custom weights.
136    #[must_use]
137    pub fn with_weights(wait_weight: f64, delay_weight: f64, door_weight: f64) -> Self {
138        Self {
139            wait_weight,
140            delay_weight,
141            door_weight,
142            wait_squared_weight: 0.0,
143            age_linear_weight: 0.0,
144            pending_positions: SmallVec::new(),
145        }
146    }
147
148    /// Turn on the squared-wait fairness bonus. Higher values prefer
149    /// older waiters more aggressively; `0.0` (the default) disables.
150    ///
151    /// # Panics
152    /// Panics on non-finite or negative weights. A `NaN` weight would
153    /// propagate through `mul_add` and silently disable every dispatch
154    /// rank; a negative weight would invert the fairness ordering.
155    /// Either is a programming error rather than a valid configuration.
156    #[must_use]
157    pub fn with_wait_squared_weight(mut self, weight: f64) -> Self {
158        assert!(
159            weight.is_finite() && weight >= 0.0,
160            "wait_squared_weight must be finite and non-negative, got {weight}"
161        );
162        self.wait_squared_weight = weight;
163        self
164    }
165
166    /// Turn on the linear waiting-age fairness term. Higher values
167    /// prefer older waiters more aggressively; `0.0` (the default)
168    /// disables. Composes additively with
169    /// [`with_wait_squared_weight`](Self::with_wait_squared_weight).
170    ///
171    /// # Panics
172    /// Panics on non-finite or negative weights, for the same reasons
173    /// as [`with_wait_squared_weight`](Self::with_wait_squared_weight).
174    #[must_use]
175    pub fn with_age_linear_weight(mut self, weight: f64) -> Self {
176        assert!(
177            weight.is_finite() && weight >= 0.0,
178            "age_linear_weight must be finite and non-negative, got {weight}"
179        );
180        self.age_linear_weight = weight;
181        self
182    }
183}
184
185impl Default for EtdDispatch {
186    /// The opinionated "pick ETD from the dropdown" configuration.
187    ///
188    /// Defaults to [`EtdDispatch::tuned`] — the baseline weights plus
189    /// an active linear-age fairness term. See the `tuned` docstring
190    /// for the calibration rationale.
191    fn default() -> Self {
192        Self::tuned()
193    }
194}
195
196impl DispatchStrategy for EtdDispatch {
197    fn pre_dispatch(
198        &mut self,
199        group: &ElevatorGroup,
200        manifest: &DispatchManifest,
201        world: &mut World,
202    ) {
203        self.pending_positions.clear();
204        for &s in group.stop_entities() {
205            if manifest.has_demand(s)
206                && let Some(p) = world.stop_position(s)
207            {
208                self.pending_positions.push(p);
209            }
210        }
211    }
212
213    fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
214        // Exclude `(car, stop)` pairs that can't produce any useful work.
215        // Without this guard, a full car whose only candidate stop is a
216        // pickup it lacks capacity to serve collapses to a zero-cost
217        // self-assignment (travel, detour, and door terms are all 0 when
218        // the car is already at the stop). Dispatch then re-selects that
219        // stop every tick — doors cycle open, reject, close, repeat — and
220        // the aboard riders are never carried to their destinations.
221        if !pair_is_useful(ctx, false) {
222            return None;
223        }
224        let mut cost = self.compute_cost(ctx.car, ctx.car_position, ctx.stop_position, ctx.world);
225        if self.wait_squared_weight > 0.0 {
226            let wait_sq: f64 = ctx
227                .manifest
228                .waiting_riders_at(ctx.stop)
229                .iter()
230                .map(|r| {
231                    let w = r.wait_ticks as f64;
232                    w * w
233                })
234                .sum();
235            cost = crate::fp::fma(self.wait_squared_weight, -wait_sq, cost).max(0.0);
236        }
237        if self.age_linear_weight > 0.0 {
238            let wait_sum: f64 = ctx
239                .manifest
240                .waiting_riders_at(ctx.stop)
241                .iter()
242                .map(|r| r.wait_ticks as f64)
243                .sum();
244            cost = crate::fp::fma(self.age_linear_weight, -wait_sum, cost).max(0.0);
245        }
246        if cost.is_finite() { Some(cost) } else { None }
247    }
248
249    fn builtin_id(&self) -> Option<super::BuiltinStrategy> {
250        Some(super::BuiltinStrategy::Etd)
251    }
252
253    fn snapshot_config(&self) -> Option<String> {
254        ron::to_string(self).ok()
255    }
256
257    fn restore_config(&mut self, serialized: &str) -> Result<(), String> {
258        let restored: Self = ron::from_str(serialized).map_err(|e| e.to_string())?;
259        *self = restored;
260        Ok(())
261    }
262}
263
264impl EtdDispatch {
265    /// Compute ETD cost for assigning an elevator to serve a stop.
266    ///
267    /// Cost = `wait_weight` * travel\_time + `delay_weight` * existing\_rider\_delay
268    ///      + `door_weight` * door\_overhead + direction\_bonus
269    fn compute_cost(
270        &self,
271        elev_eid: EntityId,
272        elev_pos: f64,
273        target_pos: f64,
274        world: &World,
275    ) -> f64 {
276        let Some(car) = world.elevator(elev_eid) else {
277            return f64::INFINITY;
278        };
279
280        let distance = (elev_pos - target_pos).abs();
281        let travel_time = if car.max_speed.value() > 0.0 {
282            distance / car.max_speed.value()
283        } else {
284            return f64::INFINITY;
285        };
286
287        // Door overhead is a seconds-denominated cost so the Hungarian
288        // can compare it apples-to-apples against travel time and
289        // existing-rider delay. Pre-fix, this was summed in ticks,
290        // multiplied by `door_weight` (dimensionless), and added to
291        // seconds-valued terms — giving door cost ~60× the intended
292        // influence at 60 Hz. A single intervening stop could then
293        // outweigh a long travel time and bias ETD toward distant
294        // cars with clear shafts over closer ones with a single
295        // waypoint. Convert with the sim's tick rate (resource-
296        // provided) and fall back to 60 Hz for bare-World contexts
297        // such as unit-test fixtures.
298        let tick_rate = world
299            .resource::<crate::time::TickRate>()
300            .map_or(60.0, |r| r.0);
301        let door_overhead_per_stop =
302            f64::from(car.door_transition_ticks * 2 + car.door_open_ticks) / tick_rate;
303
304        // Intervening pending stops between car and target contribute door overhead.
305        let (lo, hi) = if elev_pos < target_pos {
306            (elev_pos, target_pos)
307        } else {
308            (target_pos, elev_pos)
309        };
310        let intervening_stops = self
311            .pending_positions
312            .iter()
313            .filter(|p| **p > lo + 1e-9 && **p < hi - 1e-9)
314            .count() as f64;
315        let door_cost = intervening_stops * door_overhead_per_stop;
316
317        let mut existing_rider_delay = 0.0_f64;
318        for &rider_eid in car.riders() {
319            if let Some(dest) = world.route(rider_eid).and_then(Route::current_destination)
320                && let Some(dest_pos) = world.stop_position(dest)
321            {
322                let direct_dist = (elev_pos - dest_pos).abs();
323                let detour_dist = (elev_pos - target_pos).abs() + (target_pos - dest_pos).abs();
324                let extra = (detour_dist - direct_dist).max(0.0);
325                if car.max_speed.value() > 0.0 {
326                    existing_rider_delay += extra / car.max_speed.value();
327                }
328            }
329        }
330
331        // Direction bonus: if the car is already heading this way, subtract.
332        // Scoring model requires non-negative costs, so clamp at zero — losing
333        // a small amount of discriminative power vs. a pure free-for-all when
334        // two assignments tie.
335        let direction_bonus = match car.phase.moving_target() {
336            Some(current_target) => world.stop_position(current_target).map_or(0.0, |ctp| {
337                let moving_up = ctp > elev_pos;
338                let target_is_ahead = if moving_up {
339                    target_pos > elev_pos && target_pos <= ctp
340                } else {
341                    target_pos < elev_pos && target_pos >= ctp
342                };
343                if target_is_ahead {
344                    -travel_time * 0.5
345                } else {
346                    0.0
347                }
348            }),
349            None if car.phase == ElevatorPhase::Idle => -travel_time * 0.3,
350            _ => 0.0,
351        };
352
353        let raw = crate::fp::fma(
354            self.wait_weight,
355            travel_time,
356            crate::fp::fma(
357                self.delay_weight,
358                existing_rider_delay,
359                crate::fp::fma(self.door_weight, door_cost, direction_bonus),
360            ),
361        );
362        raw.max(0.0)
363    }
364}