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 /// Desired tick gap between consecutive arrivals at the same stop.
146 #[must_use]
147 pub const fn target_headway_ticks(&self) -> u32 {
148 self.target_headway_ticks
149 }
150
151 /// Maximum extra dwell hold-recovery will apply per stop.
152 #[must_use]
153 pub const fn hold_cap_ticks(&self) -> u32 {
154 self.hold_cap_ticks
155 }
156}
157
158impl Default for LoopScheduleDispatch {
159 /// Sensible defaults for a 60-tick-per-second sim: a 30-tick
160 /// (half-second) dwell, a 300-tick (5-second) headway target, and
161 /// a 120-tick (2-second) hold cap. Hosts should call [`Self::new`]
162 /// with values matched to their line geometry rather than relying
163 /// on these.
164 fn default() -> Self {
165 Self::new(30, 300, DEFAULT_HOLD_CAP_TICKS)
166 }
167}
168
169impl DispatchStrategy for LoopScheduleDispatch {
170 fn pre_dispatch(
171 &mut self,
172 group: &ElevatorGroup,
173 _manifest: &DispatchManifest,
174 world: &mut World,
175 ) {
176 // Tick fetched once up-front. `pre_dispatch` doesn't carry a
177 // tick parameter (the trait predates loop-aware strategies),
178 // so we read the `CurrentTick` resource the runtime keeps in
179 // sync. Missing the resource means we're being driven by tests
180 // that didn't seed it; hold `None` and skip the recovery path
181 // entirely. A `0` fallback would fabricate gaps against any
182 // prior arrival recorded at a non-zero tick.
183 let now = world
184 .resource::<crate::arrival_log::CurrentTick>()
185 .map(|ct| ct.0);
186
187 for line in group.lines() {
188 let line_eid = line.entity();
189 if !world
190 .line(line_eid)
191 .is_some_and(crate::components::Line::is_loop)
192 {
193 continue;
194 }
195 // Snapshot elevator entities up front so we can take a
196 // separate mutable borrow per car inside the loop without
197 // holding the immutable `&[EntityId]` from `line.elevators()`
198 // across mutable accesses to `world`.
199 let elevators: Vec<EntityId> = line.elevators().to_vec();
200
201 for eid in elevators {
202 let Some(car) = world.elevator(eid) else {
203 continue;
204 };
205 // Fixed-dwell override applied to every Loop car, every
206 // tick. Idempotent — the same value is written
207 // regardless of the car's current phase, so a car
208 // mid-cycle picks up the override on its next request.
209 let phase = car.phase;
210 let at_stop = car.target_stop;
211 {
212 // Re-acquire as `_mut` for the write, then drop
213 // immediately so the gap-recovery branch below can
214 // hold its own borrow.
215 if let Some(c) = world.elevator_mut(eid) {
216 c.door_open_ticks = self.dwell_ticks;
217 }
218 }
219
220 if !matches!(phase, ElevatorPhase::Loading) {
221 continue;
222 }
223 let Some(now) = now else { continue };
224 let Some(stop) = at_stop else { continue };
225
226 // Fresh-arrival predicate: the car was either not
227 // observed in Loading on the immediately preceding
228 // pre_dispatch tick, OR it's now at a different stop
229 // than it was on the previous Loading observation. The
230 // tick-anchored half catches same-stop re-arrivals
231 // (car runs a full lap on a tiny loop and returns to
232 // the same stop), the stop-anchored half catches the
233 // normal arrival-at-next-stop transition. Subsequent
234 // ticks where the car remains in Loading at the same
235 // stop update `seen` but don't re-issue HoldOpen.
236 let prev_seen = self.seen.insert(eid, (now, stop));
237 let is_fresh_arrival = match prev_seen {
238 None => true,
239 Some((prev_tick, prev_stop)) => prev_tick + 1 != now || prev_stop != stop,
240 };
241 if !is_fresh_arrival {
242 continue;
243 }
244
245 // Gap = how long since the previous arrival at this
246 // stop. The first arrival at a stop has no previous;
247 // there's nothing to recover *against*, so the dwell
248 // override is the only thing applied.
249 let Some(&prev) = self.last_arrival_tick.get(&stop) else {
250 self.last_arrival_tick.insert(stop, now);
251 continue;
252 };
253 self.last_arrival_tick.insert(stop, now);
254
255 let gap = now.saturating_sub(prev);
256 let target = u64::from(self.target_headway_ticks);
257 if gap >= target {
258 continue;
259 }
260 // Cars arriving below target headway extend their
261 // dwell by the gap deficit, capped to keep a stuck
262 // leader from freezing the follower indefinitely.
263 #[allow(
264 clippy::cast_possible_truncation,
265 reason = "deficit is bounded by target_headway_ticks (u32); truncation to u32 is exact"
266 )]
267 let deficit = (target - gap) as u32;
268 let extra = deficit.min(self.hold_cap_ticks);
269 if extra == 0 {
270 continue;
271 }
272 if let Some(c) = world.elevator_mut(eid) {
273 // Push directly to the per-car queue rather than
274 // routing through `Simulation::enqueue_door_command`
275 // — the strategy only has `&mut World`, not a sim
276 // handle, and `HoldOpen` is explicitly cumulative
277 // (collapsing adjacent dupes would silently drop a
278 // real recovery deficit). Honour the cap so the
279 // queue can't grow unbounded across a sustained
280 // bunching episode.
281 if c.door_command_queue.len() < crate::components::DOOR_COMMAND_QUEUE_CAP {
282 c.door_command_queue
283 .push(DoorCommand::HoldOpen { ticks: extra });
284 }
285 }
286 }
287 }
288 }
289
290 fn rank(&self, _ctx: &RankContext<'_>) -> Option<f64> {
291 // Loop cars are excluded from the Hungarian idle pool by
292 // `systems::dispatch::run`, so this method is unreachable in
293 // practice. Returning `None` keeps the contract conservative
294 // (a `Some(finite)` would have to invent a meaningless cost).
295 None
296 }
297
298 fn builtin_id(&self) -> Option<BuiltinStrategy> {
299 Some(BuiltinStrategy::LoopSchedule)
300 }
301
302 fn snapshot_config(&self) -> Option<String> {
303 // RON-serialize the tunable fields so snapshot round-trip
304 // preserves the schedule's identity. Without this, restoring a
305 // snapshot would call `LoopScheduleDispatch::default()` via
306 // `BuiltinStrategy::instantiate` and silently downgrade
307 // whatever the live sim configured.
308 ron::to_string(self).ok()
309 }
310
311 fn restore_config(&mut self, config: &str) -> Result<(), String> {
312 // A garbled config is a snapshot/version drift bug. Surface
313 // the parse error to the caller (the snapshot restore path)
314 // rather than swallow it — `WorldSnapshot::restore` propagates
315 // it back as the restore error so the caller sees a clear
316 // failure instead of a silently-defaulted strategy with
317 // observably different dwell timing.
318 let restored: Self = ron::from_str(config).map_err(|e| e.to_string())?;
319 *self = restored;
320 Ok(())
321 }
322
323 fn notify_removed(&mut self, elevator: EntityId) {
324 // Drop bookkeeping for removed elevators so the map doesn't
325 // grow without bound across long-running sims that swap cars
326 // in and out (e.g. test harnesses).
327 self.seen.remove(&elevator);
328 }
329}