Skip to main content

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}