elevator_core/dispatch/rsr.rs
1//! Relative System Response (RSR) dispatch — a composite additive
2//! cost stack.
3//!
4//! Inspired by the Otis patent lineage (Bittar US5024295A, US5146053A)
5//! and the Barney–dos Santos CGC framework. Unlike those proprietary
6//! systems, this implementation is an educational model, not a
7//! faithful reproduction of any vendor's scoring.
8//!
9//! Shape: `rank = eta_weight · travel_time + Σ penalties − Σ bonuses`.
10//! All terms are additive scalars, so they compose cleanly with the
11//! library's Kuhn–Munkres assignment. Defaults are tuned so the stack
12//! reduces to the nearest-car baseline when every weight is zero.
13//!
14//! What this deliberately leaves out: online weight tuning, fuzzy
15//! inference, and stickiness state. Those belong above the trait, not
16//! inside a strategy.
17
18use crate::components::{CarCall, ElevatorPhase};
19
20use super::{DispatchStrategy, RankContext, pair_can_do_work};
21
22/// Additive RSR-style cost stack. Lower scores win the Hungarian
23/// assignment.
24///
25/// See module docs for the cost shape. All weights default to `0.0`
26/// except `eta_weight` (1.0), giving a baseline that mirrors
27/// [`NearestCarDispatch`](super::NearestCarDispatch) until terms are
28/// opted in.
29///
30/// # Weight invariants
31///
32/// Every weight field must be **finite and non-negative**. The
33/// `with_*` builder methods enforce this with `assert!`; direct field
34/// mutation bypasses the check and is a caller responsibility. A `NaN` weight propagates through the multiply-add
35/// chain and silently collapses every pair's cost to zero (Rust's
36/// `NaN.max(0.0) == 0.0`), producing an arbitrary but type-valid
37/// assignment from the Hungarian solver — a hard bug to diagnose.
38pub struct RsrDispatch {
39 /// Weight on `travel_time = distance / max_speed` (seconds).
40 /// Default `1.0`; raising it shifts the blend toward travel time.
41 pub eta_weight: f64,
42 /// Constant added when the candidate stop lies opposite the
43 /// car's committed travel direction.
44 ///
45 /// Default `0.0`; the Otis RSR lineage uses a large value so any
46 /// right-direction candidate outranks any wrong-direction one.
47 /// Ignored for cars in [`ElevatorPhase::Idle`] or stopped phases,
48 /// since an idle car has no committed direction to be opposite to.
49 pub wrong_direction_penalty: f64,
50 /// Bonus subtracted when the candidate stop is already a car-call
51 /// inside this car.
52 ///
53 /// Merges the new pickup with an existing dropoff instead of
54 /// spawning an unrelated trip. Default `0.0`. Read from
55 /// [`DispatchManifest::car_calls_for`](super::DispatchManifest::car_calls_for).
56 pub coincident_car_call_bonus: f64,
57 /// Coefficient on a smooth load-fraction penalty
58 /// (`load_penalty_coeff · load_ratio`).
59 ///
60 /// Fires for partially loaded cars below the `bypass_load_*_pct`
61 /// threshold enforced by [`pair_can_do_work`]; lets you prefer
62 /// emptier cars for new pickups without an on/off cliff.
63 /// Default `0.0`.
64 pub load_penalty_coeff: f64,
65}
66
67impl RsrDispatch {
68 /// Create a new `RsrDispatch` with the baseline weights
69 /// (`eta_weight = 1.0`, all penalties/bonuses disabled).
70 #[must_use]
71 pub const fn new() -> Self {
72 Self {
73 eta_weight: 1.0,
74 wrong_direction_penalty: 0.0,
75 coincident_car_call_bonus: 0.0,
76 load_penalty_coeff: 0.0,
77 }
78 }
79
80 /// Set the wrong-direction penalty.
81 ///
82 /// # Panics
83 /// Panics on non-finite or negative weights — a negative penalty
84 /// would invert the direction ordering, silently preferring
85 /// wrong-direction candidates.
86 #[must_use]
87 pub fn with_wrong_direction_penalty(mut self, weight: f64) -> Self {
88 assert!(
89 weight.is_finite() && weight >= 0.0,
90 "wrong_direction_penalty must be finite and non-negative, got {weight}"
91 );
92 self.wrong_direction_penalty = weight;
93 self
94 }
95
96 /// Set the coincident-car-call bonus.
97 ///
98 /// # Panics
99 /// Panics on non-finite or negative weights — the bonus is
100 /// subtracted, so a negative value would become a penalty.
101 #[must_use]
102 pub fn with_coincident_car_call_bonus(mut self, weight: f64) -> Self {
103 assert!(
104 weight.is_finite() && weight >= 0.0,
105 "coincident_car_call_bonus must be finite and non-negative, got {weight}"
106 );
107 self.coincident_car_call_bonus = weight;
108 self
109 }
110
111 /// Set the load-penalty coefficient.
112 ///
113 /// # Panics
114 /// Panics on non-finite or negative weights.
115 #[must_use]
116 pub fn with_load_penalty_coeff(mut self, weight: f64) -> Self {
117 assert!(
118 weight.is_finite() && weight >= 0.0,
119 "load_penalty_coeff must be finite and non-negative, got {weight}"
120 );
121 self.load_penalty_coeff = weight;
122 self
123 }
124
125 /// Set the ETA weight.
126 ///
127 /// # Panics
128 /// Panics on non-finite or negative weights. Zero is allowed and
129 /// reduces the strategy to penalty/bonus tiebreaking alone.
130 #[must_use]
131 pub fn with_eta_weight(mut self, weight: f64) -> Self {
132 assert!(
133 weight.is_finite() && weight >= 0.0,
134 "eta_weight must be finite and non-negative, got {weight}"
135 );
136 self.eta_weight = weight;
137 self
138 }
139}
140
141impl Default for RsrDispatch {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147impl DispatchStrategy for RsrDispatch {
148 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
149 if !pair_can_do_work(ctx) {
150 return None;
151 }
152 let car = ctx.world.elevator(ctx.car)?;
153
154 // ETA — travel time to the candidate stop.
155 let distance = (ctx.car_position - ctx.stop_position).abs();
156 let max_speed = car.max_speed.value();
157 if max_speed <= 0.0 {
158 return None;
159 }
160 let travel_time = distance / max_speed;
161 let mut cost = self.eta_weight * travel_time;
162
163 // Wrong-direction penalty. Only applies when the car has a
164 // committed direction (not Idle / Stopped) — an idle car can
165 // accept any candidate without "reversing" anything.
166 if self.wrong_direction_penalty > 0.0
167 && let Some(target) = car.phase.moving_target()
168 && let Some(target_pos) = ctx.world.stop_position(target)
169 {
170 let car_going_up = target_pos > ctx.car_position;
171 let car_going_down = target_pos < ctx.car_position;
172 let cand_above = ctx.stop_position > ctx.car_position;
173 let cand_below = ctx.stop_position < ctx.car_position;
174 if (car_going_up && cand_below) || (car_going_down && cand_above) {
175 cost += self.wrong_direction_penalty;
176 }
177 }
178
179 // Coincident-car-call bonus — the candidate stop is already a
180 // committed dropoff for this car.
181 if self.coincident_car_call_bonus > 0.0
182 && ctx
183 .manifest
184 .car_calls_for(ctx.car)
185 .iter()
186 .any(|c: &CarCall| c.floor == ctx.stop)
187 {
188 cost -= self.coincident_car_call_bonus;
189 }
190
191 // Smooth load-fraction penalty. `pair_can_do_work` has already
192 // filtered over-capacity and bypass-threshold cases; this term
193 // shapes preference among the survivors so emptier cars win
194 // pickups when all else is equal. Idle cars contribute zero.
195 if self.load_penalty_coeff > 0.0 && car.phase() != ElevatorPhase::Idle {
196 let capacity = car.weight_capacity().value();
197 if capacity > 0.0 {
198 let load_ratio = (car.current_load().value() / capacity).clamp(0.0, 1.0);
199 cost += self.load_penalty_coeff * load_ratio;
200 }
201 }
202
203 let cost = cost.max(0.0);
204 if cost.is_finite() { Some(cost) } else { None }
205 }
206}