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}