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}