elevator_core/sim/eta.rs
1//! ETA queries (single-stop and best-of-group).
2//!
3//! Part of the [`super::Simulation`] API surface; extracted from the
4//! monolithic `sim.rs` for readability. See the parent module for the
5//! overarching essential-API summary.
6
7use crate::components::Velocity;
8use crate::entity::{ElevatorId, EntityId};
9use crate::error::EtaError;
10use crate::stop::StopRef;
11use std::time::Duration;
12
13impl super::Simulation {
14 // ── ETA queries ─────────────────────────────────────────────────
15
16 /// Estimated time until `elev` arrives at `stop`, summing closed-form
17 /// trapezoidal travel time for every leg up to (and including) the leg
18 /// that ends at `stop`, plus the door dwell at every *intermediate* stop.
19 ///
20 /// "Arrival" is the moment the door cycle begins at `stop` — door time
21 /// at `stop` itself is **not** added; door time at earlier stops along
22 /// the route **is**.
23 ///
24 /// # Errors
25 ///
26 /// - [`EtaError::NotAnElevator`] if `elev` is not an elevator entity.
27 /// - [`EtaError::NotAStop`] if `stop` is not a stop entity.
28 /// - [`EtaError::ServiceModeExcluded`] if the elevator's
29 /// [`ServiceMode`](crate::components::ServiceMode) is dispatch-excluded
30 /// (`Manual` / `Independent`).
31 /// - [`EtaError::StopNotQueued`] if `stop` is neither the elevator's
32 /// current movement target nor anywhere in its
33 /// [`destination_queue`](Self::destination_queue).
34 /// - [`EtaError::StopVanished`] if a stop in the route lost its position
35 /// during calculation.
36 ///
37 /// The estimate is best-effort. It assumes the queue is served in order
38 /// with no mid-trip insertions; dispatch decisions, manual door commands,
39 /// and rider boarding/exiting beyond the configured dwell will perturb
40 /// the actual arrival.
41 pub fn eta(&self, elev: ElevatorId, stop: EntityId) -> Result<Duration, EtaError> {
42 let elev = elev.entity();
43 let elevator = self
44 .world
45 .elevator(elev)
46 .ok_or(EtaError::NotAnElevator(elev))?;
47 self.world.stop(stop).ok_or(EtaError::NotAStop(stop))?;
48 let svc = self.world.service_mode(elev).copied().unwrap_or_default();
49 if svc.is_dispatch_excluded() {
50 return Err(EtaError::ServiceModeExcluded(elev));
51 }
52
53 // Build the route in service order: current target first (if any),
54 // then queue entries, with adjacent duplicates collapsed.
55 let mut route: Vec<EntityId> = Vec::new();
56 if let Some(t) = elevator.phase().moving_target() {
57 route.push(t);
58 }
59 if let Some(q) = self.world.destination_queue(elev) {
60 for &s in q.queue() {
61 if route.last() != Some(&s) {
62 route.push(s);
63 }
64 }
65 }
66 if !route.contains(&stop) {
67 return Err(EtaError::StopNotQueued {
68 elevator: elev,
69 stop,
70 });
71 }
72
73 let max_speed = elevator.max_speed().value();
74 let accel = elevator.acceleration().value();
75 let decel = elevator.deceleration().value();
76 let door_cycle_ticks =
77 u64::from(elevator.door_transition_ticks()) * 2 + u64::from(elevator.door_open_ticks());
78 let door_cycle_secs = (door_cycle_ticks as f64) * self.dt;
79
80 // Account for any in-progress door cycle before the first travel leg:
81 // the elevator is parked at its current stop and won't move until the
82 // door FSM returns to Closed.
83 let mut total = match elevator.door() {
84 crate::door::DoorState::Opening {
85 ticks_remaining,
86 open_duration,
87 close_duration,
88 } => f64::from(*ticks_remaining + *open_duration + *close_duration) * self.dt,
89 crate::door::DoorState::Open {
90 ticks_remaining,
91 close_duration,
92 } => f64::from(*ticks_remaining + *close_duration) * self.dt,
93 crate::door::DoorState::Closing { ticks_remaining } => {
94 f64::from(*ticks_remaining) * self.dt
95 }
96 crate::door::DoorState::Closed => 0.0,
97 };
98
99 let in_door_cycle = !matches!(elevator.door(), crate::door::DoorState::Closed);
100 let mut pos = self
101 .world
102 .position(elev)
103 .ok_or(EtaError::NotAnElevator(elev))?
104 .value;
105 let vel_signed = self.world.velocity(elev).map_or(0.0, Velocity::value);
106
107 for (idx, &s) in route.iter().enumerate() {
108 let s_pos = self
109 .world
110 .stop_position(s)
111 .ok_or(EtaError::StopVanished(s))?;
112 let dist = (s_pos - pos).abs();
113 // Only the first leg can carry initial velocity, and only if
114 // the car is already moving toward this stop and not stuck in
115 // a door cycle (which forces it to stop first).
116 let v0 = if idx == 0 && !in_door_cycle && vel_signed.abs() > f64::EPSILON {
117 let dir = (s_pos - pos).signum();
118 if dir * vel_signed > 0.0 {
119 vel_signed.abs()
120 } else {
121 0.0
122 }
123 } else {
124 0.0
125 };
126 total += crate::eta::travel_time(dist, v0, max_speed, accel, decel);
127 if s == stop {
128 return Ok(Duration::from_secs_f64(total.max(0.0)));
129 }
130 total += door_cycle_secs;
131 pos = s_pos;
132 }
133 // `route.contains(&stop)` was true above, so the loop must hit `stop`.
134 // Fall through as a defensive backstop.
135 Err(EtaError::StopNotQueued {
136 elevator: elev,
137 stop,
138 })
139 }
140
141 /// Best ETA to `stop` across all dispatch-eligible elevators, optionally
142 /// filtered by indicator-lamp [`Direction`](crate::components::Direction).
143 ///
144 /// Pass [`Direction::Either`](crate::components::Direction::Either) to
145 /// consider every car. Otherwise, only cars whose committed direction is
146 /// `Either` or matches the requested direction are considered — useful
147 /// for hall-call assignment ("which up-going car arrives first?").
148 ///
149 /// Returns the entity ID of the winning elevator and its ETA, or `None`
150 /// if no eligible car has `stop` queued.
151 #[must_use]
152 pub fn best_eta(
153 &self,
154 stop: impl Into<StopRef>,
155 direction: crate::components::Direction,
156 ) -> Option<(EntityId, Duration)> {
157 use crate::components::Direction;
158 let stop = self.resolve_stop(stop.into()).ok()?;
159 self.world
160 .iter_elevators()
161 .filter_map(|(eid, _, elev)| {
162 let car_dir = elev.direction();
163 let direction_ok = match direction {
164 Direction::Either => true,
165 requested => car_dir == Direction::Either || car_dir == requested,
166 };
167 if !direction_ok {
168 return None;
169 }
170 self.eta(ElevatorId::from(eid), stop).ok().map(|d| (eid, d))
171 })
172 .min_by_key(|(_, d)| *d)
173 }
174}