Skip to main content

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}