#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::measurements::{Altitude, Duration, Length, Pressure, Speed, VerticalRate, Volume};
use crate::{Fuel, FuelFlow, FuelType, VerticalDistance};
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct ClimbDescentBand {
pub level: VerticalDistance,
pub tas: Speed,
pub vertical_rate: VerticalRate,
pub ff: FuelFlow,
}
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct CumulativeClimbDescentEntry {
pub level: VerticalDistance,
pub time: Duration,
pub fuel: Volume,
pub distance: Length,
}
#[derive(Clone, PartialEq, Debug, Default)]
pub struct ClimbDescentPerformance {
table: Vec<ClimbDescentBand>,
}
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct ClimbDescentResult {
pub time: Duration,
pub fuel: Fuel,
pub horizontal_distance: Length,
}
impl ClimbDescentResult {
pub fn with_wind(self, headwind: Speed) -> Self {
let wind_correction = headwind * self.time;
let ground_dist = self.horizontal_distance - wind_correction;
let ground_dist = if ground_dist < Length::nm(0.0) {
Length::nm(0.0)
} else {
ground_dist
};
Self {
horizontal_distance: ground_dist,
..self
}
}
}
fn to_altitude(vd: &VerticalDistance) -> Option<Altitude> {
vd.to_msl(Pressure::STD, Length::ft(0.0))
}
impl ClimbDescentPerformance {
pub fn new(table: Vec<ClimbDescentBand>) -> Self {
Self { table }
}
pub fn from_fn<F>(f: F, ceiling: VerticalDistance) -> Self
where
F: Fn(&VerticalDistance) -> (Speed, VerticalRate, FuelFlow),
{
let mut table: Vec<ClimbDescentBand> = Vec::new();
let mut vd = VerticalDistance::Gnd;
let mut alt = 0u16;
while vd <= ceiling {
let (tas, vertical_rate, ff) = f(&vd);
table.push(ClimbDescentBand {
level: vd,
tas,
vertical_rate,
ff,
});
alt += 1000;
vd = VerticalDistance::Altitude(alt);
}
Self { table }
}
pub fn from_cumulative(
entries: &[CumulativeClimbDescentEntry],
fuel_type: FuelType,
) -> Option<Self> {
if entries.len() < 2 {
return None;
}
let mut table: Vec<ClimbDescentBand> = Vec::with_capacity(entries.len() - 1);
for pair in entries.windows(2) {
let prev = &pair[0];
let cur = &pair[1];
let delta_alt = to_altitude(&cur.level)? - to_altitude(&prev.level)?;
let delta_time = cur.time - prev.time;
if *delta_time.value() == 0 {
return None;
}
let tas = (cur.distance - prev.distance) / delta_time;
let vertical_rate = delta_alt / delta_time;
let delta_fuel = cur.fuel - prev.fuel;
let ff = Fuel::from_volume(delta_fuel, fuel_type) / delta_time;
table.push(ClimbDescentBand {
level: cur.level,
tas,
vertical_rate,
ff,
});
}
Some(Self { table })
}
fn at_level(&self, level: &VerticalDistance) -> &ClimbDescentBand {
self.table
.iter()
.rfind(|row| &row.level <= level)
.expect("climb/descent performance table must not be empty")
}
pub fn between(
&self,
from_level: &VerticalDistance,
to_level: &VerticalDistance,
) -> Option<ClimbDescentResult> {
if from_level >= to_level || self.table.is_empty() {
return None;
}
let from_alt = to_altitude(from_level)?;
let to_alt = to_altitude(to_level)?;
let mut band_floor = from_alt;
let mut total_time = Duration::s(0);
let mut accumulated_fuel: Option<Fuel> = None;
let mut total_dist = Length::m(0.0);
let mut accumulate = |row: &ClimbDescentBand, delta_alt: Altitude| {
let time = delta_alt / row.vertical_rate;
let fuel = row.ff * time;
total_time = total_time + time;
accumulated_fuel = Some(match accumulated_fuel {
Some(f) => f + fuel,
None => fuel,
});
total_dist = total_dist + row.tas * time;
};
for row in &self.table {
let row_alt = to_altitude(&row.level)?;
if row_alt <= band_floor {
continue; }
let band_ceiling = if row_alt < to_alt { row_alt } else { to_alt };
let delta_alt = band_ceiling - band_floor;
accumulate(row, delta_alt);
band_floor = band_ceiling;
if band_floor >= to_alt {
break;
}
}
if band_floor < to_alt {
let row = self.at_level(to_level);
let delta_alt = to_alt - band_floor;
accumulate(row, delta_alt);
}
let fuel = accumulated_fuel?;
Some(ClimbDescentResult {
time: total_time,
fuel,
horizontal_distance: total_dist,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::measurements::{LengthUnit, SpeedUnit, VerticalRateUnit};
use crate::FuelType;
fn avgas_ff(lph: f32) -> FuelFlow {
FuelFlow::PerHour(avgas!(Volume::l(lph)))
}
fn simple_table() -> ClimbDescentPerformance {
ClimbDescentPerformance::new(vec![
ClimbDescentBand {
level: VerticalDistance::Altitude(2000),
tas: Speed::kt(80.0),
vertical_rate: VerticalRate::fpm(800.0),
ff: avgas_ff(20.0),
},
ClimbDescentBand {
level: VerticalDistance::Altitude(4000),
tas: Speed::kt(85.0),
vertical_rate: VerticalRate::fpm(600.0),
ff: avgas_ff(18.0),
},
])
}
#[test]
fn between_single_band() {
let perf = simple_table();
let result = perf
.between(&VerticalDistance::Gnd, &VerticalDistance::Altitude(2000))
.expect("should produce a result");
assert_eq!(*result.time.value(), 150, "time should be 150 s");
let dist_nm = *result
.horizontal_distance
.convert_to(LengthUnit::NauticalMiles)
.value();
assert!(
(dist_nm - 3.333).abs() < 0.05,
"distance ~3.33 NM, got {dist_nm}"
);
}
#[test]
fn between_two_bands() {
let perf = simple_table();
let result = perf
.between(&VerticalDistance::Gnd, &VerticalDistance::Altitude(4000))
.expect("should produce a result");
assert_eq!(*result.time.value(), 350, "time should be 350 s");
}
#[test]
fn from_to_equal_returns_none() {
let perf = simple_table();
let result = perf.between(
&VerticalDistance::Altitude(2000),
&VerticalDistance::Altitude(2000),
);
assert!(result.is_none());
}
#[test]
fn from_above_to_returns_none() {
let perf = simple_table();
let result = perf.between(
&VerticalDistance::Altitude(3000),
&VerticalDistance::Altitude(1000),
);
assert!(result.is_none());
}
#[test]
fn with_wind_reduces_distance_for_headwind() {
let perf = simple_table();
let result = perf
.between(&VerticalDistance::Gnd, &VerticalDistance::Altitude(2000))
.unwrap();
let no_wind_dist = *result
.horizontal_distance
.convert_to(LengthUnit::NauticalMiles)
.value();
let with_headwind = result.with_wind(Speed::kt(20.0));
let headwind_dist = *with_headwind
.horizontal_distance
.convert_to(LengthUnit::NauticalMiles)
.value();
assert!(
headwind_dist < no_wind_dist,
"headwind should reduce ground distance: {headwind_dist} >= {no_wind_dist}"
);
}
#[test]
fn from_fn_builds_table() {
let ff = avgas_ff(20.0);
let perf = ClimbDescentPerformance::from_fn(
|_| (Speed::kt(80.0), VerticalRate::fpm(700.0), ff),
VerticalDistance::Altitude(4000),
);
assert_eq!(perf.table.len(), 5);
}
fn pa28_cumulative_entries() -> Vec<CumulativeClimbDescentEntry> {
vec![
CumulativeClimbDescentEntry {
level: VerticalDistance::Gnd,
time: Duration::m(0),
fuel: Volume::gal(0.0),
distance: Length::nm(0.0),
},
CumulativeClimbDescentEntry {
level: VerticalDistance::PressureAltitude(2_000),
time: Duration::m(4),
fuel: Volume::gal(0.9),
distance: Length::nm(5.0),
},
CumulativeClimbDescentEntry {
level: VerticalDistance::PressureAltitude(4_000),
time: Duration::m(8),
fuel: Volume::gal(1.8),
distance: Length::nm(11.0),
},
CumulativeClimbDescentEntry {
level: VerticalDistance::PressureAltitude(6_000),
time: Duration::m(13),
fuel: Volume::gal(2.9),
distance: Length::nm(18.0),
},
CumulativeClimbDescentEntry {
level: VerticalDistance::PressureAltitude(8_000),
time: Duration::m(18),
fuel: Volume::gal(4.1),
distance: Length::nm(27.0),
},
]
}
#[test]
fn from_cumulative_builds_correct_table_size() {
let entries = pa28_cumulative_entries();
let perf = ClimbDescentPerformance::from_cumulative(&entries, FuelType::AvGas)
.expect("valid table");
assert_eq!(perf.table.len(), 4);
}
#[test]
fn from_cumulative_derives_correct_vertical_rate() {
let entries = pa28_cumulative_entries();
let perf = ClimbDescentPerformance::from_cumulative(&entries, FuelType::AvGas).unwrap();
let row0 = &perf.table[0];
let roc_fpm = *row0
.vertical_rate
.convert_to(VerticalRateUnit::FeetPerMinute)
.value();
assert!(
(roc_fpm - 500.0).abs() < 1.0,
"first band RoC should be ~500 fpm, got {roc_fpm}"
);
let row1 = &perf.table[1];
let roc_fpm = *row1
.vertical_rate
.convert_to(VerticalRateUnit::FeetPerMinute)
.value();
assert!(
(roc_fpm - 500.0).abs() < 1.0,
"second band RoC should be ~500 fpm, got {roc_fpm}"
);
let row2 = &perf.table[2];
let roc_fpm = *row2
.vertical_rate
.convert_to(VerticalRateUnit::FeetPerMinute)
.value();
assert!(
(roc_fpm - 400.0).abs() < 1.0,
"third band RoC should be ~400 fpm, got {roc_fpm}"
);
}
#[test]
fn from_cumulative_derives_correct_tas() {
let entries = pa28_cumulative_entries();
let perf = ClimbDescentPerformance::from_cumulative(&entries, FuelType::AvGas).unwrap();
let row0 = &perf.table[0];
let tas_kt = *row0.tas.convert_to(SpeedUnit::Knots).value();
assert!(
(tas_kt - 75.0).abs() < 0.5,
"first band TAS should be ~75 kt, got {tas_kt}"
);
let row2 = &perf.table[2];
let tas_kt = *row2.tas.convert_to(SpeedUnit::Knots).value();
assert!(
(tas_kt - 84.0).abs() < 0.5,
"third band TAS should be ~84 kt, got {tas_kt}"
);
}
#[test]
fn from_cumulative_matches_poh_totals() {
let entries = pa28_cumulative_entries();
let perf = ClimbDescentPerformance::from_cumulative(&entries, FuelType::AvGas).unwrap();
let result = perf
.between(
&VerticalDistance::Gnd,
&VerticalDistance::PressureAltitude(8_000),
)
.expect("should produce a result");
let time_s = *result.time.value();
assert!(
(time_s as f32 - 1080.0).abs() < 30.0,
"total time should be ~1080 s, got {time_s}"
);
let dist_nm = *result
.horizontal_distance
.convert_to(LengthUnit::NauticalMiles)
.value();
assert!(
(dist_nm - 27.0).abs() < 1.0,
"total distance should be ~27 NM, got {dist_nm}"
);
}
#[test]
fn from_cumulative_partial_climb() {
let entries = pa28_cumulative_entries();
let perf = ClimbDescentPerformance::from_cumulative(&entries, FuelType::AvGas).unwrap();
let result = perf
.between(
&VerticalDistance::Gnd,
&VerticalDistance::PressureAltitude(4_000),
)
.expect("should produce a result");
let time_s = *result.time.value();
assert!(
(time_s as f32 - 480.0).abs() < 10.0,
"time to 4000 ft should be ~480 s, got {time_s}"
);
}
#[test]
fn from_cumulative_too_few_entries_returns_none() {
let entries = [CumulativeClimbDescentEntry {
level: VerticalDistance::Gnd,
time: Duration::m(0),
fuel: Volume::gal(0.0),
distance: Length::nm(0.0),
}];
assert!(ClimbDescentPerformance::from_cumulative(&entries, FuelType::AvGas).is_none());
}
#[test]
fn from_cumulative_empty_returns_none() {
assert!(ClimbDescentPerformance::from_cumulative(&[], FuelType::AvGas).is_none());
}
}