Skip to main content

aerocontext_planning/
crosssection.rs

1//! The route cross-section: the vertical profile along a flight plan —
2//! where it climbs, cruises, and descends, and the estimated time and
3//! altitude at each waypoint.
4//!
5//! This is what a "profile view" shows and what tells a briefing *which*
6//! forecast hour and *which* altitudes to ask about along the route. It is
7//! a no-wind, straight-line estimate from the operator's
8//! [`aerocontext_core::AircraftProfile`] — **advisory**,
9//! not a performance computation.
10
11use std::time::Duration;
12
13use aerocontext_core::{AircraftProfile, GeoPoint, geo};
14
15use crate::flightplan::FlightPlan;
16
17/// Flight phase at a point along the route.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum FlightPhase {
21    /// Climbing toward cruise.
22    Climb,
23    /// At cruise altitude.
24    Cruise,
25    /// Descending toward the destination.
26    Descent,
27}
28
29/// One sampled point of the vertical profile.
30#[derive(Debug, Clone, PartialEq)]
31#[non_exhaustive]
32pub struct CrossSectionSample {
33    /// Identifier when the sample sits on a named route waypoint.
34    pub ident: Option<String>,
35    /// Position of the sample.
36    pub position: GeoPoint,
37    /// Distance from the departure along the route, nautical miles.
38    pub along_track_nm: f64,
39    /// Estimated altitude here, feet MSL.
40    pub altitude_ft: f64,
41    /// Estimated time from the ETD to here.
42    pub eta_from_etd: Duration,
43    /// Flight phase here.
44    pub phase: FlightPhase,
45}
46
47/// The vertical profile of a route plus its trip totals.
48#[derive(Debug, Clone, PartialEq)]
49#[non_exhaustive]
50pub struct CrossSection {
51    /// One sample per route waypoint, departure first.
52    pub samples: Vec<CrossSectionSample>,
53    /// Total route distance, nautical miles.
54    pub total_distance_nm: f64,
55    /// Estimated time en route (taxi excluded).
56    pub time_en_route: Duration,
57    /// Estimated trip fuel including taxi and reserve, in the profile's
58    /// fuel unit.
59    pub fuel_required: f64,
60    /// Whether [`Self::fuel_required`] fits within the profile's usable
61    /// capacity.
62    pub fuel_within_capacity: bool,
63}
64
65/// Failure building a cross-section.
66#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum CrossSectionError {
69    /// Fewer than two route points: nothing to profile.
70    #[error("a cross-section needs at least two route points")]
71    TooFewPoints,
72    /// No cruise altitude in the plan and none could be assumed.
73    #[error("the flight plan carries no cruise altitude")]
74    NoCruiseAltitude,
75}
76
77impl CrossSection {
78    /// Build the cross-section for `plan` flown per `profile`. Cruise
79    /// altitude comes from the plan; departure and destination are taken
80    /// at sea level (field elevations are not in the `.fpl`), so the
81    /// climb and descent ramps are conservative.
82    pub fn build(plan: &FlightPlan, profile: &AircraftProfile) -> Result<Self, CrossSectionError> {
83        if plan.route.len() < 2 {
84            return Err(CrossSectionError::TooFewPoints);
85        }
86        let cruise_ft = f64::from(
87            plan.cruise_altitude_ft
88                .ok_or(CrossSectionError::NoCruiseAltitude)?,
89        );
90
91        // Cumulative distance at each waypoint.
92        let mut cumulative = Vec::with_capacity(plan.route.len());
93        let mut total = 0.0;
94        cumulative.push(0.0);
95        for leg in plan.route.windows(2) {
96            total += geo::distance_nm(leg[0].position, leg[1].position);
97            cumulative.push(total);
98        }
99
100        // Climb and descent are distance segments at the ends; cruise
101        // fills the middle. If they would overlap on a short route, split
102        // the route at its midpoint instead of cruising.
103        let climb_dist = profile.climb_tas_kt * profile.climb_hours(0.0, cruise_ft);
104        let descent_dist = profile.descent_tas_kt * profile.descent_hours(cruise_ft, 0.0);
105        let (climb_dist, descent_dist) = if climb_dist + descent_dist > total && total > 0.0 {
106            let scale = total / (climb_dist + descent_dist);
107            (climb_dist * scale, descent_dist * scale)
108        } else {
109            (climb_dist, descent_dist)
110        };
111        let top_of_descent = (total - descent_dist).max(0.0);
112
113        let descent_span = (total - top_of_descent).max(0.0);
114        let samples = plan
115            .route
116            .iter()
117            .zip(&cumulative)
118            .map(|(wp, &along)| {
119                let (altitude_ft, phase) =
120                    altitude_at(along, climb_dist, top_of_descent, descent_span, cruise_ft);
121                CrossSectionSample {
122                    ident: Some(wp.identifier.clone()),
123                    position: wp.position,
124                    along_track_nm: along,
125                    altitude_ft,
126                    eta_from_etd: hours_to_duration(time_to(
127                        along,
128                        climb_dist,
129                        top_of_descent,
130                        total,
131                        profile,
132                    )),
133                    phase,
134                }
135            })
136            .collect();
137
138        let ete_hours = time_to(total, climb_dist, top_of_descent, total, profile);
139        let trip_fuel = profile.cruise_burn_per_hour * ete_hours;
140        let fuel_required = trip_fuel + profile.taxi_fuel + profile.reserve_fuel();
141        Ok(Self {
142            samples,
143            total_distance_nm: total,
144            time_en_route: hours_to_duration(ete_hours),
145            fuel_required,
146            fuel_within_capacity: fuel_required <= profile.fuel_capacity,
147        })
148    }
149}
150
151/// Altitude and phase at `along` NM, given the climb distance, the
152/// top-of-descent point, and the descent span beyond it.
153fn altitude_at(
154    along: f64,
155    climb_dist: f64,
156    top_of_descent: f64,
157    descent_span: f64,
158    cruise_ft: f64,
159) -> (f64, FlightPhase) {
160    if climb_dist > 0.0 && along < climb_dist {
161        let frac = (along / climb_dist).clamp(0.0, 1.0);
162        (cruise_ft * frac, FlightPhase::Climb)
163    } else if descent_span > 0.0 && along > top_of_descent {
164        let frac = ((along - top_of_descent) / descent_span).clamp(0.0, 1.0);
165        (cruise_ft * (1.0 - frac), FlightPhase::Descent)
166    } else {
167        (cruise_ft, FlightPhase::Cruise)
168    }
169}
170
171/// Hours from ETD to `along` NM: climb at climb TAS, cruise at cruise
172/// TAS, descent at descent TAS.
173fn time_to(
174    along: f64,
175    climb_dist: f64,
176    top_of_descent: f64,
177    total: f64,
178    profile: &AircraftProfile,
179) -> f64 {
180    let climb_end = climb_dist.min(along);
181    let climb_hours = safe_div(climb_end, profile.climb_tas_kt);
182    let cruise_end = along.min(top_of_descent);
183    let cruise_hours = safe_div((cruise_end - climb_dist).max(0.0), profile.cruise_tas_kt);
184    let descent_end = along.min(total);
185    let descent_hours = safe_div(
186        (descent_end - top_of_descent).max(0.0),
187        profile.descent_tas_kt,
188    );
189    climb_hours + cruise_hours + descent_hours
190}
191
192fn safe_div(distance: f64, speed_kt: f64) -> f64 {
193    if speed_kt > 0.0 {
194        distance / speed_kt
195    } else {
196        0.0
197    }
198}
199
200fn hours_to_duration(hours: f64) -> Duration {
201    Duration::from_secs_f64((hours.max(0.0)) * 3600.0)
202}
203
204#[cfg(test)]
205mod tests;