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    /// 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}