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.
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 default 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 #[must_use]
67 pub fn new() -> Self {
68 Self {
69 wait_weight: 1.0,
70 delay_weight: 1.0,
71 door_weight: 0.5,
72 wait_squared_weight: 0.0,
73 age_linear_weight: 0.0,
74 pending_positions: SmallVec::new(),
75 }
76 }
77
78 /// Create with a single delay weight (backwards-compatible shorthand).
79 #[must_use]
80 pub fn with_delay_weight(delay_weight: f64) -> Self {
81 Self {
82 wait_weight: 1.0,
83 delay_weight,
84 door_weight: 0.5,
85 wait_squared_weight: 0.0,
86 age_linear_weight: 0.0,
87 pending_positions: SmallVec::new(),
88 }
89 }
90
91 /// Create with fully custom weights.
92 #[must_use]
93 pub fn with_weights(wait_weight: f64, delay_weight: f64, door_weight: f64) -> Self {
94 Self {
95 wait_weight,
96 delay_weight,
97 door_weight,
98 wait_squared_weight: 0.0,
99 age_linear_weight: 0.0,
100 pending_positions: SmallVec::new(),
101 }
102 }
103
104 /// Turn on the squared-wait fairness bonus. Higher values prefer
105 /// older waiters more aggressively; `0.0` (the default) disables.
106 ///
107 /// # Panics
108 /// Panics on non-finite or negative weights. A `NaN` weight would
109 /// propagate through `mul_add` and silently disable every dispatch
110 /// rank; a negative weight would invert the fairness ordering.
111 /// Either is a programming error rather than a valid configuration.
112 #[must_use]
113 pub fn with_wait_squared_weight(mut self, weight: f64) -> Self {
114 assert!(
115 weight.is_finite() && weight >= 0.0,
116 "wait_squared_weight must be finite and non-negative, got {weight}"
117 );
118 self.wait_squared_weight = weight;
119 self
120 }
121
122 /// Turn on the linear waiting-age fairness term. Higher values
123 /// prefer older waiters more aggressively; `0.0` (the default)
124 /// disables. Composes additively with
125 /// [`with_wait_squared_weight`](Self::with_wait_squared_weight).
126 ///
127 /// # Panics
128 /// Panics on non-finite or negative weights, for the same reasons
129 /// as [`with_wait_squared_weight`](Self::with_wait_squared_weight).
130 #[must_use]
131 pub fn with_age_linear_weight(mut self, weight: f64) -> Self {
132 assert!(
133 weight.is_finite() && weight >= 0.0,
134 "age_linear_weight must be finite and non-negative, got {weight}"
135 );
136 self.age_linear_weight = weight;
137 self
138 }
139}
140
141impl Default for EtdDispatch {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147impl DispatchStrategy for EtdDispatch {
148 fn pre_dispatch(
149 &mut self,
150 group: &ElevatorGroup,
151 manifest: &DispatchManifest,
152 world: &mut World,
153 ) {
154 self.pending_positions.clear();
155 for &s in group.stop_entities() {
156 if manifest.has_demand(s)
157 && let Some(p) = world.stop_position(s)
158 {
159 self.pending_positions.push(p);
160 }
161 }
162 }
163
164 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
165 // Exclude `(car, stop)` pairs that can't produce any useful work.
166 // Without this guard, a full car whose only candidate stop is a
167 // pickup it lacks capacity to serve collapses to a zero-cost
168 // self-assignment (travel, detour, and door terms are all 0 when
169 // the car is already at the stop). Dispatch then re-selects that
170 // stop every tick — doors cycle open, reject, close, repeat — and
171 // the aboard riders are never carried to their destinations.
172 if !pair_can_do_work(ctx) {
173 return None;
174 }
175 let mut cost = self.compute_cost(ctx.car, ctx.car_position, ctx.stop_position, ctx.world);
176 if self.wait_squared_weight > 0.0 {
177 let wait_sq: f64 = ctx
178 .manifest
179 .waiting_riders_at(ctx.stop)
180 .iter()
181 .map(|r| {
182 let w = r.wait_ticks as f64;
183 w * w
184 })
185 .sum();
186 cost = self.wait_squared_weight.mul_add(-wait_sq, cost).max(0.0);
187 }
188 if self.age_linear_weight > 0.0 {
189 let wait_sum: f64 = ctx
190 .manifest
191 .waiting_riders_at(ctx.stop)
192 .iter()
193 .map(|r| r.wait_ticks as f64)
194 .sum();
195 cost = self.age_linear_weight.mul_add(-wait_sum, cost).max(0.0);
196 }
197 if cost.is_finite() { Some(cost) } else { None }
198 }
199
200 fn builtin_id(&self) -> Option<super::BuiltinStrategy> {
201 Some(super::BuiltinStrategy::Etd)
202 }
203
204 fn snapshot_config(&self) -> Option<String> {
205 ron::to_string(self).ok()
206 }
207
208 fn restore_config(&mut self, serialized: &str) -> Result<(), String> {
209 let restored: Self = ron::from_str(serialized).map_err(|e| e.to_string())?;
210 *self = restored;
211 Ok(())
212 }
213}
214
215impl EtdDispatch {
216 /// Compute ETD cost for assigning an elevator to serve a stop.
217 ///
218 /// Cost = `wait_weight` * travel\_time + `delay_weight` * existing\_rider\_delay
219 /// + `door_weight` * door\_overhead + direction\_bonus
220 fn compute_cost(
221 &self,
222 elev_eid: EntityId,
223 elev_pos: f64,
224 target_pos: f64,
225 world: &World,
226 ) -> f64 {
227 let Some(car) = world.elevator(elev_eid) else {
228 return f64::INFINITY;
229 };
230
231 let distance = (elev_pos - target_pos).abs();
232 let travel_time = if car.max_speed.value() > 0.0 {
233 distance / car.max_speed.value()
234 } else {
235 return f64::INFINITY;
236 };
237
238 // Door overhead is a seconds-denominated cost so the Hungarian
239 // can compare it apples-to-apples against travel time and
240 // existing-rider delay. Pre-fix, this was summed in ticks,
241 // multiplied by `door_weight` (dimensionless), and added to
242 // seconds-valued terms — giving door cost ~60× the intended
243 // influence at 60 Hz. A single intervening stop could then
244 // outweigh a long travel time and bias ETD toward distant
245 // cars with clear shafts over closer ones with a single
246 // waypoint. Convert with the sim's tick rate (resource-
247 // provided) and fall back to 60 Hz for bare-World contexts
248 // such as unit-test fixtures.
249 let tick_rate = world
250 .resource::<crate::time::TickRate>()
251 .map_or(60.0, |r| r.0);
252 let door_overhead_per_stop =
253 f64::from(car.door_transition_ticks * 2 + car.door_open_ticks) / tick_rate;
254
255 // Intervening pending stops between car and target contribute door overhead.
256 let (lo, hi) = if elev_pos < target_pos {
257 (elev_pos, target_pos)
258 } else {
259 (target_pos, elev_pos)
260 };
261 let intervening_stops = self
262 .pending_positions
263 .iter()
264 .filter(|p| **p > lo + 1e-9 && **p < hi - 1e-9)
265 .count() as f64;
266 let door_cost = intervening_stops * door_overhead_per_stop;
267
268 let mut existing_rider_delay = 0.0_f64;
269 for &rider_eid in car.riders() {
270 if let Some(dest) = world.route(rider_eid).and_then(Route::current_destination)
271 && let Some(dest_pos) = world.stop_position(dest)
272 {
273 let direct_dist = (elev_pos - dest_pos).abs();
274 let detour_dist = (elev_pos - target_pos).abs() + (target_pos - dest_pos).abs();
275 let extra = (detour_dist - direct_dist).max(0.0);
276 if car.max_speed.value() > 0.0 {
277 existing_rider_delay += extra / car.max_speed.value();
278 }
279 }
280 }
281
282 // Direction bonus: if the car is already heading this way, subtract.
283 // Scoring model requires non-negative costs, so clamp at zero — losing
284 // a small amount of discriminative power vs. a pure free-for-all when
285 // two assignments tie.
286 let direction_bonus = match car.phase.moving_target() {
287 Some(current_target) => world.stop_position(current_target).map_or(0.0, |ctp| {
288 let moving_up = ctp > elev_pos;
289 let target_is_ahead = if moving_up {
290 target_pos > elev_pos && target_pos <= ctp
291 } else {
292 target_pos < elev_pos && target_pos >= ctp
293 };
294 if target_is_ahead {
295 -travel_time * 0.5
296 } else {
297 0.0
298 }
299 }),
300 None if car.phase == ElevatorPhase::Idle => -travel_time * 0.3,
301 _ => 0.0,
302 };
303
304 let raw = self.wait_weight.mul_add(
305 travel_time,
306 self.delay_weight.mul_add(
307 existing_rider_delay,
308 self.door_weight.mul_add(door_cost, direction_bonus),
309 ),
310 );
311 raw.max(0.0)
312 }
313}