aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
//! The route cross-section: the vertical profile along a flight plan —
//! where it climbs, cruises, and descends, and the estimated time and
//! altitude at each waypoint.
//!
//! This is what a "profile view" shows and what tells a briefing *which*
//! forecast hour and *which* altitudes to ask about along the route. It is
//! a no-wind, straight-line estimate from the operator's
//! [`aerocontext_core::AircraftProfile`] — **advisory**,
//! not a performance computation.

use std::time::Duration;

use aerocontext_core::{AircraftProfile, GeoPoint, geo};

use crate::flightplan::FlightPlan;

/// Flight phase at a point along the route.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FlightPhase {
    /// Climbing toward cruise.
    Climb,
    /// At cruise altitude.
    Cruise,
    /// Descending toward the destination.
    Descent,
}

/// One sampled point of the vertical profile.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct CrossSectionSample {
    /// Identifier when the sample sits on a named route waypoint.
    pub ident: Option<String>,
    /// Position of the sample.
    pub position: GeoPoint,
    /// Distance from the departure along the route, nautical miles.
    pub along_track_nm: f64,
    /// Estimated altitude here, feet MSL.
    pub altitude_ft: f64,
    /// Estimated time from the ETD to here.
    pub eta_from_etd: Duration,
    /// Flight phase here.
    pub phase: FlightPhase,
}

/// The vertical profile of a route plus its trip totals.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct CrossSection {
    /// One sample per route waypoint, departure first.
    pub samples: Vec<CrossSectionSample>,
    /// Total route distance, nautical miles.
    pub total_distance_nm: f64,
    /// Estimated time en route (taxi excluded).
    pub time_en_route: Duration,
    /// Estimated trip fuel including taxi and reserve, in the profile's
    /// fuel unit.
    pub fuel_required: f64,
    /// Whether [`Self::fuel_required`] fits within the profile's usable
    /// capacity.
    pub fuel_within_capacity: bool,
}

/// Failure building a cross-section.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CrossSectionError {
    /// Fewer than two route points: nothing to profile.
    #[error("a cross-section needs at least two route points")]
    TooFewPoints,
    /// No cruise altitude in the plan and none could be assumed.
    #[error("the flight plan carries no cruise altitude")]
    NoCruiseAltitude,
}

impl CrossSection {
    /// Build the cross-section for `plan` flown per `profile`. Cruise
    /// altitude comes from the plan; departure and destination are taken
    /// at sea level (field elevations are not in the `.fpl`), so the
    /// climb and descent ramps are conservative.
    pub fn build(plan: &FlightPlan, profile: &AircraftProfile) -> Result<Self, CrossSectionError> {
        if plan.route.len() < 2 {
            return Err(CrossSectionError::TooFewPoints);
        }
        let cruise_ft = f64::from(
            plan.cruise_altitude_ft
                .ok_or(CrossSectionError::NoCruiseAltitude)?,
        );

        // Cumulative distance at each waypoint.
        let mut cumulative = Vec::with_capacity(plan.route.len());
        let mut total = 0.0;
        cumulative.push(0.0);
        for leg in plan.route.windows(2) {
            total += geo::distance_nm(leg[0].position, leg[1].position);
            cumulative.push(total);
        }

        // Climb and descent are distance segments at the ends; cruise
        // fills the middle. If they would overlap on a short route, split
        // the route at its midpoint instead of cruising.
        let climb_dist = profile.climb_tas_kt * profile.climb_hours(0.0, cruise_ft);
        let descent_dist = profile.descent_tas_kt * profile.descent_hours(cruise_ft, 0.0);
        let (climb_dist, descent_dist) = if climb_dist + descent_dist > total && total > 0.0 {
            let scale = total / (climb_dist + descent_dist);
            (climb_dist * scale, descent_dist * scale)
        } else {
            (climb_dist, descent_dist)
        };
        let top_of_descent = (total - descent_dist).max(0.0);

        let descent_span = (total - top_of_descent).max(0.0);
        let samples = plan
            .route
            .iter()
            .zip(&cumulative)
            .map(|(wp, &along)| {
                let (altitude_ft, phase) =
                    altitude_at(along, climb_dist, top_of_descent, descent_span, cruise_ft);
                CrossSectionSample {
                    ident: Some(wp.identifier.clone()),
                    position: wp.position,
                    along_track_nm: along,
                    altitude_ft,
                    eta_from_etd: hours_to_duration(time_to(
                        along,
                        climb_dist,
                        top_of_descent,
                        total,
                        profile,
                    )),
                    phase,
                }
            })
            .collect();

        let ete_hours = time_to(total, climb_dist, top_of_descent, total, profile);
        let trip_fuel = profile.cruise_burn_per_hour * ete_hours;
        let fuel_required = trip_fuel + profile.taxi_fuel + profile.reserve_fuel();
        Ok(Self {
            samples,
            total_distance_nm: total,
            time_en_route: hours_to_duration(ete_hours),
            fuel_required,
            fuel_within_capacity: fuel_required <= profile.fuel_capacity,
        })
    }
}

/// Altitude and phase at `along` NM, given the climb distance, the
/// top-of-descent point, and the descent span beyond it.
fn altitude_at(
    along: f64,
    climb_dist: f64,
    top_of_descent: f64,
    descent_span: f64,
    cruise_ft: f64,
) -> (f64, FlightPhase) {
    if climb_dist > 0.0 && along < climb_dist {
        let frac = (along / climb_dist).clamp(0.0, 1.0);
        (cruise_ft * frac, FlightPhase::Climb)
    } else if descent_span > 0.0 && along > top_of_descent {
        let frac = ((along - top_of_descent) / descent_span).clamp(0.0, 1.0);
        (cruise_ft * (1.0 - frac), FlightPhase::Descent)
    } else {
        (cruise_ft, FlightPhase::Cruise)
    }
}

/// Hours from ETD to `along` NM: climb at climb TAS, cruise at cruise
/// TAS, descent at descent TAS.
fn time_to(
    along: f64,
    climb_dist: f64,
    top_of_descent: f64,
    total: f64,
    profile: &AircraftProfile,
) -> f64 {
    let climb_end = climb_dist.min(along);
    let climb_hours = safe_div(climb_end, profile.climb_tas_kt);
    let cruise_end = along.min(top_of_descent);
    let cruise_hours = safe_div((cruise_end - climb_dist).max(0.0), profile.cruise_tas_kt);
    let descent_end = along.min(total);
    let descent_hours = safe_div(
        (descent_end - top_of_descent).max(0.0),
        profile.descent_tas_kt,
    );
    climb_hours + cruise_hours + descent_hours
}

fn safe_div(distance: f64, speed_kt: f64) -> f64 {
    if speed_kt > 0.0 {
        distance / speed_kt
    } else {
        0.0
    }
}

fn hours_to_duration(hours: f64) -> Duration {
    Duration::from_secs_f64((hours.max(0.0)) * 3600.0)
}

#[cfg(test)]
mod tests;