1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
//! Regression tests for the commit-on-dispatch behaviour in
//! [`systems::dispatch`](crate::systems::dispatch).
//!
//! Shape: once a car enters `MovingToStop` toward a stop that still
//! has demand, the car is excluded from the Hungarian idle pool and
//! the stop is excluded from `pending_stops_minus_covered`. This
//! eliminates two classes of wasted motion surfaced by the
//! playground — mid-flight reassignment ping-pong and double
//! dispatch to the same hall call.
use crate::components::{ElevatorPhase, Weight};
use crate::dispatch::nearest_car::NearestCarDispatch;
use crate::sim::Simulation;
use crate::stop::StopId;
use super::helpers::{default_config, run_until_done};
/// Once car A is committed to stop X, a subsequent tick must not
/// re-assign a now-idle car B to the same stop — car A sees the
/// call through; car B is not pulled along for an empty touch-and-go.
#[test]
fn second_idle_car_not_double_dispatched_to_same_stop() {
let mut cfg = default_config();
// Two cars, both starting at the lobby.
cfg.elevators.push(crate::config::ElevatorConfig {
id: 1,
name: "Car 2".into(),
max_speed: cfg.elevators[0].max_speed,
acceleration: cfg.elevators[0].acceleration,
deceleration: cfg.elevators[0].deceleration,
weight_capacity: Weight::from(800.0),
starting_stop: StopId(0),
door_open_ticks: cfg.elevators[0].door_open_ticks,
door_transition_ticks: cfg.elevators[0].door_transition_ticks,
restricted_stops: Vec::new(),
#[cfg(feature = "energy")]
energy_profile: None,
service_mode: None,
inspection_speed_factor: 0.25,
bypass_load_up_pct: None,
bypass_load_down_pct: None,
});
let mut sim = Simulation::new(&cfg, NearestCarDispatch::new()).unwrap();
// One rider at stop 2 going to stop 0 — single hall call, one
// car is enough.
// Rider at stop 1 going UP to stop 2. Cars at stop 0 will go UP
// to fetch — same direction, so rider_can_board's direction
// filter doesn't reject the dispatch pair on arrival.
sim.spawn_rider(StopId(1), StopId(2), 70.0).unwrap();
let car_ids = sim.world().elevator_ids();
let car_a = car_ids[0];
let car_b = car_ids[1];
// Advance until the first car is en route. A couple of ticks is
// enough — ack latency defaults to 0, so dispatch fires on step 1.
for _ in 0..5 {
sim.step();
}
let a_moving = matches!(
sim.world()
.elevator(car_a)
.map_or(ElevatorPhase::Idle, crate::components::Elevator::phase),
ElevatorPhase::MovingToStop(_)
);
let b_moving = matches!(
sim.world()
.elevator(car_b)
.map_or(ElevatorPhase::Idle, crate::components::Elevator::phase),
ElevatorPhase::MovingToStop(_)
);
assert!(
a_moving ^ b_moving,
"exactly one car should be committed to the single call; got A_moving={a_moving} B_moving={b_moving}"
);
// Let the sim finish — delivery must still complete.
let drained = run_until_done(&mut sim, 20_000);
assert!(drained);
assert_eq!(sim.metrics().total_delivered(), 1);
}
/// A car committed to a stop with demand must *not* have its trip
/// canceled when another idle car becomes available on a later tick.
/// Regression for mid-flight reassignment ping-pong.
#[test]
fn committed_car_not_reassigned_mid_trip() {
let mut cfg = default_config();
cfg.elevators.push(crate::config::ElevatorConfig {
id: 1,
name: "Car 2".into(),
max_speed: cfg.elevators[0].max_speed,
acceleration: cfg.elevators[0].acceleration,
deceleration: cfg.elevators[0].deceleration,
weight_capacity: Weight::from(800.0),
starting_stop: StopId(0),
door_open_ticks: cfg.elevators[0].door_open_ticks,
door_transition_ticks: cfg.elevators[0].door_transition_ticks,
restricted_stops: Vec::new(),
#[cfg(feature = "energy")]
energy_profile: None,
service_mode: None,
inspection_speed_factor: 0.25,
bypass_load_up_pct: None,
bypass_load_down_pct: None,
});
let mut sim = Simulation::new(&cfg, NearestCarDispatch::new()).unwrap();
// Rider at stop 1 going UP to stop 2. Cars at stop 0 will go UP
// to fetch — same direction, so rider_can_board's direction
// filter doesn't reject the dispatch pair on arrival.
sim.spawn_rider(StopId(1), StopId(2), 70.0).unwrap();
let car_ids = sim.world().elevator_ids();
let [car_a, car_b] = [car_ids[0], car_ids[1]];
// Step until a car is MovingToStop.
let mut committed_car = None;
for _ in 0..50 {
sim.step();
for &c in &[car_a, car_b] {
if let Some(car) = sim.world().elevator(c)
&& matches!(car.phase(), ElevatorPhase::MovingToStop(_))
{
committed_car = Some((c, car.phase()));
break;
}
}
if committed_car.is_some() {
break;
}
}
let (c_id, c_phase) = committed_car.expect("one car must be MovingToStop after 50 ticks");
// Let several more dispatch cycles run; verify the committed car
// keeps the same target.
for _ in 0..20 {
sim.step();
if let Some(car) = sim.world().elevator(c_id)
&& !matches!(
car.phase(),
ElevatorPhase::MovingToStop(_)
| ElevatorPhase::DoorOpening
| ElevatorPhase::Loading
| ElevatorPhase::DoorClosing
| ElevatorPhase::Stopped
)
{
// Car transitioned to Idle mid-trip without reaching the
// stop — that's the reassignment ping-pong we're guarding
// against.
panic!(
"car {c_id:?} abandoned its MovingToStop trip mid-flight (phase {:?} after starting at {c_phase:?})",
car.phase()
);
}
}
}
/// Complement: a `MovingToStop` car whose target *loses demand* (no
/// rider anywhere heading there, no hall call) MUST be re-eligible
/// for Hungarian reassignment so its trip can be redirected to
/// something useful.
#[test]
fn car_reeligible_when_target_loses_demand() {
let mut sim = Simulation::new(&default_config(), NearestCarDispatch::new()).unwrap();
// Rider at stop 2 going to stop 0 — triggers dispatch of the single car.
// Rider at stop 1 going UP to stop 2. Cars at stop 0 will go UP
// to fetch — same direction, so rider_can_board's direction
// filter doesn't reject the dispatch pair on arrival.
sim.spawn_rider(StopId(1), StopId(2), 70.0).unwrap();
// Give the car a few ticks to start moving.
for _ in 0..5 {
sim.step();
}
// Sanity: the car is committed now.
let car_id = sim.world().elevator_ids()[0];
assert!(
matches!(
sim.world().elevator(car_id).unwrap().phase(),
ElevatorPhase::MovingToStop(_)
),
"car should be moving to stop 2"
);
// Now remove the rider mid-flight — simulating abandonment. The
// rider's exit clears the hall call, so the target loses demand.
// Use the public `despawn_rider` path so the rider index and
// hall-call pending_riders stay in sync.
let rider_id = sim.world().iter_riders().next().map(|(id, _)| id).unwrap();
sim.despawn_rider(crate::entity::RiderId(rider_id)).unwrap();
// Run a few more ticks. With demand gone, the car should become
// re-eligible: either re-routed, idled, or the trip simply
// completing to an empty stop. The key property is that the car
// *did* notice and did not just plow forward stuck in its
// original target forever.
for _ in 0..30 {
sim.step();
}
// No specific phase assertion — the fix guarantees re-eligibility
// (committed_pairs is empty, car enters idle_elevators on the
// next dispatch), not a particular outcome. As long as nothing
// panicked and no rider is stranded, we're good.
assert_eq!(sim.metrics().total_spawned(), 1);
assert_eq!(sim.metrics().total_delivered(), 0);
}