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};
19use crate::traffic_detector::{TrafficDetector, TrafficMode};
20
21use super::{DispatchStrategy, RankContext, pair_can_do_work};
22
23/// Look up the current [`TrafficMode`] from `ctx.world` and return the
24/// scaling factor to apply to the wrong-direction penalty.
25///
26/// Returns `multiplier` when the mode is `UpPeak` or `DownPeak`, else
27/// `1.0`. Also returns `1.0` when the detector resource is missing —
28/// keeping the strategy functional in tests that skip `Simulation::new`.
29fn peak_scaling(ctx: &RankContext<'_>, multiplier: f64) -> f64 {
30 let mode = ctx
31 .world
32 .resource::<TrafficDetector>()
33 .map_or(TrafficMode::Idle, TrafficDetector::current_mode);
34 match mode {
35 TrafficMode::UpPeak | TrafficMode::DownPeak => multiplier,
36 _ => 1.0,
37 }
38}
39
40/// Additive RSR-style cost stack. Lower scores win the Hungarian
41/// assignment.
42///
43/// See module docs for the cost shape. All weights default to `0.0`
44/// except `eta_weight` (1.0), giving a baseline that mirrors
45/// [`NearestCarDispatch`](super::NearestCarDispatch) until terms are
46/// opted in.
47///
48/// # Weight invariants
49///
50/// Every weight field must be **finite and non-negative**. The
51/// `with_*` builder methods enforce this with `assert!`; direct field
52/// mutation bypasses the check and is a caller responsibility. A `NaN` weight propagates through the multiply-add
53/// chain and silently collapses every pair's cost to zero (Rust's
54/// `NaN.max(0.0) == 0.0`), producing an arbitrary but type-valid
55/// assignment from the Hungarian solver — a hard bug to diagnose.
56pub struct RsrDispatch {
57 /// Weight on `travel_time = distance / max_speed` (seconds).
58 /// Default `1.0`; raising it shifts the blend toward travel time.
59 pub eta_weight: f64,
60 /// Constant added when the candidate stop lies opposite the
61 /// car's committed travel direction.
62 ///
63 /// Default `0.0`; the Otis RSR lineage uses a large value so any
64 /// right-direction candidate outranks any wrong-direction one.
65 /// Ignored for cars in [`ElevatorPhase::Idle`] or stopped phases,
66 /// since an idle car has no committed direction to be opposite to.
67 pub wrong_direction_penalty: f64,
68 /// Bonus subtracted when the candidate stop is already a car-call
69 /// inside this car.
70 ///
71 /// Merges the new pickup with an existing dropoff instead of
72 /// spawning an unrelated trip. Default `0.0`. Read from
73 /// [`DispatchManifest::car_calls_for`](super::DispatchManifest::car_calls_for).
74 pub coincident_car_call_bonus: f64,
75 /// Coefficient on a smooth load-fraction penalty
76 /// (`load_penalty_coeff · load_ratio`).
77 ///
78 /// Fires for partially loaded cars below the `bypass_load_*_pct`
79 /// threshold enforced by [`pair_can_do_work`]; lets you prefer
80 /// emptier cars for new pickups without an on/off cliff.
81 /// Default `0.0`.
82 pub load_penalty_coeff: f64,
83 /// Multiplier applied to `wrong_direction_penalty` when the
84 /// [`TrafficDetector`] classifies the current tick as
85 /// [`TrafficMode::UpPeak`] or [`TrafficMode::DownPeak`].
86 ///
87 /// Default `1.0` (mode-agnostic — behaviour identical to pre-peak
88 /// tuning). Raising it strengthens directional commitment during
89 /// peaks where a car carrying a lobby-bound load shouldn't be
90 /// pulled backwards to grab a new pickup. Off-peak periods keep
91 /// the unscaled penalty, leaving inter-floor assignments free
92 /// to reverse cheaply.
93 ///
94 /// Silently reduces to `1.0` when no `TrafficDetector` resource
95 /// is installed — tests and custom sims that bypass the auto-install
96 /// stay unaffected.
97 pub peak_direction_multiplier: f64,
98}
99
100impl RsrDispatch {
101 /// Create a new `RsrDispatch` with the baseline weights
102 /// (`eta_weight = 1.0`, all penalties/bonuses disabled).
103 #[must_use]
104 pub const fn new() -> Self {
105 Self {
106 eta_weight: 1.0,
107 wrong_direction_penalty: 0.0,
108 coincident_car_call_bonus: 0.0,
109 load_penalty_coeff: 0.0,
110 peak_direction_multiplier: 1.0,
111 }
112 }
113
114 /// Set the wrong-direction penalty.
115 ///
116 /// # Panics
117 /// Panics on non-finite or negative weights — a negative penalty
118 /// would invert the direction ordering, silently preferring
119 /// wrong-direction candidates.
120 #[must_use]
121 pub fn with_wrong_direction_penalty(mut self, weight: f64) -> Self {
122 assert!(
123 weight.is_finite() && weight >= 0.0,
124 "wrong_direction_penalty must be finite and non-negative, got {weight}"
125 );
126 self.wrong_direction_penalty = weight;
127 self
128 }
129
130 /// Set the coincident-car-call bonus.
131 ///
132 /// # Panics
133 /// Panics on non-finite or negative weights — the bonus is
134 /// subtracted, so a negative value would become a penalty.
135 #[must_use]
136 pub fn with_coincident_car_call_bonus(mut self, weight: f64) -> Self {
137 assert!(
138 weight.is_finite() && weight >= 0.0,
139 "coincident_car_call_bonus must be finite and non-negative, got {weight}"
140 );
141 self.coincident_car_call_bonus = weight;
142 self
143 }
144
145 /// Set the load-penalty coefficient.
146 ///
147 /// # Panics
148 /// Panics on non-finite or negative weights.
149 #[must_use]
150 pub fn with_load_penalty_coeff(mut self, weight: f64) -> Self {
151 assert!(
152 weight.is_finite() && weight >= 0.0,
153 "load_penalty_coeff must be finite and non-negative, got {weight}"
154 );
155 self.load_penalty_coeff = weight;
156 self
157 }
158
159 /// Set the ETA weight.
160 ///
161 /// # Panics
162 /// Panics on non-finite or negative weights. Zero is allowed and
163 /// reduces the strategy to penalty/bonus tiebreaking alone.
164 #[must_use]
165 pub fn with_eta_weight(mut self, weight: f64) -> Self {
166 assert!(
167 weight.is_finite() && weight >= 0.0,
168 "eta_weight must be finite and non-negative, got {weight}"
169 );
170 self.eta_weight = weight;
171 self
172 }
173
174 /// Set the peak-direction multiplier.
175 ///
176 /// # Panics
177 /// Panics on non-finite or sub-1.0 values. A multiplier below `1.0`
178 /// would *weaken* the direction penalty during peaks (the opposite
179 /// of the intent) — explicitly disallowed so a typo doesn't silently
180 /// invert the tuning.
181 #[must_use]
182 pub fn with_peak_direction_multiplier(mut self, factor: f64) -> Self {
183 assert!(
184 factor.is_finite() && factor >= 1.0,
185 "peak_direction_multiplier must be finite and ≥ 1.0, got {factor}"
186 );
187 self.peak_direction_multiplier = factor;
188 self
189 }
190}
191
192impl Default for RsrDispatch {
193 fn default() -> Self {
194 Self::new()
195 }
196}
197
198impl DispatchStrategy for RsrDispatch {
199 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
200 if !pair_can_do_work(ctx) {
201 return None;
202 }
203 let car = ctx.world.elevator(ctx.car)?;
204
205 // ETA — travel time to the candidate stop.
206 let distance = (ctx.car_position - ctx.stop_position).abs();
207 let max_speed = car.max_speed.value();
208 if max_speed <= 0.0 {
209 return None;
210 }
211 let travel_time = distance / max_speed;
212 let mut cost = self.eta_weight * travel_time;
213
214 // Wrong-direction penalty. Only applies when the car has a
215 // committed direction (not Idle / Stopped) — an idle car can
216 // accept any candidate without "reversing" anything.
217 if self.wrong_direction_penalty > 0.0
218 && let Some(target) = car.phase.moving_target()
219 && let Some(target_pos) = ctx.world.stop_position(target)
220 {
221 let car_going_up = target_pos > ctx.car_position;
222 let car_going_down = target_pos < ctx.car_position;
223 let cand_above = ctx.stop_position > ctx.car_position;
224 let cand_below = ctx.stop_position < ctx.car_position;
225 if (car_going_up && cand_below) || (car_going_down && cand_above) {
226 // During up-peak/down-peak the directional invariant
227 // is load-bearing (a committed car shouldn't reverse
228 // to grab a new pickup), so scale the penalty up.
229 // Off-peak, the base value still rules — inter-floor
230 // traffic wants cheap reversals.
231 let scaled = self.wrong_direction_penalty
232 * peak_scaling(ctx, self.peak_direction_multiplier);
233 cost += scaled;
234 }
235 }
236
237 // Coincident-car-call bonus — the candidate stop is already a
238 // committed dropoff for this car.
239 if self.coincident_car_call_bonus > 0.0
240 && ctx
241 .manifest
242 .car_calls_for(ctx.car)
243 .iter()
244 .any(|c: &CarCall| c.floor == ctx.stop)
245 {
246 cost -= self.coincident_car_call_bonus;
247 }
248
249 // Smooth load-fraction penalty. `pair_can_do_work` has already
250 // filtered over-capacity and bypass-threshold cases; this term
251 // shapes preference among the survivors so emptier cars win
252 // pickups when all else is equal. Idle cars contribute zero.
253 if self.load_penalty_coeff > 0.0 && car.phase() != ElevatorPhase::Idle {
254 let capacity = car.weight_capacity().value();
255 if capacity > 0.0 {
256 let load_ratio = (car.current_load().value() / capacity).clamp(0.0, 1.0);
257 cost += self.load_penalty_coeff * load_ratio;
258 }
259 }
260
261 let cost = cost.max(0.0);
262 if cost.is_finite() { Some(cost) } else { None }
263 }
264}