aerocontext_planning/
crosssection.rs1use std::time::Duration;
12
13use aerocontext_core::{AircraftProfile, GeoPoint, geo};
14
15use crate::flightplan::FlightPlan;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum FlightPhase {
21 Climb,
23 Cruise,
25 Descent,
27}
28
29#[derive(Debug, Clone, PartialEq)]
31#[non_exhaustive]
32pub struct CrossSectionSample {
33 pub ident: Option<String>,
35 pub position: GeoPoint,
37 pub along_track_nm: f64,
39 pub altitude_ft: f64,
41 pub eta_from_etd: Duration,
43 pub phase: FlightPhase,
45}
46
47#[derive(Debug, Clone, PartialEq)]
49#[non_exhaustive]
50pub struct CrossSection {
51 pub samples: Vec<CrossSectionSample>,
53 pub total_distance_nm: f64,
55 pub time_en_route: Duration,
57 pub fuel_required: f64,
60 pub fuel_within_capacity: bool,
63}
64
65#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum CrossSectionError {
69 #[error("a cross-section needs at least two route points")]
71 TooFewPoints,
72 #[error("the flight plan carries no cruise altitude")]
74 NoCruiseAltitude,
75}
76
77impl CrossSection {
78 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 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 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
151fn 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
171fn 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;