The airline fleet assignment problem (FAP) involves assigning aircraft types to scheduled flight legs to maximize profit, considering revenue, operating costs, and constraints like fleet availability and aircraft flow balance.
The Itinerary-based Fleet Assignment Model (IFAM) can be viewed as a
combination of two interacting parts (FAM + PAM):
- FAM (Fleet Assignment Model): decides which aircraft
type covers each leg, ensuring flow balance and utilization
feasibility.
- PAM (Passenger Assignment Model): allocates passenger
demand across itineraries, handling spill and recapture under leg
capacity limits.
Objective Function
\[ \min\; \underbrace{\sum_{i \in L}\sum_{k \in K} c_{k,i}\,f_i^k}_{\text{Operating cost of assigning fleet $k$ to leg $i$ (FAM)}} \;+\; \underbrace{\sum_{p \in P}\sum_{r \in P}\big(fare_p - b_p^r\cdot fare_r\big)\,t_p^r}_{\text{Revenue from pax $p$ allocated/recaptured to option $r$ (PAM)}} \]
Flight coverage \[ \underbrace{\sum_{k \in K} f_i^k}_{\text{Aircraft assigned to leg $i$}} = 1 \quad \forall i\in L \]
Aircraft flow balance \[ \underbrace{y_{n^+}^k + \sum_{i\in O(k,n)} f_i^k}_{\text{Flows leaving node $n$}} = \underbrace{y_{n^-}^k + \sum_{i\in I(k,n)} f_i^k}_{\text{Flows entering node $n$}} \quad \forall n\in N^k,\;\forall k\in K \]
Utilization limits \[ \underbrace{\sum_{a\in N^k} y_a^k + f_a^k}_{\text{Total hours used by fleet $k$}} \;\le\; \underbrace{A c^k}_{\text{Available capacity of fleet $k$}} \quad \forall k\in K \]
Leg capacity with spill/recapture \[ \underbrace{\sum_{p\in P}\delta_i^{p}\, b_p}_{\text{Primary pax on leg $i$}} \;+\; \underbrace{\sum_{p\in P}\sum_{q\in P}\delta_i^{\,q}\, y_{pq}}_{\text{Recaptured pax on leg $i$}} \;\le\; \underbrace{\sum_{k\in K} s_k\, f_i^k}_{\text{Seats supplied on leg $i$}} \quad \forall i\in L \]
Demand limits \[ \underbrace{\sum_{r\in P} t_p^r}_{\text{Total pax assigned to itinerary $p$}} \;\le\; \underbrace{D_p}_{\text{Demand for $p$}} \quad \forall p\in P \]
The methodology applied in this study follows the framework presented in Barnhart, Farahat, and Lohatepanont (2009), Airline Fleet Assignment with Enhanced Revenue Modeling, Operations Research, 57(1): 231–244. https://doi.org/10.1287/opre.1070.0503
From Barnhart et al. (2009), IFAM refines revenue modeling by optimizing passenger bookings \(v_p\) for each product \(p\) (itinerary–fare class), subject to capacity constraints:
\[ \max\; \underbrace{\sum_{p \in P} h_p v_p}_{\text{Revenue from booked products}} \;-\; \underbrace{\sum_{l \in L} \sum_{f \in F} c_{l,f} x_{l,f}}_{\text{Operating costs of assignments}} \]
subject to leg capacities
\[ \underbrace{\sum_{p \in P} \delta_{p,l} v_p}_{\text{Passengers using leg }l} \;\leq\; \underbrace{\sum_{f \in F} \text{CAP}_f x_{l,f}}_{\text{Seats from fleet $f$ on leg $l$}} \qquad \forall l \in L \]
and demand bounds
\[ \underbrace{0 \;\leq\; v_p \;\leq\; D_p}_{\text{Bookings bounded by demand}} \qquad \forall p \in P \]
This code extends IFAM with linear spill (\(s_p\)) and recapture (\(y_{p,q}\)), allowing spilled demand from product \(p\) to be recaptured on alternative \(q\), with recapture rate \(\rho_p\) and revenue dilution \(\gamma_q\).
Packages are essential for optimization (OMPR with ROI plugins for solvers like GLPK/Symphony) and data handling (tidyverse). Theoretical basis: Optimization models like IFAM are solved as mixed-integer programs (MIPs), requiring linear programming solvers.
suppressWarnings(
pacman::p_load(
tidyverse,
ompr,
ompr.roi,
ROI,
ROI.plugin.glpk, #glpk solver
scales,
ROI.plugin.symphony, #symphony solver
stringr,
kableExtra,
ggthemes,
character.only = FALSE
)
)
Parameters include fuel prices, recapture rates, and dilution factors. In revenue management, recapture models spilled-demand redirection:
\[ \underbrace{\sum_{q} y_{p,q}}_{\text{Recaptured from product $p$}} \;\le\; \underbrace{\rho_p s_p}_{\text{Recapture cap by rate}} \]
\[ \underbrace{\gamma_q h_q}_{\text{Diluted revenue on $q$}} \qquad \text{with typically } \gamma_q < 1 \]
From the paper, enhanced revenue modeling in IFAM/SFAM approximates such dynamics linearly for tractability.
fuel_price_usd_per_t <- 700
# Using approximately USD 700 per tonne, which aligns with Jet A-1 market spot prices (e.g., U.S.: USD 697.6/MT) as of late August 2025.
default_recapture_rate <- 0.60
# Recapture rate (0-1) [Source: Revenue Management / Network Planning (based on historical rebooking)]
default_recapture_gamma <- 0.90
# Revenue dilution for recaptured pax (0-1) [Source: Pricing & Yield Management (based on historical fare mix and dilution analysis)]
Flight data includes legs with origins, destinations, block hours, distances, and required range.
Basic FAP theory: each leg \(l \in L\) must be covered by one fleet type \(f \in F\):
\[ \underbrace{\sum_{f \in F} x_{l,f}}_{\text{Assignment to leg $l$}} \;=\; \underbrace{1}_{\text{Exactly one fleet assigned}} \qquad \forall l \in L \]
flights <- tibble::tribble(
~flight_id, ~origin, ~dest, ~block_h, ~distance_km, ~req_range_nm,
"BA001", "LHR", "JFK", 8.0, 5556, 3200,
"BA002", "LHR", "JFK", 8.0, 5556, 3200,
"BA003", "LHR", "LAX", 11.0, 8755, 5400,
"BA004", "LHR", "DEL", 9.5, 6715, 3700,
"BA101", "LHR", "EDI", 1.3, 534, 500,
"BA102", "LHR", "EDI", 1.3, 534, 500
)
Fleet data specifies aircraft types with range, seats, turn times, available tails, utilization caps, and costs.
Constraints include fleet limits:
\[ \underbrace{\sum_{l \in L} \big(b_l + t_f\big)\, x_{l,f}}_{\text{Assigned block + turn hours}} \;\leq\; \underbrace{u_f \, n_f}_{\text{Total available utilization hours}} \qquad \forall f \in F \]
where \(b_l\) is block time, \(t_f\) turn time, \(u_f\) max utilization per tail, and \(n_f\) number of tails.
Paper’s FAM uses timeline networks for balance, but here simplified to utilization caps.
fleet <- tibble::tribble(
~type, ~range_nm, ~seats, ~turn_h, ~tails, ~max_util_h_per_tail, ~block_cost_usd_per_h, ~fuel_burn_t_per_h, ~fix_cost_per_flt_usd,
"A350-1000", 8000, 360, 1.5, 5, 18, 7500, 5.6, 7000,
"B787-9", 7600, 290, 1.3, 6, 17, 6800, 4.8, 6500,
"A321neo", 3700, 220, 0.8, 9, 12, 3200, 2.0, 2100,
"A320-200", 3000, 180, 0.8, 18, 11, 3000, 2.2, 2000
)
Products are itineraries with fares and demands. In IFAM, each product \(p \in P\) has: \[ \underbrace{v_p}_{\text{Bookings for product $p$}} \;\leq\; \underbrace{D_p}_{\text{Demand for product $p$}} \qquad \forall p \in P \]
where \(D_p\) is demand and \(h_p\) is fare (revenue per booking) for product \(p\) (fare‑class‑level if products are itinerary×fare‑class; expected yield if aggregated).
Here, products are mostly single-leg, but the model supports multi-leg via the incidence matrix.
products <- tibble::tribble(
~p_id, ~fare_usd, ~demand,
"LHR-JFK-1", 620, 320,
"LHR-JFK-2", 580, 360,
"LHR-LAX", 720, 360,
"LHR-DEL", 540, 300,
"LHR-EDI-1", 140, 160,
"LHR-EDI-2", 135, 170
)
# fare_usd : comes from Revenue Accounting and/or Pricing Department (average fare by market or bucket)
# demand : comes from Demand Forecasting models in Revenue Management/Network Planning
# - Sources: historical booking (PNR/ticketing data), MIDT/ARC data, market studies
# - Typically, RM systems (e.g. Sabre, PROS, Amadeus) produce unconstrained demand forecasts
#
# For planning, demand is adjusted for seasonality, macro factors (GDP, fuel, competitor schedules)
# plus cubic forecasting where each axis (e.g. seasonality, booking window, market state) represents
# a condition, and the 3D surface captures their combined effect on unconstrained demand.
Incidence \(\Phi_{p,l} = 1\) if product \(p\) uses leg \(l\).
The product–leg incidence matrix links passenger demand (products) to the physical flight network (legs) to ensure that bookings and recaptured flows are correctly translated into seat consumption on each leg.
This enables network effects in capacity:
\[ \underbrace{\sum_{p \in P} \Phi_{p,l}\Big(b_p + \sum_{q \in P} y_{q,p}\Big)}_{\text{Effective passengers on leg $l$}} \;\leq\; \underbrace{\sum_{f \in F} s_f \, x_{l,f}}_{\text{Seats supplied on leg $l$}} \qquad \forall l \in L \]
where \(b_p\) is primary bookings and \(y_{q,p}\) is recaptured inflow.
Paper’s IFAM uses a similar \(\delta_{p,l}\) formulation for spill modeling.
phi <- tibble::tribble(
~p_id, ~flight_id,
"LHR-JFK-1", "BA001",
"LHR-JFK-2", "BA002",
"LHR-LAX", "BA003",
"LHR-DEL", "BA004",
"LHR-EDI-1", "BA101",
"LHR-EDI-2", "BA102"
)
Recapture adjacency \(A_{p,q} = 1\) if spill from product \(p\) can be redirected to product \(q\).
Constraint:
\[ \underbrace{y_{p,q}}_{\text{Recaptured flow from $p$ to $q$}} \;\leq\; \underbrace{A_{p,q} \, s_p}_{\text{Allowed only if adjacency permits}} \qquad \forall p,q \in P \]
In advanced revenue models (paper’s SFAM extension), recapture approximates network effects by redirecting demand within subnetworks.
Here, \(A\) allows recapture only within the same OD pair.
# Product IDs
P_ids <- products$p_id
# Adjacency matrix A (p,q): 1 if recapture p->q allowed
A <- matrix(0L, nrow = nrow(products), ncol = nrow(products), dimnames = list(P_ids, P_ids))
# Function to get OD label
same_od <- function(pid) sub("-[0-9]+$", "", pid)
# Set A=1 for same OD, p != q
for (p in seq_len(nrow(products))) {
for (q in seq_len(nrow(products))) {
if (p != q && same_od(P_ids[p]) == same_od(P_ids[q])) A[p,q] <- 1L
}
}
# Recapture rates and dilutions
rho_p <- rep(default_recapture_rate, length(P_ids))
gamma_in <- rep(default_recapture_gamma, length(P_ids))
Compute dimensions, vectors, and matrices for the model.
Compatibility constraint:
\[ \underbrace{x_{l,f}}_{\text{Assignment of fleet $f$ to leg $l$}} \;\leq\; \underbrace{\kappa_{l,f}}_{\text{Feasibility indicator (1 if $f$ can fly $l$)}} \qquad \forall l \in L, f \in F \]
Cost calculation:
\[ \underbrace{c_{l,f}}_{\text{Total cost of fleet $f$ on leg $l$}} = \underbrace{b_l c^b_f}_{\text{Block-hour operating cost}} \;+\; \underbrace{b_l r_f p^f}_{\text{Fuel burn × fuel price}} \;+\; \underbrace{c^o_f}_{\text{Fixed cost per flight}} \]
# IDs and dimensions
L_ids <- flights$flight_id
T_ids <- fleet$type
L <- length(L_ids)
T <- length(T_ids)
P <- length(P_ids)
# Fleet vectors
seats_t <- fleet$seats
turn_t <- fleet$turn_h
util_cap_t <- fleet$tails * fleet$max_util_h_per_tail
# Compatibility matrix (range + no short-haul on long-range rule)
compat_mat <- matrix(0, nrow = L, ncol = T)
for (i in seq_len(L)) {
for (k in seq_len(T)) {
compat_mat[i, k] <- as.integer(fleet$range_nm[k] >= flights$req_range_nm[i] &
!(fleet$range_nm[k] < 4000 & flights$req_range_nm[i] >= 3200))
}
}
# Cost matrix c[i,k]
cost_mat <- matrix(NA_real_, nrow = L, ncol = T)
for (i in seq_len(L)) {
for (k in seq_len(T)) {
bh <- flights$block_h[i]
var_cost <- bh * fleet$block_cost_usd_per_h[k]
fuel_cost <- bh * fleet$fuel_burn_t_per_h[k] * fuel_price_usd_per_t
fix_cost <- fleet$fix_cost_per_flt_usd[k]
cost_mat[i, k] <- var_cost + fuel_cost + fix_cost
}
}
# Phi matrix (P x L)
Phi <- matrix(0L, nrow = P, ncol = L, dimnames = list(P_ids, L_ids))
for (r in seq_len(nrow(phi))) {
p <- match(phi$p_id[r], P_ids)
i <- match(phi$flight_id[r], L_ids)
Phi[p, i] <- 1L
}
# Demand and fares
D_p <- products$demand
fare_p <- products$fare_usd
Formulate IFAM as a Mixed-Integer Program (MIP) using OMPR.
Objective:
\[ \max\; \underbrace{\sum_{p \in P} h_p b_p}_{\text{Primary revenue}} \;+\; \underbrace{\sum_{p \in P}\sum_{q \in P} \gamma_q h_q y_{p,q}}_{\text{Recaptured diluted revenue}} \;-\; \underbrace{\sum_{l \in L}\sum_{f \in F} c_{l,f} x_{l,f}}_{\text{Operating costs}} \]
Constraints include: - Coverage
\[
\underbrace{\sum_{f \in F} x_{l,f}}_{\text{Fleet assigned to leg $l$}} =
1
\qquad \forall l \in L
\]
Utilization
\[
\underbrace{\sum_{l \in L} \big(b_l + t_f\big) x_{l,f}}_{\text{Assigned
block + turn hours}}
\;\leq\;
\underbrace{u_f n_f}_{\text{Available utilization hours}}
\qquad \forall f \in F
\]
Compatibility
\[
\underbrace{x_{l,f}}_{\text{Assignment}} \;\leq\;
\underbrace{\kappa_{l,f}}_{\text{Feasibility}}
\qquad \forall l \in L, f \in F
\]
Demand conservation
\[
\underbrace{b_p}_{\text{Primary bookings}}
\;+\;
\underbrace{s_p}_{\text{Spill}}
\;+\;
\underbrace{\sum_q y_{p,q}}_{\text{Recaptured outflow}}
\;=\;
\underbrace{D_p}_{\text{Demand}}
\qquad \forall p \in P
\]
Recapture limit
\[
\underbrace{\sum_q y_{p,q}}_{\text{Total recapture from $p$}}
\;\leq\;
\underbrace{\rho_p s_p}_{\text{Recapture cap}}
\qquad \forall p \in P
\]
Leg capacity
\[
\underbrace{\sum_{p \in P} \Phi_{p,l}\, b_p}_{\text{Primary pax on leg
$l$}}
\;+\;
\underbrace{\sum_{p \in P}\sum_{q \in P} \Phi_{q,l}\,
y_{p,q}}_{\text{Recaptured pax on leg $l$}}
\;\leq\;
\underbrace{\sum_{f \in F} s_f x_{l,f}}_{\text{Seats supplied on leg
$l$}}
\qquad \forall l \in L
\]
Paper’s SFAM uses subnetworks for tighter relaxations; here, the full IFAM with recapture approximates that.
# MIP model
m <- ompr::MIPModel() %>%
# Assignment variables
add_variable(x[i, k], i = 1:L, k = 1:T, type = "binary") %>%
# Primary bookings
add_variable(b[p], p = 1:P, lb = 0) %>%
# Spill
add_variable(s[p], p = 1:P, lb = 0) %>%
# Recapture flows
add_variable(y[p, q], p = 1:P, q = 1:P, lb = 0) %>%
# Objective
set_objective(
sum_expr(fare_p[p] * b[p], p = 1:P) +
sum_expr(gamma_in[q] * fare_p[q] * y[p, q], p = 1:P, q = 1:P) -
sum_expr(cost_mat[i, k] * x[i, k], i = 1:L, k = 1:T),
sense = "max"
) %>%
# Cover constraints
add_constraint(sum_expr(x[i, k], k = 1:T) == 1, i = 1:L) %>%
# Utilization constraints
add_constraint(
sum_expr((flights$block_h[i] + turn_t[k]) * x[i, k], i = 1:L) <= util_cap_t[k],
k = 1:T
) %>%
# Compatibility
add_constraint(x[i, k] <= compat_mat[i, k], i = 1:L, k = 1:T) %>%
# Demand conservation
add_constraint(b[p] + s[p] + sum_expr(y[p, q], q = 1:P) == D_p[p], p = 1:P) %>%
# Recapture limit by rho
add_constraint(sum_expr(y[p, q], q = 1:P) <= rho_p[p] * s[p], p = 1:P) %>%
# Recapture only on allowed arcs
add_constraint(y[p, q] <= A[p, q] * s[p], p = 1:P, q = 1:P) %>%
# Leg capacity with effective bookings
add_constraint(
sum_expr(Phi[p, i] * b[p], p = 1:P) +
sum_expr(Phi[q, i] * y[p, q], p = 1:P, q = 1:P) <=
sum_expr(seats_t[k] * x[i, k], k = 1:T),
i = 1:L
)
# Solve with Symphony (or GLPK)
res <- solve_model(
m,
with_ROI(
solver = "symphony",
verbosity = -2,
gap_limit = 0.01, # 1% gap
time_limit = 60 # seconds
)
)
Extract assignments \(x_{l,f}\), bookings \(b_p\), spill \(s_p\), and recaptures \(y_{p,q}\).
Compute effective bookings:
\[ \underbrace{b_p^{\text{eff}}}_{\text{Effective bookings for product $p$}} \;=\; \underbrace{b_p}_{\text{Primary bookings}} \;+\; \underbrace{\sum_{q \in P} y_{q,p}}_{\text{Recaptured inflow}} \qquad \forall p \in P \]
Paper notes that IFAM’s LP relaxation is weak; recapture adds realism but increases complexity.
# Assignments where x > 0.5
sol_x <- get_solution(res, x[i, k]) %>% filter(value > 0.5)
# Primary bookings
sol_b <- get_solution(res, b[p]) %>% transmute(p = p, b = value)
# Spill
sol_s <- get_solution(res, s[p]) %>% transmute(p = p, s = value)
# Recapture flows > epsilon
sol_y <- get_solution(res, y[p, q]) %>% filter(value > 1e-6) %>%
transmute(p_from = p, p_to = q, flow = value)
# Assignment table
assignments <- sol_x %>%
mutate(flight_id = L_ids[i], type = T_ids[k]) %>%
select(flight_id, type)
# Vectors and matrices for computations
b_vec <- numeric(P); b_vec[sol_b$p] <- sol_b$b
y_mat <- matrix(0, nrow = P, ncol = P)
if (nrow(sol_y)) {
for (r in seq_len(nrow(sol_y))) y_mat[sol_y$p_from[r], sol_y$p_to[r]] <- sol_y$flow[r]
}
# Effective bookings
b_eff <- b_vec + colSums(y_mat)
# Pax boarded per leg
boarded_per_leg <- as.numeric(t(Phi) %*% b_eff)
leg_load <- tibble(flight_id = L_ids, pax_boarded = boarded_per_leg)
# Costs from assignments
cost_leg <- assignments %>%
mutate(cost_usd = map2_dbl(flight_id, type, ~{
ii <- match(.x, L_ids); kk <- match(.y, T_ids); cost_mat[ii, kk]
}))
# Leg summary
leg_summary <- assignments %>%
left_join(fleet %>% select(type, seats, turn_h), by = "type") %>%
left_join(flights %>% select(flight_id, block_h, distance_km), by = "flight_id") %>%
left_join(cost_leg, by = c("flight_id","type")) %>%
left_join(leg_load, by = "flight_id") %>%
mutate(ASK = seats * distance_km,
RPK = pmin(pax_boarded, seats) * distance_km)
Key Performance Indicators: Profit, revenue, costs, ASK/RPK, load factor, spill, recapture.
From paper, SFAM bounds optimality gaps; here, solver gap limits ensure near-optimality.
Revenue: Primary \(\sum_p h_p b_p\) + recaptured \(\sum_{p,q} \gamma_q h_q y_{p,q}\).
# Revenues
revenue_primary <- sum(fare_p * b_vec)
revenue_recap_in <- sum(gamma_in[col(y_mat)] * fare_p[col(y_mat)] * as.vector(y_mat))
total_revenue <- revenue_primary + revenue_recap_in
# Total cost and profit
total_cost <- sum(leg_summary$cost_usd)
profit <- res$objective_value
# ASK and RPK totals
ASK_total <- sum(leg_summary$ASK)
RPK_total <- sum(leg_summary$RPK)
# Load factor
LF <- ifelse(ASK_total > 0, RPK_total / ASK_total, NA_real_)
# Spill table
spill_tbl <- tibble(p_id = P_ids,
demand = D_p,
booked_primary = b_vec,
recap_in = colSums(y_mat),
recap_out = rowSums(y_mat),
spill = ifelse(P > 0, (D_p - booked_primary - recap_out), NA_real_)) %>%
mutate(booked_effective = booked_primary + recap_in)
# KPI table
kpis <- tibble::tibble(
KPI = c("Status","Objective (Profit, USD)","Total Revenue (USD)","Total Cost (USD)",
"ASK (km*seats)","RPK (km*pax)","Load Factor",
"Total Spill (pax)","Total Recaptured (pax)"),
Value = c(res$status,
scales::dollar(profit),
scales::dollar(total_revenue),
scales::dollar(total_cost),
scales::comma(round(ASK_total,0)),
scales::comma(round(RPK_total,0)),
scales::percent(LF, accuracy = 0.1),
scales::comma(round(sum(spill_tbl$spill),0)),
scales::comma(round(sum(spill_tbl$recap_in),0))
)
)
Outputs include KPIs, assignments, leg summaries, product flows, and recapture arcs. These validate the model against theoretical expectations from the paper.
cat("\n=== Leg summary (first few) ===\n")
##
## === Leg summary (first few) ===
print(head(leg_summary %>% select(flight_id, type, seats, pax_boarded, block_h, distance_km, cost_usd), 10))
## flight_id type seats pax_boarded block_h distance_km cost_usd
## 1 BA001 A350-1000 360 320 8.0 5556 98360
## 2 BA002 A350-1000 360 360 8.0 5556 98360
## 3 BA003 A350-1000 360 360 11.0 8755 132620
## 4 BA004 B787-9 290 290 9.5 6715 103020
## 5 BA101 A320-200 180 160 1.3 534 7902
## 6 BA102 A320-200 180 170 1.3 534 7902
theme_report <- function() {
theme_solarized(base_size = 12) +
theme(
plot.title = element_text(size = 16, face = "plain", margin = margin(b = 6)),
plot.subtitle = element_text(size = 11, colour = "grey30", margin = margin(b = 8)),
plot.caption = element_text(size = 9, colour = "grey40"),
axis.title = element_text(size = 11, colour = "grey30"),
axis.text = element_text(size = 10, colour = "grey20"),
panel.grid.minor = element_blank()
)
}
kpis %>%
kable(caption = "Executive KPIs", align = "l") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed","responsive"))
| KPI | Value |
|---|---|
| Status | success |
| Objective (Profit, USD) | $420,186 |
| Total Revenue (USD) | $868,350 |
| Total Cost (USD) | $448,164 |
| ASK (km*seats) | 9,291,710 |
| RPK (km*pax) | 9,053,450 |
| Load Factor | 97.4% |
| Total Spill (pax) | 0 |
| Total Recaptured (pax) | 0 |
Fleet Assignment Matrix (Flights × Types)
assign_grid <- assignments %>%
mutate(assigned = 1L) %>%
tidyr::complete(flight_id = L_ids, type = T_ids, fill = list(assigned = 0L)) %>%
arrange(flight_id, type)
ggplot(assign_grid, aes(x = type, y = flight_id, fill = factor(assigned))) +
geom_tile(color = "white", linewidth = 0.4) +
scale_fill_manual(values = c("0" = "grey92", "1" = "steelblue3"), guide = "none") +
labs(title = "Fleet Assignment Heatmap", x = "Aircraft Type", y = "Flight ID") +
theme_report()
Leg Loads vs Capacity
leg_plot <- leg_summary %>%
mutate(load_pct = pmin(pax_boarded / seats, 1))
ggplot(leg_plot, aes(x = reorder(flight_id, pax_boarded))) +
geom_col(aes(y = seats), alpha = 0.25) +
geom_col(aes(y = pax_boarded)) +
geom_text(aes(y = pmax(pax_boarded, seats) * 1.02,
label = scales::percent(load_pct, accuracy = 0.1)),
size = 3) +
coord_flip() +
labs(title = "Pax Boarded vs Seats", subtitle = "Label shows load factor per leg",
x = "Flight", y = "Pax") +
theme_solarized()
Cost per Leg
# Recreate cost components per assigned leg (no change to solver)
cost_leg_decomp <- assignments %>%
mutate(ii = match(flight_id, L_ids),
kk = match(type, T_ids),
bh = flights$block_h[ii],
fuel_per_h = fleet$fuel_burn_t_per_h[kk] * fuel_price_usd_per_t,
# If block_cost_usd_per_h INCLUDED fuel, pull it out; clamp at zero
var_cost_nonfuel = pmax(fleet$block_cost_usd_per_h[kk] - fuel_per_h, 0) * bh,
fuel_cost = fuel_per_h * bh,
fix_cost = fleet$fix_cost_per_flt_usd[kk]) %>%
select(flight_id, type, var_cost = var_cost_nonfuel, fuel_cost, fix_cost) %>%
tidyr::pivot_longer(cols = c(var_cost, fuel_cost, fix_cost),
names_to = "component", values_to = "usd")
ggplot(cost_leg_decomp, aes(x = reorder(flight_id, usd, sum), y = usd, fill = component)) +
geom_col(position = "stack") +
coord_flip() +
scale_y_continuous(labels = scales::dollar) +
labs(title = "Cost Breakdown per Leg", x = "Flight", y = "USD") +
theme_report() +
theme(legend.title = element_blank())
Utilization vs Capacity (by Fleet Type)
# Hours assigned = sum(block + turn) for each type
util_df <- assignments %>%
mutate(ii = match(flight_id, L_ids),
kk = match(type, T_ids),
hours = flights$block_h[ii] + fleet$turn_h[kk]) %>%
group_by(type) %>%
summarise(assigned_h = sum(hours), .groups = "drop") %>%
mutate(capacity_h = util_cap_t[match(type, T_ids)],
util_pct = pmin(assigned_h / capacity_h, 1))
ggplot(util_df, aes(x = reorder(type, assigned_h))) +
geom_col(aes(y = capacity_h), alpha = 0.15, fill = "grey70") + # Grey bar = capacity
geom_col(aes(y = assigned_h), fill = "black") + # Black bar = assigned
geom_text(aes(y = pmax(assigned_h, capacity_h) * 1.02,
label = paste0(scales::percent(util_pct, accuracy = 0.1),
" (", round(assigned_h,1), "/", round(capacity_h,1), "h)")),
size = 3) +
scale_y_continuous(expand = expansion(mult = c(0, 0.12))) +
coord_flip() +
labs(
title = "Fleet Utilization vs Capacity",
subtitle = "Black bar = ∑(block_h + turn_h) assigned per type\nGrey bar = tails × max_util_h_per_tail",
caption = "Note: Low utilization arises because only 6 flights are modelled for reference,\nwhile fleet capacity is sized for full-day operations across all tails."
) +
theme_solarized_2()
Spill by OD
# Use same_od() from earlier to aggregate by market
spill_od <- spill_tbl %>%
mutate(od = same_od(p_id)) %>%
group_by(od) %>%
summarise(
demand = sum(demand),
booked_effective = sum(booked_effective),
spill = sum(spill),
recaptured_in = sum(recap_in),
.groups = "drop"
) %>%
mutate(load_pct = booked_effective / demand)
p1 <- ggplot(spill_od, aes(x = reorder(od, demand), y = demand)) +
geom_col(alpha = 0.15, fill = "grey80") +
geom_col(aes(y = booked_effective), fill = "black") +
geom_text(aes(y = booked_effective,
label = scales::percent(load_pct, accuracy = 0.1)),
hjust = -0.1, size = 3.5) +
coord_flip(clip = "off") +
scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
labs(title = "OD Demand vs Effective Bookings",
x = "OD", y = "Pax") +
theme_solarized_2()
p1
Leg Economics: Cost vs Revenue (Sanity Check)
# Estimated revenue per leg from effective bookings on that leg times average fare
# Note: uses average fare for products touching leg;
avg_fare_leg <- {
# average fare by leg from products using it
leg_prod <- as.data.frame(Phi)
colnames(leg_prod) <- L_ids
leg_prod$p_id <- P_ids
leg_prod <- leg_prod %>%
pivot_longer(cols = all_of(L_ids), names_to = "flight_id", values_to = "inc") %>%
filter(inc == 1L) %>%
left_join(products, by = "p_id") %>%
group_by(flight_id) %>%
summarise(avg_fare = mean(fare_usd), .groups = "drop")
}
leg_rev <- leg_summary %>%
left_join(avg_fare_leg, by = "flight_id") %>%
mutate(revenue_usd = pax_boarded * avg_fare)
leg_econ <- leg_rev %>%
select(flight_id, revenue_usd, cost_usd) %>%
pivot_longer(cols = c(revenue_usd, cost_usd),
names_to = "metric", values_to = "usd")
ggplot(leg_econ, aes(x = reorder(flight_id, usd, sum), y = usd, fill = metric)) +
geom_col(position = "dodge", width = 0.6) +
coord_flip() +
scale_y_continuous(labels = scales::dollar) +
labs(title = "Leg Economics (Estimated Revenue vs Cost)",
x = "Flight", y = "USD") +
theme_solarized_2() +
theme(legend.title = element_blank())
Compatibility Heatmap (Range/Business Rule)
compat_df <- as.data.frame(compat_mat)
colnames(compat_df) <- T_ids
compat_df$flight_id <- L_ids
compat_long <- compat_df %>%
pivot_longer(-flight_id, names_to = "type", values_to = "ok")
ggplot(compat_long, aes(x = type, y = flight_id, fill = factor(ok))) +
geom_tile(color = "white", linewidth = 0.4) +
scale_fill_manual(values = c("0" = "grey90", "1" = "darkseagreen3"),
labels = c("Not Allowed","Allowed"), name = NULL) +
labs(title = "Leg × Type Compatibility", x = "Aircraft Type", y = "Flight") +
theme_solarized()
```