Skip to main content

efb/route/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024, 2026 Joe Pearson
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16use std::fmt;
17use std::rc::Rc;
18
19use crate::error::Error;
20use crate::fp::Performance;
21use crate::measurements::Speed;
22use crate::nd::*;
23use crate::{VerticalDistance, Wind};
24
25mod accumulator;
26mod leg;
27mod profile;
28mod token;
29
30pub use accumulator::TotalsToLeg;
31pub use leg::Leg;
32pub use profile::{AirspaceIntersection, VerticalPoint, VerticalProfile};
33use token::Tokens;
34pub use token::{Token, TokenKind};
35
36/// A route that goes from an origin to a destination.
37///
38/// The route is composed of legs where each [`leg`] describes path between two
39/// [`fixes`].
40///
41/// # Decoding
42///
43/// The route can be decoded from a space separated list of fixes, wind values
44/// and performance elements. The route elements
45///
46/// ```text
47/// 13509KT N0107 EDDH D DCT W EDHL
48/// ```
49///
50/// would create a route from Hamburg to Luebeck via outbound delta routing and
51/// inbound whisky routing with a desired TAS of 107kt and a wind of 9kt from
52/// south-east. Performance elements can be add at any point but latest before
53/// the first leg is defined (we have from and to fix).
54///
55/// Thus, each leg is computed based on the latest performance elements defined
56/// on the route. Extending our route to
57///
58/// ```text
59/// 13509KT N0107 EDDH D DCT 18009KT DCT W EDHL
60/// ```
61///
62/// we would have wind from south-east (135°) on the leg from EDDH to D (VRP Delta), but
63/// the wind would turn to south (180°) for the remaining legs.
64///
65/// [`leg`]: Leg
66/// [`fixes`]: crate::nd::Fix
67#[derive(Clone, PartialEq, Debug, Default)]
68pub struct Route {
69    tokens: Tokens,
70    legs: Vec<Leg>,
71    speed: Option<Speed>,
72    level: Option<VerticalDistance>,
73    origin: Option<Rc<Airport>>,
74    takeoff_rwy: Option<Runway>,
75    destination: Option<Rc<Airport>>,
76    landing_rwy: Option<Runway>,
77    alternate: Option<NavAid>,
78}
79
80impl Route {
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Decodes a `route` that is composed of a space separated list of fix
86    /// idents read from the navigation data `nd`.
87    pub fn decode(&mut self, route: &str, nd: &NavigationData) -> Result<(), Error> {
88        self.tokens = Tokens::new(route, nd);
89        self.legs.clear();
90
91        // clear values relevant during parsing of all tokens
92        self.origin.take();
93        self.destination.take();
94        self.takeoff_rwy.take();
95        self.landing_rwy.take();
96
97        let mut level: Option<VerticalDistance> = None;
98        let mut tas: Option<Speed> = None;
99        let mut wind: Option<Wind> = None;
100        let mut from: Option<NavAid> = None;
101        let mut to: Option<NavAid> = None;
102
103        for token in &self.tokens {
104            match token.kind() {
105                TokenKind::Speed(value) => {
106                    tas = Some(*value);
107                    // first speed is cruise speed
108                    if self.speed.is_none() {
109                        self.speed = Some(*value);
110                    }
111                }
112
113                TokenKind::Level(value) => {
114                    level = Some(*value);
115                    // first level is cruise level
116                    if self.level.is_none() {
117                        self.level = Some(*value);
118                    }
119                }
120
121                TokenKind::Wind(value) => wind = Some(*value),
122
123                TokenKind::Airport { arpt, rwy } => {
124                    // Track for leg building
125                    if from.is_none() {
126                        from = Some(NavAid::Airport(Rc::clone(arpt)));
127                    } else if to.is_none() {
128                        to = Some(NavAid::Airport(Rc::clone(arpt)));
129                    }
130
131                    // First airport is origin, subsequent airports are destinations
132                    match &self.origin {
133                        None => {
134                            // First airport = origin with optional takeoff runway
135                            self.origin = Some(Rc::clone(arpt));
136                            self.takeoff_rwy = rwy.clone();
137                        }
138                        Some(_) => {
139                            // Any subsequent airport = destination with optional landing runway
140                            self.destination = Some(Rc::clone(arpt));
141                            self.landing_rwy = rwy.clone();
142                        }
143                    }
144                }
145
146                TokenKind::NavAid(navaid) => {
147                    // Non-airport navaids (waypoints, VOR, NDB, etc.)
148                    if from.is_none() {
149                        from = Some(navaid.clone());
150                    } else if to.is_none() {
151                        to = Some(navaid.clone());
152                    }
153                }
154
155                TokenKind::Err(err) => {
156                    return Err(err.clone());
157                }
158
159                _ => (),
160            }
161
162            match (&from, &to) {
163                (Some(from), Some(to)) => {
164                    self.legs
165                        .push(Leg::new(from.clone(), to.clone(), level, tas, wind));
166                }
167                _ => continue,
168            }
169
170            (from, to) = (to, None);
171        }
172
173        Ok(())
174    }
175
176    /// Returns the tokens used to build the route.
177    pub fn tokens(&self) -> &[Token] {
178        self.tokens.tokens()
179    }
180
181    /// Clears the route elements, legs and alternate.
182    pub fn clear(&mut self) {
183        self.tokens.clear();
184        self.legs.clear();
185        self.alternate.take();
186    }
187
188    /// Returns the legs of the route.
189    pub fn legs(&self) -> &[Leg] {
190        &self.legs
191    }
192
193    /// Sets the cruise speed and level.
194    ///
195    /// The cruise speed or level is remove from the route by setting it to
196    /// `None`.
197    pub fn set_cruise(&mut self, _speed: Option<Speed>, _level: Option<VerticalDistance>) {
198        todo!("Add/remove speed and level from the elements")
199    }
200
201    pub fn speed(&self) -> Option<Speed> {
202        self.speed
203    }
204
205    pub fn level(&self) -> Option<VerticalDistance> {
206        self.level
207    }
208
209    /// Sets an alternate on the route.
210    ///
211    /// The alternate is remove by setting it to `None`.
212    pub fn set_alternate(&mut self, alternate: Option<NavAid>) {
213        self.alternate = alternate;
214    }
215
216    /// Returns the final leg but going to the alternate.
217    pub fn alternate(&self) -> Option<Leg> {
218        let final_leg = self.legs.last()?.clone();
219        Some(Leg::new(
220            final_leg.from().clone(),
221            self.alternate.clone()?,
222            final_leg.level().copied(),
223            final_leg.tas().copied(),
224            final_leg.wind().copied(),
225        ))
226    }
227
228    /// Returns the origin airport if one is defined in the route.
229    pub fn origin(&self) -> Option<Rc<Airport>> {
230        self.origin.as_ref().map(Rc::clone)
231    }
232
233    /// Returns the takeoff runway if a defined in the route.
234    pub fn takeoff_rwy(&self) -> Option<&Runway> {
235        self.takeoff_rwy.as_ref()
236    }
237
238    /// Returns  the destination airport if one is defined in the route.
239    pub fn destination(&self) -> Option<Rc<Airport>> {
240        self.destination.as_ref().map(Rc::clone)
241    }
242
243    /// Returns the landing runway if a defined in the route.
244    pub fn landing_rwy(&self) -> Option<&Runway> {
245        self.landing_rwy.as_ref()
246    }
247
248    /// Returns an iterator that accumulates totals progressively through each
249    /// leg of the route.
250    ///
251    /// This function provides cumulative [totals] from the route start up to
252    /// each leg. Each yielded `TotalsToLeg` represents the accumulated totals
253    /// from the beginning of the route to that specific leg. If [`Some`]
254    /// performance is provided, the fuel will be accumulated too.
255    ///
256    /// # Examples
257    ///
258    /// ```rust
259    /// # use efb::route::Route;
260    /// # use efb::prelude::Performance;
261    /// # fn accumulate_legs(route: Route, perf: Performance) {
262    /// // Iterate through route showing progressive totals
263    /// for (i, totals) in route.accumulate_legs(Some(&perf)).enumerate() {
264    ///     println!("Leg {}: Total distance: {}, Total fuel: {:?}",
265    ///              i + 1, totals.dist(), totals.fuel());
266    /// }
267    /// # }
268    /// ```
269    ///
270    /// # Note
271    ///
272    /// If any leg in the sequence is missing ETE or fuel data, the cumulative ETE/fuel
273    /// will be `None` for that leg and all subsequent legs, following an "all-or-nothing"
274    /// approach to ensure data consistency.
275    ///
276    /// [totals]: `TotalsToLeg`
277    pub fn accumulate_legs<'a>(
278        &'a self,
279        perf: Option<&'a Performance>,
280    ) -> impl Iterator<Item = TotalsToLeg> + 'a {
281        self.legs
282            .iter()
283            .scan(None, move |totals_to_leg: &mut Option<TotalsToLeg>, leg| {
284                // accumulate totals from previous legs
285                *totals_to_leg = Some(match totals_to_leg.as_ref() {
286                    None => TotalsToLeg::new(leg, perf),
287                    Some(prev) => prev.accumulate(leg, perf),
288                });
289                // the totals up to this leg
290                *totals_to_leg
291            })
292    }
293
294    /// Returns the totals of the entire route.
295    pub fn totals(&self, perf: Option<&Performance>) -> Option<TotalsToLeg> {
296        self.accumulate_legs(perf).last()
297    }
298
299    /// Returns the vertical profile showing all airspace intersections along
300    /// this route.
301    ///
302    /// The profile contains entry and exit points for each airspace the route
303    /// passes through, sorted by distance from the route start.
304    ///
305    /// # Examples
306    ///
307    /// ```
308    /// # use efb::route::Route;
309    /// # use efb::nd::NavigationData;
310    /// # fn show_profile(route: &Route, nd: &NavigationData) {
311    /// let profile = route.vertical_profile(nd);
312    ///
313    /// for intersection in profile.intersections() {
314    ///     println!("{}: {:.1} NM to {:.1} NM",
315    ///         intersection.airspace().name,
316    ///         intersection.entry_distance().value(),
317    ///         intersection.exit_distance().value());
318    /// }
319    /// # }
320    /// ```
321    pub fn vertical_profile(&self, nd: &NavigationData) -> VerticalProfile {
322        VerticalProfile::new(self, nd)
323    }
324}
325
326impl fmt::Display for Route {
327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328        write!(f, "{}", self.tokens)
329    }
330}