Skip to main content

elevator_core/dispatch/
manifest.rs

1//! Per-tick demand picture handed to dispatch strategies.
2//!
3//! [`DispatchManifest`] is the read-only view of waiting riders, in-transit
4//! riders, hall calls, car calls, and rolling arrival counts that
5//! [`DispatchStrategy::rank`](crate::dispatch::DispatchStrategy::rank) uses
6//! to score `(car, stop)` pairs.
7//! It's built once per group per tick by `systems::dispatch::build_manifest`
8//! and discarded after the assignment pass.
9
10use std::collections::BTreeMap;
11
12use crate::components::{CallDirection, CarCall, HallCall, Weight};
13use crate::entity::EntityId;
14
15/// Metadata about a single rider, available to dispatch strategies.
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct RiderInfo {
19    /// Rider entity ID.
20    pub id: EntityId,
21    /// Rider's destination stop entity (from route).
22    pub destination: Option<EntityId>,
23    /// Rider weight.
24    pub weight: Weight,
25    /// Ticks this rider has been waiting (0 if riding).
26    pub wait_ticks: u64,
27}
28
29/// Full demand picture for dispatch decisions.
30///
31/// Contains per-rider metadata grouped by stop, enabling entity-aware
32/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
33///
34/// Uses `BTreeMap` for deterministic iteration order.
35#[derive(Debug, Clone, Default)]
36pub struct DispatchManifest {
37    /// Riders waiting at each stop, with full per-rider metadata.
38    pub(crate) waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
39    /// Riders currently aboard elevators, grouped by their destination stop.
40    pub(crate) riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
41    /// Number of residents at each stop (read-only hint for dispatch strategies).
42    pub(crate) resident_count_at_stop: BTreeMap<EntityId, usize>,
43    /// Pending hall calls at each stop — at most two entries per stop
44    /// (one per [`CallDirection`]). Populated only for stops served by
45    /// the group being dispatched. Strategies read this to rank based on
46    /// call age, pending-rider count, pin flags, or DCS destinations.
47    pub(crate) hall_calls_at_stop: BTreeMap<EntityId, Vec<HallCall>>,
48    /// Floor buttons pressed inside each car in the group. Keyed by car
49    /// entity. Strategies read this to plan intermediate stops without
50    /// poking into `World` directly.
51    pub(crate) car_calls_by_car: BTreeMap<EntityId, Vec<CarCall>>,
52    /// Recent arrivals per stop, counted over
53    /// [`DispatchManifest::arrival_window_ticks`] ticks. Populated from
54    /// the [`crate::arrival_log::ArrivalLog`] world resource each pass
55    /// so strategies can read a traffic-rate signal without touching
56    /// world state directly.
57    pub(crate) arrivals_at_stop: BTreeMap<EntityId, u64>,
58    /// Window the `arrivals_at_stop` counts cover, in ticks. Exposed so
59    /// strategies interpreting the raw counts can convert them to a
60    /// rate (per tick or per second).
61    pub(crate) arrival_window_ticks: u64,
62}
63
64impl DispatchManifest {
65    /// Number of riders waiting at a stop.
66    #[must_use]
67    pub fn waiting_count_at(&self, stop: EntityId) -> usize {
68        self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
69    }
70
71    /// Total weight of riders waiting at a stop.
72    #[must_use]
73    pub fn total_weight_at(&self, stop: EntityId) -> f64 {
74        self.waiting_at_stop
75            .get(&stop)
76            .map_or(0.0, |riders| riders.iter().map(|r| r.weight.value()).sum())
77    }
78
79    /// Number of riders heading to a stop (aboard elevators).
80    #[must_use]
81    pub fn riding_count_to(&self, stop: EntityId) -> usize {
82        self.riding_to_stop.get(&stop).map_or(0, Vec::len)
83    }
84
85    /// Whether a stop has any demand for this group: waiting riders,
86    /// riders heading there, or a *rider-less* hall call (one that
87    /// `press_hall_button` placed without a backing rider). Pre-fix
88    /// the rider-less case was invisible to every built-in dispatcher,
89    /// so explicit button presses with no associated rider went
90    /// unanswered indefinitely (#255).
91    ///
92    /// Hall calls *with* `pending_riders` are not double-counted —
93    /// those riders already appear in `waiting_count_at` for the
94    /// groups whose dispatch surface they belong to. Adding the call
95    /// to `has_demand` for *every* group that serves the stop would
96    /// pull cars from groups the rider doesn't even want, causing
97    /// open/close oscillation regression that the multi-group test
98    /// `dispatch_ignores_waiting_rider_targeting_another_group` pins.
99    #[must_use]
100    pub fn has_demand(&self, stop: EntityId) -> bool {
101        self.waiting_count_at(stop) > 0
102            || self.riding_count_to(stop) > 0
103            || self
104                .hall_calls_at_stop
105                .get(&stop)
106                .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
107    }
108
109    /// Number of residents at a stop (read-only hint, not active demand).
110    #[must_use]
111    pub fn resident_count_at(&self, stop: EntityId) -> usize {
112        self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
113    }
114
115    /// Rider arrivals at `stop` within the last
116    /// [`arrival_window_ticks`](Self::arrival_window_ticks) ticks. The
117    /// signal is the rolling-window per-stop arrival rate that
118    /// commercial controllers use to pick a traffic mode and that
119    /// [`crate::dispatch::reposition::PredictiveParking`] uses to
120    /// forecast demand. Unvisited stops return 0.
121    #[must_use]
122    pub fn arrivals_at(&self, stop: EntityId) -> u64 {
123        self.arrivals_at_stop.get(&stop).copied().unwrap_or(0)
124    }
125
126    /// Window size (in ticks) over which [`arrivals_at`](Self::arrivals_at)
127    /// counts events. Strategies convert counts to rates by dividing
128    /// by this.
129    #[must_use]
130    pub const fn arrival_window_ticks(&self) -> u64 {
131        self.arrival_window_ticks
132    }
133
134    /// The hall call at `(stop, direction)`, if pressed.
135    #[must_use]
136    pub fn hall_call_at(&self, stop: EntityId, direction: CallDirection) -> Option<&HallCall> {
137        self.hall_calls_at_stop
138            .get(&stop)?
139            .iter()
140            .find(|c| c.direction == direction)
141    }
142
143    /// All hall calls across every stop in the group (flattened iterator).
144    ///
145    /// No `#[must_use]` needed: `impl Iterator` already carries that
146    /// annotation, and adding our own triggers clippy's
147    /// `double_must_use` lint.
148    pub fn iter_hall_calls(&self) -> impl Iterator<Item = &HallCall> {
149        self.hall_calls_at_stop.values().flatten()
150    }
151
152    /// Floor buttons currently pressed inside `car`. Empty slice if the
153    /// car has no aboard riders or no outstanding presses.
154    #[must_use]
155    pub fn car_calls_for(&self, car: EntityId) -> &[CarCall] {
156        self.car_calls_by_car.get(&car).map_or(&[], Vec::as_slice)
157    }
158
159    /// Riders waiting at a specific stop.
160    #[must_use]
161    pub fn waiting_riders_at(&self, stop: EntityId) -> &[RiderInfo] {
162        self.waiting_at_stop.get(&stop).map_or(&[], Vec::as_slice)
163    }
164
165    /// Iterate over all `(stop, riders)` pairs with waiting demand.
166    pub fn iter_waiting_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
167        self.waiting_at_stop
168            .iter()
169            .map(|(stop, riders)| (stop, riders.as_slice()))
170    }
171
172    /// Riders currently riding toward a specific stop.
173    #[must_use]
174    pub fn riding_riders_to(&self, stop: EntityId) -> &[RiderInfo] {
175        self.riding_to_stop.get(&stop).map_or(&[], Vec::as_slice)
176    }
177
178    /// Iterate over all `(stop, riders)` pairs with in-transit demand.
179    pub fn iter_riding_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
180        self.riding_to_stop
181            .iter()
182            .map(|(stop, riders)| (stop, riders.as_slice()))
183    }
184
185    /// Iterate over all `(stop, hall_calls)` pairs with active calls.
186    pub fn iter_hall_call_stops(&self) -> impl Iterator<Item = (&EntityId, &[HallCall])> {
187        self.hall_calls_at_stop
188            .iter()
189            .map(|(stop, calls)| (stop, calls.as_slice()))
190    }
191}