elevator_core/dispatch/loop_schedule.rs
1//! `LoopSchedule` — fixed-dwell timetable for [`LineKind::Loop`] groups.
2//!
3//! Where [`crate::dispatch::LoopSweepDispatch`] lets each Loop car
4//! carry whatever per-car `door_open_ticks` the config specified
5//! (so dwell tracks the rider load at each stop), `LoopSchedule`
6//! overrides every Loop car in the group to a single
7//! `dwell_ticks` value. The resulting timetable is predictable —
8//! every car spends the same amount of time at every stop on every
9//! lap — which is what people-mover lines, gondolas, and timetabled
10//! shuttle services want.
11//!
12//! ## What this strategy does
13//!
14//! - **Fixed dwell**: every Loop car in the group has its
15//! `door_open_ticks` rewritten to the schedule's `dwell_ticks` once
16//! per pass via `pre_dispatch`. Idempotent — the same value is
17//! written unconditionally each tick, so re-applying the strategy
18//! leaves car state unchanged.
19//! - **Hold-recovery**: when a car arrives at a stop sooner than
20//! `target_headway_ticks` after the preceding car arrived at the
21//! same stop, the strategy issues a [`DoorCommand::HoldOpen`]
22//! extending the dwell by `min(target_headway_ticks - gap,
23//! hold_cap_ticks)`. This pushes the bunched follower back to its
24//! schedule slot rather than letting it tailgate the leader.
25//! - The cap prevents indefinite hold if the leader is stuck (e.g.
26//! stopped indefinitely for heavy boarding) — the follower waits
27//! at most `hold_cap_ticks` extra per stop, then resumes patrol.
28//! - Crucially, hold-recovery **never speeds a car up**: an
29//! early-arriving follower can only delay itself, never overtake.
30//! - A leader that runs late is not held — only followers running
31//! ahead of their schedule are held.
32//! - **Snapshot round-trip**: `builtin_id` returns
33//! [`BuiltinStrategy::LoopSchedule`] and `snapshot_config` /
34//! `restore_config` carry all three tunable fields. Per-pass
35//! bookkeeping (last-arrival ticks, in-loading set) is `#[serde(skip)]`
36//! — restored sims rebuild it on the first tick where each car next
37//! enters Loading.
38//!
39//! Bunching under heavy load is **largely** mitigated by hold-recovery
40//! but not eliminated: a leader that takes an unusually long time to
41//! board may exceed `hold_cap_ticks` of follower hold, and the
42//! follower then catches up before recovering. Tune `hold_cap_ticks`
43//! to the worst-case boarding burst your line expects.
44//!
45//! [`DoorCommand::HoldOpen`]: crate::door::DoorCommand::HoldOpen
46//! [`LineKind::Loop`]: crate::components::LineKind::Loop
47
48use std::collections::HashMap;
49
50use crate::components::ElevatorPhase;
51use crate::door::DoorCommand;
52use crate::entity::EntityId;
53use crate::world::World;
54
55use super::{BuiltinStrategy, DispatchManifest, DispatchStrategy, ElevatorGroup, RankContext};
56
57/// Default hold-recovery cap.
58///
59/// Picked to be comfortably below most real-world worst-case dwells
60/// while still letting the schedule recover from typical boarding
61/// excursions on a 60Hz sim. Hosts should tune via
62/// [`LoopScheduleDispatch::new`] for production scenarios.
63pub const DEFAULT_HOLD_CAP_TICKS: u32 = 120;
64
65/// Dispatch strategy that holds Loop cars to a uniform dwell at every
66/// stop, with hold-recovery to keep the timetable stable under load.
67///
68/// See the module-level documentation for the full contract. The
69/// tunable fields are exposed through accessors so hosts can inspect
70/// the schedule in-flight (HUDs, debuggers). The struct itself is
71/// immutable after construction — replace the active strategy via
72/// [`Simulation::set_dispatch`](crate::sim::Simulation::set_dispatch)
73/// with a freshly-built instance to retune live, or rely on
74/// [`restore_config`](DispatchStrategy::restore_config) on the snapshot
75/// path.
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
77pub struct LoopScheduleDispatch {
78 /// Target dwell at each stop, in ticks. Overrides every Loop car's
79 /// per-car `door_open_ticks` whenever this strategy is active on
80 /// the group.
81 dwell_ticks: u32,
82 /// Desired tick gap between consecutive cars arriving at the same
83 /// stop. A follower arriving below this gap holds its dwell to
84 /// recover. Set above expected loop transit time for the gap to be
85 /// reachable.
86 target_headway_ticks: u32,
87 /// Upper bound on the extra dwell hold-recovery applies per stop.
88 /// Without this cap, a stalled leader (e.g. heavy boarding well
89 /// beyond `dwell_ticks`) would freeze the follower indefinitely.
90 hold_cap_ticks: u32,
91 /// Per-stop tick of the most recent arrival, used to compute the
92 /// gap on the next arrival. Skipped in snapshot serialization —
93 /// restored sims rebuild the map on the first arrival at each stop
94 /// after restore. The first-arrival miss is a one-time cost
95 /// equivalent to one un-held tick per stop after restore.
96 ///
97 /// Keyed on the served stop's [`EntityId`]. Entries for stops
98 /// removed via [`Simulation::remove_stop`](crate::sim::Simulation::remove_stop)
99 /// remain in the map; this is safe because the entity allocator
100 /// uses generation counters under `slotmap`, so a removed
101 /// [`EntityId`] never re-points at a newly-spawned stop.
102 /// Long-running sims that churn stops will leak a few bytes per
103 /// retired stop — bounded and acceptable for v1.
104 #[serde(skip)]
105 last_arrival_tick: HashMap<EntityId, u64>,
106 /// Per-car `(last loading-pre-dispatch tick, last seen stop)`.
107 /// The fresh-arrival predicate fires whenever the recorded tick
108 /// is not the immediately preceding `pre_dispatch` tick *or* the
109 /// recorded stop changed. The tick-anchored half catches
110 /// same-stop re-arrivals on a tiny loop (car leaves S, runs once
111 /// around, returns to S); the stop-anchored half catches normal
112 /// arrival-at-the-next-stop transitions while the car was still
113 /// in Loading state on the previous pass (rare but legal under
114 /// long dwells).
115 #[serde(skip)]
116 seen: HashMap<EntityId, (u64, EntityId)>,
117}
118
119impl LoopScheduleDispatch {
120 /// Construct a `LoopScheduleDispatch` with explicit tunables.
121 ///
122 /// All three integer parameters are clamped to a minimum of `1`:
123 /// a zero dwell would collapse the door cycle into a no-op, a zero
124 /// headway would never trigger recovery, and a zero hold cap would
125 /// disable recovery entirely (use a small but positive value to
126 /// keep recovery active without unbounded waits).
127 #[must_use]
128 pub fn new(dwell_ticks: u32, target_headway_ticks: u32, hold_cap_ticks: u32) -> Self {
129 Self {
130 dwell_ticks: dwell_ticks.max(1),
131 target_headway_ticks: target_headway_ticks.max(1),
132 hold_cap_ticks: hold_cap_ticks.max(1),
133 last_arrival_tick: HashMap::new(),
134 seen: HashMap::new(),
135 }
136 }
137
138 /// Dwell at each stop, in ticks. See [`Self::new`] for the
139 /// invariants this guarantees.
140 #[must_use]
141 pub const fn dwell_ticks(&self) -> u32 {
142 self.dwell_ticks
143 }
144
145 /// Inspect the configured target headway. See the field doc on the
146 /// struct for the role this plays in recovery.
147 #[must_use]
148 pub const fn target_headway_ticks(&self) -> u32 {
149 self.target_headway_ticks
150 }
151
152 /// Inspect the configured hold cap. See the field doc on the struct
153 /// for why the cap matters.
154 #[must_use]
155 pub const fn hold_cap_ticks(&self) -> u32 {
156 self.hold_cap_ticks
157 }
158}
159
160impl Default for LoopScheduleDispatch {
161 /// Sensible defaults for a 60-tick-per-second sim: a 30-tick
162 /// (half-second) dwell, a 300-tick (5-second) headway target, and
163 /// a 120-tick (2-second) hold cap. Hosts should call [`Self::new`]
164 /// with values matched to their line geometry rather than relying
165 /// on these.
166 fn default() -> Self {
167 Self::new(30, 300, DEFAULT_HOLD_CAP_TICKS)
168 }
169}
170
171impl DispatchStrategy for LoopScheduleDispatch {
172 fn pre_dispatch(
173 &mut self,
174 group: &ElevatorGroup,
175 _manifest: &DispatchManifest,
176 world: &mut World,
177 ) {
178 // Tick fetched once up-front. `pre_dispatch` doesn't carry a
179 // tick parameter (the trait predates loop-aware strategies),
180 // so we read the `CurrentTick` resource the runtime keeps in
181 // sync. Missing the resource means we're being driven by tests
182 // that didn't seed it; hold `None` and skip the recovery path
183 // entirely. A `0` fallback would fabricate gaps against any
184 // prior arrival recorded at a non-zero tick.
185 let now = world
186 .resource::<crate::arrival_log::CurrentTick>()
187 .map(|ct| ct.0);
188
189 for line in group.lines() {
190 let line_eid = line.entity();
191 if !world
192 .line(line_eid)
193 .is_some_and(crate::components::Line::is_loop)
194 {
195 continue;
196 }
197 // Snapshot elevator entities up front so we can take a
198 // separate mutable borrow per car inside the loop without
199 // holding the immutable `&[EntityId]` from `line.elevators()`
200 // across mutable accesses to `world`.
201 let elevators: Vec<EntityId> = line.elevators().to_vec();
202
203 for eid in elevators {
204 // Dispatch-excluded cars (Manual / Inspection / OutOfService)
205 // are driven by the operator, not the schedule; the user's
206 // dwell setting must win over the strategy's override.
207 if world
208 .service_mode(eid)
209 .is_some_and(|m| m.is_dispatch_excluded())
210 {
211 continue;
212 }
213 let Some(car) = world.elevator(eid) else {
214 continue;
215 };
216 // Fixed-dwell override applied to every Loop car, every
217 // tick. Idempotent — the same value is written
218 // regardless of the car's current phase, so a car
219 // mid-cycle picks up the override on its next request.
220 let phase = car.phase;
221 let at_stop = car.target_stop;
222 {
223 // Re-acquire as `_mut` for the write, then drop
224 // immediately so the gap-recovery branch below can
225 // hold its own borrow.
226 if let Some(c) = world.elevator_mut(eid) {
227 c.door_open_ticks = self.dwell_ticks;
228 }
229 }
230
231 if !matches!(phase, ElevatorPhase::Loading) {
232 continue;
233 }
234 let Some(now) = now else { continue };
235 let Some(stop) = at_stop else { continue };
236
237 // Fresh-arrival predicate: the car was either not
238 // observed in Loading on the immediately preceding
239 // pre_dispatch tick, OR it's now at a different stop
240 // than it was on the previous Loading observation. The
241 // tick-anchored half catches same-stop re-arrivals
242 // (car runs a full lap on a tiny loop and returns to
243 // the same stop), the stop-anchored half catches the
244 // normal arrival-at-next-stop transition. Subsequent
245 // ticks where the car remains in Loading at the same
246 // stop update `seen` but don't re-issue HoldOpen.
247 let prev_seen = self.seen.insert(eid, (now, stop));
248 let is_fresh_arrival = match prev_seen {
249 None => true,
250 Some((prev_tick, prev_stop)) => prev_tick + 1 != now || prev_stop != stop,
251 };
252 if !is_fresh_arrival {
253 continue;
254 }
255
256 // Gap = how long since the previous arrival at this
257 // stop. The first arrival at a stop has no previous;
258 // there's nothing to recover *against*, so the dwell
259 // override is the only thing applied.
260 let Some(&prev) = self.last_arrival_tick.get(&stop) else {
261 self.last_arrival_tick.insert(stop, now);
262 continue;
263 };
264 self.last_arrival_tick.insert(stop, now);
265
266 let gap = now.saturating_sub(prev);
267 let target = u64::from(self.target_headway_ticks);
268 if gap >= target {
269 continue;
270 }
271 // Cars arriving below target headway extend their
272 // dwell by the gap deficit, capped to keep a stuck
273 // leader from freezing the follower indefinitely.
274 #[allow(
275 clippy::cast_possible_truncation,
276 reason = "deficit is bounded by target_headway_ticks (u32); truncation to u32 is exact"
277 )]
278 let deficit = (target - gap) as u32;
279 let extra = deficit.min(self.hold_cap_ticks);
280 if extra == 0 {
281 continue;
282 }
283 if let Some(c) = world.elevator_mut(eid) {
284 // Push directly to the per-car queue rather than
285 // routing through `Simulation::enqueue_door_command`
286 // — the strategy only has `&mut World`, not a sim
287 // handle, and `HoldOpen` is explicitly cumulative
288 // (collapsing adjacent dupes would silently drop a
289 // real recovery deficit). Honour the cap so the
290 // queue can't grow unbounded across a sustained
291 // bunching episode.
292 if c.door_command_queue.len() < crate::components::DOOR_COMMAND_QUEUE_CAP {
293 c.door_command_queue
294 .push(DoorCommand::HoldOpen { ticks: extra });
295 }
296 }
297 }
298 }
299 }
300
301 fn rank(&self, _ctx: &RankContext<'_>) -> Option<f64> {
302 // Loop cars are excluded from the Hungarian idle pool by
303 // `systems::dispatch::run`, so this method is unreachable in
304 // practice. Returning `None` keeps the contract conservative
305 // (a `Some(finite)` would have to invent a meaningless cost).
306 None
307 }
308
309 fn builtin_id(&self) -> Option<BuiltinStrategy> {
310 Some(BuiltinStrategy::LoopSchedule)
311 }
312
313 fn snapshot_config(&self) -> Option<String> {
314 // RON-serialize the tunable fields so snapshot round-trip
315 // preserves the schedule's identity. Without this, restoring a
316 // snapshot would call `LoopScheduleDispatch::default()` via
317 // `BuiltinStrategy::instantiate` and silently downgrade
318 // whatever the live sim configured.
319 ron::to_string(self).ok()
320 }
321
322 fn restore_config(&mut self, config: &str) -> Result<(), String> {
323 // A garbled config is a snapshot/version drift bug. Surface
324 // the parse error to the caller (the snapshot restore path)
325 // rather than swallow it — `WorldSnapshot::restore` propagates
326 // it back as the restore error so the caller sees a clear
327 // failure instead of a silently-defaulted strategy with
328 // observably different dwell timing.
329 let restored: Self = ron::from_str(config).map_err(|e| e.to_string())?;
330 *self = restored;
331 Ok(())
332 }
333
334 fn notify_removed(&mut self, elevator: EntityId) {
335 // Drop bookkeeping for removed elevators so the map doesn't
336 // grow without bound across long-running sims that swap cars
337 // in and out (e.g. test harnesses).
338 self.seen.remove(&elevator);
339 }
340}