use std::time::Duration;
use aerocontext_core::{AircraftProfile, GeoPoint, geo};
use crate::flightplan::FlightPlan;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FlightPhase {
Climb,
Cruise,
Descent,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct CrossSectionSample {
pub ident: Option<String>,
pub position: GeoPoint,
pub along_track_nm: f64,
pub altitude_ft: f64,
pub eta_from_etd: Duration,
pub phase: FlightPhase,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct CrossSection {
pub samples: Vec<CrossSectionSample>,
pub total_distance_nm: f64,
pub time_en_route: Duration,
pub fuel_required: f64,
pub fuel_within_capacity: bool,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CrossSectionError {
#[error("a cross-section needs at least two route points")]
TooFewPoints,
#[error("the flight plan carries no cruise altitude")]
NoCruiseAltitude,
}
impl CrossSection {
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)?,
);
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);
}
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,
})
}
}
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)
}
}
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;