Even as a student pilot, I’m aware that the official performance tables for an aircraft are given in the Pilot Operating Handbook specific to a given aircraft, not just by make and model, but to the aircraft itself.
I extracted performance numbers from an unofficial source (but with aircraft serial number ranges that covered my training aircraft).
I provide the following code for instructional purposes – to document my approach and thinking, and so that others who are so inclined can do the same, using their own sourced data. But please do not endanger yourself.
There will be minimal explanation below.
Source CSV files
I can NOT take responsibility for the contents of these performance tables. There may be errors, or they may not be appropriate to your specific aircraft. They are included here for educational purposes only.
fixed fractional modification by change from ISA temperature (not modeled here)
Numeric output:
climb time
fuel used
distnace
All are cumulative values starting from sea level pressure altitude.
Predictive models:
stats::approxfun linear interpolation, because only a single predictor
Plot shows point-to-point connections via approxfun
This isn’t really predictive modeling at all, but just straight interpolation between points.
Code
##### Climb time, fuel, and distance ###### - p_alt is the only predictor# - use approxfun for all columnsdf_climbdist <-read_csv("climb_dist.csv", col_types ="d") |>pivot_longer(cols =-p_alt,names_to ="metric" )approxfun_climbdist <-map(split(df_climbdist, df_climbdist$metric), function(df) { approxfun(x = df$p_alt, y = df$value) })climbdist <-function(altitudes) {map(approxfun_climbdist, function(f) {f(altitudes)}) |>as_tibble() |>transmute(p_alt = altitudes, temp, speed, rate, cum_time, cum_fuel, cum_dist)}# climbdist(seq(0, 12000, 1000)) == read_csv("climb_dist.csv", col_types = "d") # Confirm perfect match to original# climbdist(c(1000, 3000))$cum_fuel |> diff() # example from one altitude to anotherg <- df_climbdist |>ggplot(aes(p_alt, value, color = metric)) +geom_point() +geom_line() +theme_bw() +facet_wrap(~metric, scales ="free_y")ggplotly(g)
Cruise performance
These were the most complex performance tables, with more predictors and more data to use.
Furthermore, close inspection of plots of the original data showed kinks in the POH data that lacked face validity and likely reflected measurement or rounding discrepancies.
At first, I tried to faithfully reproduce these irregularities in the POH performance data, with a rather complex 3-nearest neighbor not forming a degenerate triangle interpolating tiling predictive model, and it did “work” by completely memorizing the original performance data and interpolating between closest points. But, in the end, the kinks did not make physical or aerodynamic sense, and I ended up using a gam model.
(In fact, I compared linear, polynomial, generalized additive, and my bizarre nearest-neighbor interpolated models. I also performed leave-one-out-cross-validation on the gam model to try to get a sense of expected errors, as well as confirming sensible interpolation between the minimal 3 temperature values available.)
Continuous numeric predictors:
pressure altitude (6 levels)
engine RPM (6 to 7 levels for each pressure altitude, not fully overlapping)
temperature (only 3 levels)
Numeric output:
% maximum cruise power (% MCP)
knots true airspeed (KTAS)
gallons per hour fuel use (GPH)
Not modeled here: speed fairings
Predictive models:
mgcv::gam generalized additive model performed most acceptably to me
Comparison of linear, polynomial, and generalized additive predictive models
Just showing adjusted R2. Not included here are LOOCV error calculations for gam model or the weird nearest neighbor triangular interpolation.
Code
df_cruise <-read_csv("cruise.csv", col_types ="d")outcomes <-c("pwr", "ktas", "gph")outcome_labels <-c("pwr"="% max cruise power","ktas"="TAS (knots)","gph"="Gallons per hour")temp_labels <-c("-20"="20°C below standard","0"="Standard temperature","20"="20°C above standard")# Color palette: choose darkest 6 colors in "Blues"pal <- RColorBrewer::brewer.pal(n =9, "Blues")[4:9] # has 9 colors, drop the first 3 lighter ones##### Compare adjusted R-squared performance: lm, lm with polynomial terms, and gam ###### - using full training set, so not as good for assessing prediction on new datagam_models <-list() # save all the gam models for future usedf_rsq <-data.frame() # store the adjusted R-squared values for comparisonfor (outcome in outcomes) {# pwr, ktas, gph lm_fit <-lm(as.formula(paste0(outcome, " ~ alt + rpm + temp")), data = df_cruise) poly_fit <-lm(as.formula(paste0( outcome," ~ poly(alt, 2) + poly(rpm, 2) + poly(temp, 2)" )),data = df_cruise ) gam_fit <-gam(as.formula(paste0(outcome, " ~ s(alt, rpm, temp)")),bs ="gp",# bs = "tp", # doesn't perform betterfamily =gaussian(),data = df_cruise ) gam_models[[outcome]] <- gam_fit df_rsq <-rbind( df_rsq,data.frame(outcome = outcome,lm =summary(lm_fit)$adj.r.squared,poly =summary(poly_fit)$adj.r.squared,gam =summary(gam_fit)$r.sq ) )}df_rsq |>mutate_if(is.numeric, round, 4) |> knitr::kable()
outcome
lm
poly
gam
pwr
0.9848
0.9850
0.9993
ktas
0.9930
0.9931
0.9989
gph
0.9872
0.9871
0.9994
Plot shows overlay of original and gam model predictions
Good agreement. KTAS has the most weird kinks, which are smoothed out and non-intersecting in the gam model, which is preferable to me.
Next plot zooms in on an example problem area, but these plots are all ggplotly interactable, so you can select the weird areas to zoom in on yourself.
Code
# GAM final model test# - overlay facet_grid of original POH and gam model-predicted# - generate dataframe with both orig POH as well as pred from GAMdf_gam_test <- df_cruise |>mutate(model ="orig") |>pivot_longer(cols = pwr:gph, names_to ="outcome", values_to ="value") |>bind_rows( df_cruise |>transmute(alt, rpm, temp, model ="pred") |>bind_cols(map_dfc(gam_models, predict, df_cruise |>select(alt, rpm, temp))) |>pivot_longer(cols = pwr:gph, names_to ="outcome", values_to ="value") ) |>arrange(alt, rpm)# Generate grid plot with overlaid orig and pred linesg <- df_gam_test |>mutate(alt =factor(alt, ordered =TRUE)) |>ggplot(aes(rpm, value, color = alt, linetype = model)) +# geom_point() +geom_line() +facet_grid(rows =vars(outcome),cols =vars(temp),scale ="free_y",labeller =labeller(outcome = outcome_labels, temp = temp_labels) ) +theme_bw() +labs(# title = "Cruise Performance: C172S NAV III / KAP 140",x ="RPM",y ="" ) +scale_color_manual("Pressure altitude (ft)", values =rev(pal)) +scale_linetype_manual(values =c("orig"="solid", "pred"="longdash")) +guides(color ="none")ggplotly(g)
Crossing lines in original data
Occur with true air speed (TAS) and only at the extremes. Seem very unlikely to be true. More likely the result of rounding errors and small errors of measurement. Example shown here, although previous plots are interactive and can be examined as well.
At first, I perfectly modeled these discontinuities with the complex triangle interpolation method, but in the end chose to go with a GAM model that seemed more plausible to be actually true.
Heatmap of derived metric NM per gallon: RPM vs pressure altitiude, by temperature
Using the published cruise performance data, I calculated the travel efficiency metric of nautical miles traveled per gallon of fuel used, at the various RPM and altitudes available.
This doesn’t take into account the fuel consumed to actually reach the higher altitudes, where fuel use becomes more efficient.
Code
##### Heatmap of derived metric NM per gallon: RPM vs pressure altitiude, by temperature #####df_cruise |># filter(rpm/100 == round(rpm/100)) |>mutate(eff = ktas / gph,label =paste0(round(eff, 1), " NM / G\n", ktas, " KTAS\n", gph, " GPH") ) |>ggplot(aes(alt, rpm, fill = eff)) +geom_tile() +geom_text(aes(label = label), size =2.2, color ="black") +# #555theme_bw() +scale_fill_viridis_c(option ="H", "NM per gallon", limits =c(10, 15)) +facet_wrap(~temp,labeller =labeller(outcome = outcome_labels, temp = temp_labels) ) +labs(title ="Nautical miles per gallon, by altitude and RPM",x ="Pressure Altitude (feet)",y ="RPM" ) +scale_x_continuous(breaks =seq(2000, 12000, 2000)) +scale_y_continuous(breaks =seq(2100, 2700, 100))
Short field landing
Continuous numeric predictors:
pressure altitude (9 levels)
temperature (5 levels)
Numeric output:
ground roll in feet
total feet to clear a 50 foot obstruction
Not modeled here: headwind/tailwind, non-paved runway, flaps up
Predictive models:
mgcv::gam generalized additive model for ground roll and total distance to clear 50’ obstacle
however, gam parameter of bs = "gp" performed poorly, so instead used bp = "tp"
can’t say I fully understand exactly why
Plot shows overlay of original and gam model predictions
Very good agreement, acceptable to me to use.
The curves in the POH are actually too perfect, and were almost certainly the results of a modelled curve and not actual results.
I will probably separately describe the Shiny App that I developed using these calculations.
It has some really nice usability enhancements like automatically fetching information for a selected airport, including weather information and runway headings, which allows calculations of head/tailwinds and pressure + density altitudes, which can then be fed into the performance predictive models.
However, I will probably not share the app or code, because I think that would be irresponsible of me and potentially endanger others.