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