This model analyzes one origin–destination (OD) market served by parallel flights. We jointly determine both prices and seat allocations. The decision process flows through three core modules:
The sequential loop is:
price → forecast → allocation (LP) → revenue → adjust price.
The methodology applied in this study follows the framework presented in Jayaram, A., Amit, R.K., Agarwal, A., & Luo, X. (2024), Elasticity-integrated pricing and allocation heuristic for airline revenue management, Journal of Revenue and Pricing Management, 23(3):305–317. https://doi.org/10.1057/s41272-023-00454-6
This model estimates demand by aggregating over three dimensions:
Aggregated demand for product \(k\) (Equation 3):
\[ \underbrace{D_k(p_k)}_{\text{Demand for product }k} = \underbrace{\sum_{t}^{T}}_{\text{time to departure}} \underbrace{\mathrm{DM}}_{\text{booking volume}} \cdot \underbrace{\big(e^{-\gamma R_2}-e^{-\gamma R_1}\big)}_{\text{RD window weight}} \cdot \sum_{C_j\in C_k^{M}} \Bigg[ \underbrace{\big(e^{-\lambda (\tau_j-p_0)}-e^{-\lambda (\tau_{j+1}-p_0)}\big)}_{\text{WTP band mass }M_j} \cdot \underbrace{\frac{\exp\!\big(\sum_{a\in A'} \beta_a a_k\big)}{\sum_{\ell\in C_j}\exp\!\big(\sum_{a\in A'}\beta_a a_\ell\big)}}_{\text{MNL share with price attribute}} \Bigg]. \]
A useful identity for the RD window weight is:
\[ \underbrace{\text{RD weight}}_{\text{window }[R_1,R_2]} = \underbrace{e^{-\gamma R_1} - e^{-\gamma R_2}}_{\text{integrated decay over the window}}. \]
We maximize revenue with capacity constraints (and we may add price bounds if needed):
\[ \max \; \sum_{k=1}^N \underbrace{p_k}_{\text{price}}\, \underbrace{\min\{D_k(p_k),\,q_{ik}\}}_{\text{sold seats }z_k} \quad \text{s.t.}\quad \underbrace{\sum_k q_{ik}\le S_i}_{\text{capacity by flight }i},\; q_{ik}\ge 0. \]
We linearize the minimum via an auxiliary variable \(z_k\):
\[ \max \; \sum_k \underbrace{p_k z_k}_{\text{revenue}} \quad \text{s.t.}\quad \underbrace{z_k \le D_k(p_k)}_{\text{demand cap}},\; \underbrace{z_k \le q_{ik}}_{\text{allocation cap}},\; \underbrace{\sum_k q_{ik}\le S_i}_{\text{capacity}},\; z_k,q_{ik}\ge 0. \]
In this formulation:
In words: L-JOM jointly decides how many seats (\(q_{ik}\)) to allocate at each fare (\(p_k\)) by maximizing revenue (\(p_k z_k\)), subject to forecast demand limits (\(D_k\)) and flight capacity (\(S_i\)). The linearization replaces the nonlinear minimum operator with simple linear caps, so the problem can be solved efficiently as a linear program.
Price affects both WTP gating and within-band utility, so concavity is not guaranteed. We use small, monotone price moves per product; after each move we re-solve L-JOM and keep the best move.
A simple finite-difference own-price elasticity diagnostic is
\[ \underbrace{\varepsilon_{k}}_{\text{own elasticity}} \approx \underbrace{\frac{p_k}{D_k}}_{\text{scale}} \cdot \underbrace{\frac{D_k(p_k(1+\epsilon))-D_k(p_k)}{p_k \epsilon}}_{\text{finite difference}}. \] In the research paper, the optimal revenue for each pricing setting was computed in Microsoft Excel using the GRG Non-linear solver engine with the Multistart option enabled. This approach performed repeated runs of the GRG Non-linear algorithm from different initial price values, increasing the likelihood of reaching the global optimum.
In our implementation, instead of Excel’s GRG solver, we replicate this search through an iterative heuristic in R. The EPAH routine perturbs prices in small steps, re-computes demand forecasts via JFM-PA, re-solves allocations with L-JOM, and accepts improving moves. This mimics the repeated solver trials of the Multistart method, but in a systematic, programmatic loop.
suppressPackageStartupMessages({
pacman::p_load("pacman",
"tidyverse","lubridate","stringr",
"ompr","ompr.roi","ROI","ROI.plugin.glpk",
"scales", "purrr", "tidyverse")
})
LOCK_PRICES <- FALSE # FALSE => run EPAH (when bucket prices are not determined); TRUE => single L-JOM at base fares
EPAH_MAX_ITER <- 40
EPAH_STEPS <- c(0.01) # 1% step
EPAH_TOL <- 1e-6 # stop when revenue gain is negligible
PHI_CALIB <- 0.5 # tuning factor (between 0 and 1) that controls how strongly we match observed product mix
RESERVE_EXO <- FALSE # we do not include outside (fixed) products in this example
EXO_RESERVE_FRAC <- 1.0
set.seed(77)
-One O–D market served by two parallel flights (Flight 1 and Flight 2). -Each flight offers three products (six total), labeled A–F. -, C, E belong to Flight 1 (from highest to lowest price within the flight); -B, D, F belong to Flight 2 (from highest to lowest).
Capacity per flight is fixed and identical across products.
Data are synthetic, for illustration only.
# flights and capacity
flights <- tibble(flight_no = c(1L, 2L), cap = 180L)
cap_by_flight <- flights %>% arrange(flight_no) %>% select(flight_no, cap)
# products A..F with descending base fares per flight
product_map <- tibble::tribble(
~product, ~flight_no, ~fare,
"A", 1L, 320,
"C", 1L, 240,
"E", 1L, 160,
"B", 2L, 315,
"D", 2L, 235,
"F", 2L, 155
)
# volumes by reading day (RD)
travel_day <- as.Date("2025-10-01")
RD_grid <- 0:27
vol_profile <- function(base_mean) {
tibble(RD = RD_grid, lambda = base_mean * exp(-0.08 * RD)) %>%
mutate(vol = rpois(n(), lambda = lambda))
}
base_means <- c(A=10, C=7, E=6, B=9, D=6, F=5)
obs <- purrr::map_dfr(names(base_means), function(p) {
vp <- vol_profile(base_means[[p]])
vp$product <- p
vp
}) %>%
left_join(product_map, by = "product") %>%
mutate(
booking_date = as.Date(travel_day) - RD,
travel_date = travel_day,
volume = pmax(vol, 0L),
yield_usd = fare * runif(n(), 0.9, 1.1)
) %>%
select(travel_date, booking_date, RD, flight_no, product, volume, yield_usd, fare)
df_prep <- obs %>%
filter(volume > 0, is.finite(yield_usd)) %>%
mutate(RD = as.integer(travel_date - booking_date))
We list all products that will be optimized and assign them to a fare ladder within each flight. A fare ladder means prices decrease step-by-step from higher to lower products, without inversions. Formally, if product \(1\) is the highest-fare option and product \(m\) is the lowest-fare option for a given flight, we require:
\[ \underbrace{p_{1}}_{\text{highest fare}} \;\ge\; \underbrace{p_{2}}_{\text{next lower}} \;\ge\; \cdots \;\ge\; \underbrace{p_{m}}_{\text{lowest fare}} , \]
ensuring a monotone structure that reflects realistic airline pricing.
fare_map <- product_map %>% select(product, fare)
optim_set <- fare_map$product
opt_products <- product_map %>%
filter(product %in% optim_set) %>%
arrange(flight_no, desc(fare)) %>%
group_by(flight_no) %>%
mutate(rank_in_flight = row_number()) %>%
ungroup() %>%
mutate(prod_id = row_number(), kind = "opt")
K_opt <- nrow(opt_products)
alt_set <- opt_products %>%
select(flight_no, product, fare, kind) %>%
arrange(flight_no, desc(fare)) %>%
mutate(alt_id = row_number())
K_alt <- nrow(alt_set)
opt_idx <- (alt_set$kind == "opt")
Condition (monotone ladder): within each flight \(p_{1}\ge p_{2}\ge p_{3}\).
We (i) fit a booking-curve slope \(\gamma\), (ii) estimate price disutility \(\beta_p\) via PPML with choice-set fixed effects, (iii) set a WTP slope \(\lambda\) so the top price band holds ≈5% mass, and (iv) compute RD window weights.
Three RD windows \(\{27D, 14D, 7D\}\) with weights
\[ w(\text{win}) \;\propto\; \underbrace{\int_{R_{\text{low}}}^{R_{\text{high}}} e^{-\gamma r}\, dr}_{\text{integrated booking-curve decay}} = \frac{ \underbrace{e^{-\gamma R_{\text{low}}}}_{\text{weight at window start}} - \underbrace{e^{-\gamma R_{\text{high}}}}_{\text{weight at window end}} }{\underbrace{\gamma}_{\text{decay rate}}}. \]
Earlier windows carry lower marginal mass if \(\gamma > 0\); demand concentrates as departure nears.
For willingness-to-pay (WTP) bands, \(\lambda\) shapes how market mass decays as
price increases.
The mass of band \(j\) is:
\[ M_j \;=\; \underbrace{e^{-\lambda(\tau_j - p_0)}}_{\text{mass above lower threshold}} \;-\; \underbrace{e^{-\lambda(\tau_{j+1} - p_0)}}_{\text{mass above upper threshold}} \]
- \(M_j \ge 0\) for all bands.
- \(\sum_j M_j \le 1\) (total mass is
normalized).
- A larger \(\lambda\) means demand
decays faster across price levels, concentrating mass in lower
fares.
# RD windows and weights
rd_windows <- tibble(
label = c("27D","14D","7D"),
R_low = c(15, 8, 0),
R_high = c(27, 14, 7)
)
# booking-decay gamma
gamma_rd <- {
rd_fit <- df_prep %>% count(RD, wt = volume, name = "v") %>% filter(v>0) %>% mutate(lnv = log(v))
if (nrow(rd_fit) >= 3) {
g <- -coef(lm(lnv ~ RD, data = rd_fit))["RD"] %>% as.numeric()
max(1e-4, min(0.4, ifelse(is.finite(g), g, 0.08)))
} else 0.08
}
# helper: window label
label_for_RD <- function(RD) dplyr::case_when(
RD >= 15 & RD <= 27 ~ "27D",
RD >= 8 & RD <= 14 ~ "14D",
RD >= 0 & RD <= 7 ~ "7D",
TRUE ~ NA_character_
)
# PPML for price disutility beta_p
ppml_df <- df_prep %>%
mutate(win = label_for_RD(RD)) %>%
filter(!is.na(win)) %>%
semi_join(alt_set %>% select(flight_no, product), by = c("flight_no","product")) %>%
group_by(flight_no, win, product, fare) %>%
summarise(volume = sum(volume), .groups = "drop") %>%
mutate(choice_set = paste(flight_no, win, sep = "_"))
beta_p <- tryCatch({
fit <- glm(volume ~ fare + factor(choice_set), data = ppml_df, family = poisson(link="log"))
b <- as.numeric(coef(fit)["fare"])
if (!is.finite(b) || b >= 0) -0.02 else b
}, error = function(e) -0.02)
# WTP slope lambda so ~5% mass remains at top of price span
p_span <- range(alt_set$fare, na.rm = TRUE)
lambda_w <- {
rng <- diff(p_span); rng <- ifelse(is.finite(rng) && rng>0, rng, 100)
lam <- log(1/0.05)/rng
max(1e-4, min(0.4, lam))
}
# RD masses proportional to exp(-gamma*R)
dm_by_win <- rd_windows %>%
rowwise() %>%
mutate(DM = sum(df_prep %>% filter(RD >= R_low, RD <= R_high) %>% pull(volume))) %>%
ungroup() %>%
mutate(DM = ifelse(DM>0, DM, 1))
rd_weights <- rd_windows %>%
mutate(weight_raw = exp(-gamma_rd * R_low) - exp(-gamma_rd * R_high)) %>%
mutate(weight = weight_raw / sum(weight_raw)) %>%
select(label, weight)
We adjust the basic MNL shares so they better match the actual sales
mix we observe.
Each product’s share is multiplied by a weight based on how often it was
really chosen, instead of adding new intercepts for every product.
\[ \tilde{s}_{k|j} \;\propto\; \underbrace{s_{k|j}}_{\text{MNL share}} \cdot \underbrace{w_k^{\phi}}_{\text{observed sales weight}} \]
where
- \(s_{k|j}\) = original MNL share of
product \(k\) in band \(j\),
- \(w_k\) = relative weight from
observed data,
- \(\phi \in [0,1]\) = tuning factor (0
= ignore data, 1 = fully match data).
This way, products that sold more than expected get a higher adjusted share, and products that sold less get a lower adjusted share.
obs_alt <- df_prep %>%
group_by(flight_no, product) %>%
summarise(obs_vol = sum(volume), .groups = "drop")
obs_alt_full <- alt_set %>%
select(alt_id, flight_no, product) %>%
left_join(obs_alt, by = c("flight_no","product")) %>%
mutate(obs_vol = replace_na(obs_vol, 0))
eps_w <- 1e-3
obs_share <- (obs_alt_full$obs_vol + eps_w) / sum(obs_alt_full$obs_vol + eps_w)
w_calib <- (obs_share / mean(obs_share)) ^ PHI_CALIB
We build utility with price as an attribute, construct WTP (willingness-to-pay) bands from the current price vector, and aggregate over RD (reading-day) windows.
\[ \underbrace{U_k}_{\text{utility of product $k$}} \;=\; \underbrace{\beta_p}_{\substack{\text{price slope}\\ (\beta_p < 0)}} \cdot \underbrace{p_k}_{\text{price of product $k$}} \]
This means that if the price \(p_k\) goes up, the utility \(U_k\) goes down because \(\beta_p\) is negative.
For willingness-to-pay (WTP) bands, we compute the demand mass \(M_j\) between two thresholds \(\tau_j\) and \(\tau_{j+1}\):
\[ \underbrace{M_j}_{\text{mass in WTP band $j$}} \;=\; \underbrace{e^{-\lambda(\tau_j - p_0)}}_{\text{mass above lower threshold}} - \underbrace{e^{-\lambda(\tau_{j+1} - p_0)}}_{\text{mass above upper threshold}} . \]
Here:
- \(p_0\) is the minimum price
threshold.
- \(\tau_j\) are the cut-off points
that divide the price axis into bands.
- \(\lambda\) controls how fast demand
decays as prices increase.
make_util <- function(p_opt_vec) {
p_all <- alt_set$fare
p_all[opt_idx] <- p_opt_vec
U <- beta_p * p_all
list(p_all = p_all, U = U)
}
predict_D_all <- function(p_opt_vec) {
util <- make_util(p_opt_vec)
p_all <- util$p_all; U <- util$U; expU <- exp(U)
tau <- sort(unique(p_all)); tau_ext <- c(tau, Inf)
p0_min <- min(p_all)
Mj <- numeric(length(tau_ext) - 1)
for (j in seq_along(Mj)) {
Mj[j] <- exp(-lambda_w*(tau_ext[j]-p0_min)) - exp(-lambda_w*(tau_ext[j+1]-p0_min))
}
Mj[Mj < 0] <- 0
rd_tbl <- rd_weights %>% left_join(dm_by_win, by = "label") %>% transmute(wDM = weight * DM)
K_alt <- nrow(alt_set)
D_all <- numeric(K_alt)
for (j in seq_along(Mj)) {
Cj_mask <- (p_all <= tau_ext[j])
denom <- sum(expU[Cj_mask])
if (denom <= 0 || Mj[j] <= 0) next
share_j <- numeric(K_alt)
share_j[Cj_mask] <- expU[Cj_mask] / denom
share_j[Cj_mask] <- share_j[Cj_mask] * w_calib[Cj_mask]
share_j[Cj_mask] <- share_j[Cj_mask] / sum(share_j[Cj_mask])
w_contrib <- sum(rd_tbl$wDM) * Mj[j]
D_all <- D_all + w_contrib * share_j
}
D_all
}
predict_D <- function(p_opt_vec) predict_D_all(p_opt_vec)[opt_idx]
We keep capacities as defined by each flight. (No exogenous reservations in this toy example.)
effective_capacity <- function(p_vec_opt) {
cap_by_flight %>% arrange(flight_no) %>% pull(cap)
}
We solve the linearized allocation with GLPK. The min operator becomes two linear caps on sold seats.
\[ \underbrace{z_k \le D_k(p_k)}_{\text{demand cap}},\quad \underbrace{z_k \le q_{ik}}_{\text{allocation cap}}. \]
solve_ljom <- function(D_vec_opt, p_vec_opt, cap_eff_vec = NULL) {
K <- length(D_vec_opt)
prod_tbl <- opt_products %>% select(prod_id, flight_no)
flights_u <- unique(prod_tbl$flight_no)
if (is.null(cap_eff_vec)) {
cap_eff_vec <- cap_by_flight %>% arrange(flight_no) %>% pull(cap)
}
cap_map <- tibble(flight_no = sort(unique(cap_by_flight$flight_no)),
cap_eff = cap_eff_vec)
model <- MIPModel() %>%
add_variable(q[k], k = 1:K, type = "continuous", lb = 0) %>%
add_variable(z[k], k = 1:K, type = "continuous", lb = 0) %>%
set_objective(sum_expr(p_vec_opt[k] * z[k], k = 1:K), "max") %>%
add_constraint(z[k] <= D_vec_opt[k], k = 1:K) %>%
add_constraint(z[k] <= q[k], k = 1:K)
for (fl in flights_u) {
idx <- prod_tbl %>% filter(flight_no == fl) %>% pull(prod_id)
cap_eff <- cap_map %>% filter(flight_no == fl) %>% pull(cap_eff) %>% as.numeric()
model <- model %>% add_constraint(sum_expr(q[k], k = idx) <= cap_eff)
}
res <- solve_model(model, with_ROI(solver = "glpk"))
q_sol <- get_solution(res, q[k]) %>% arrange(k)
z_sol <- get_solution(res, z[k]) %>% arrange(k)
tibble(
prod_id = 1:K,
flight_no= opt_products$flight_no,
product = opt_products$product,
price = p_vec_opt,
D_pred = D_vec_opt,
q_alloc = q_sol$value,
z_sold = z_sol$value,
rev = p_vec_opt * z_sol$value
)
}
We make sure that fares in each flight follow a step-down order (highest to lowest) so that no lower product is priced above a higher one.
enforce_monotone_by_flight <- function(p_vec) {
pv <- p_vec
for (fl in unique(opt_products$flight_no)) {
idx <- which(opt_products$flight_no == fl)
idx <- idx[order(opt_products$rank_in_flight[idx])] # 1 = highest fare
for (t in seq_along(idx)[-1]) {
i_prev <- idx[t - 1]; i_curr <- idx[t]
pv[i_curr] <- min(pv[i_curr], pv[i_prev])
}
}
pv
}
At each iteration, EPAH tests small up/down price moves for every product and .
\[ \underbrace{\Delta R_{i,d,s}}_{\text{revenue change}} \;=\; \underbrace{R\!\big(p^{(t)} \xrightarrow[]{i,d,s}\big)}_{\text{revenue after move on product }i} \;-\; \underbrace{R\!\big(p^{(t)}\big)}_{\text{current revenue}} , \] where the candidate move changes only product \(i\) in direction \(d \in \{\text{up},\text{down}\}\) by step size \(s\) (e.g., \(s=1\%\)), while preserving the fare ladder.
The algorithm picks \[ (i^\star,d^\star,s^\star) \;\in\; \arg\max_{i,d,s}\; \underbrace{\Delta R_{i,d,s}}_{\text{largest tested gain}} , \] and accepts the move only if \[ \Delta R_{i^\star,d^\star,s^\star} \;>\; \underbrace{\text{tol}}_{\text{termination threshold}} . \]
- Prices remain monotone within each flight (ladder constraint is re-enforced after each trial). - The search is : it finds the best gain among the tested directions/steps in this iteration. - Using a richer step grid (e.g., \(s\in\{0.5\%,1\%,2\%,5\%\}\)) or an adaptive line search can improve coverage.
own_elasticity <- function(p_vec, eps = 0.01) {
D0 <- predict_D(p_vec)
out <- rep(NA_real_, length(p_vec))
for (i in seq_along(p_vec)) {
p_try <- p_vec; p_try[i] <- p_try[i] * (1 + eps)
p_try <- enforce_monotone_by_flight(p_try)
D1 <- predict_D(p_try)
out[i] <- ifelse(D0[i] > 0, (p_vec[i]/D0[i]) * (D1[i]-D0[i]) / (p_vec[i]*eps), NA_real_)
}
out
}
EPAH <- function(p_init, steps = c(0.01), max_iter = 40, tol = 1e-6) {
p <- enforce_monotone_by_flight(p_init)
best <- NULL; best_R <- -Inf
for (it in seq_len(max_iter)) {
cap_eff <- effective_capacity(p)
D <- predict_D(p)
sol <- solve_ljom(D, p, cap_eff_vec = cap_eff)
R <- sum(sol$rev, na.rm = TRUE)
if (R > best_R + tol) {
best <- list(iter = it, p = p, sol = sol, revenue = R,
own_elast = own_elasticity(p))
best_R <- R
} else break
delta_R <- rep(-Inf, length(p))
dir_best <- character(length(p))
step_best<- numeric(length(p))
for (i in seq_along(p)) {
cand <- expand.grid(dir = c("up","down"), s = steps, KEEP.OUT.ATTRS = FALSE)
R_cand <- rep(-Inf, nrow(cand))
for (cc in seq_len(nrow(cand))) {
s <- cand$s[cc]; d <- cand$dir[cc]
p_try <- p; p_try[i] <- if (d=="up") p[i]*(1+s) else p[i]*(1-s)
p_try <- enforce_monotone_by_flight(p_try)
cap_eff_try <- effective_capacity(p_try)
D_try <- predict_D(p_try)
sol_try <- solve_ljom(D_try, p_try, cap_eff_vec = cap_eff_try)
R_cand[cc] <- sum(sol_try$rev, na.rm = TRUE)
}
j <- which.max(R_cand)
delta_R[i] <- R_cand[j] - R
dir_best[i] <- cand$dir[j]
step_best[i]<- cand$s[j]
}
i_star <- which.max(delta_R)
if (delta_R[i_star] <= tol) break
p[i_star] <- if (dir_best[i_star] == "up") p[i_star]*(1+step_best[i_star]) else p[i_star]*(1-step_best[i_star])
p <- enforce_monotone_by_flight(p)
}
best
}
p_base <- opt_products$fare
if (LOCK_PRICES) {
cap_eff <- effective_capacity(p_base)
D_fix <- predict_D(p_base)
sol_tbl <- solve_ljom(D_fix, p_base, cap_eff_vec = cap_eff)
res <- list(p = p_base, sol = sol_tbl, revenue = sum(sol_tbl$rev), own_elast = own_elasticity(p_base))
} else {
res <- EPAH(p_init = p_base, steps = EPAH_STEPS, max_iter = EPAH_MAX_ITER, tol = EPAH_TOL)
}
flight_summary <- res$sol %>%
left_join(cap_by_flight, by = "flight_no") %>%
group_by(flight_no, cap) %>%
reframe(
seats_alloc = sum(q_alloc), # \sum_k q_k
seats_sold = sum(z_sold), # \sum_k z_k
revenue = sum(rev),
avg_price = revenue / pmax(seats_sold, 1e-9),
slack = cap - seats_alloc
) %>% arrange(flight_no)
flight_summary
by_product <- res$sol %>%
left_join(cap_by_flight, by = "flight_no") %>%
group_by(flight_no) %>%
mutate(
flight_seats_alloc = sum(q_alloc),
flight_seats_sold = sum(z_sold),
flight_revenue = sum(rev),
flight_slack = cap - flight_seats_alloc
) %>%
ungroup() %>%
arrange(flight_no, desc(rev)) %>%
transmute(
flight_no, product,
price_final = price,
D_pred = D_pred,
q_alloc,
z_sold,
rev = rev,
cap, flight_seats_alloc, flight_seats_sold, flight_revenue, flight_slack
)
by_product
tibble(
product = opt_products$product,
flight = opt_products$flight_no,
price = res$p,
own_elasticity = res$own_elast
) %>% arrange(flight, dplyr::desc(price))
eps_fd <- 0.01 # finite-difference step for elasticity (±1%)
mult_grid <- seq(0.80, 1.20, 0.02) # explore ±20% around current price
p0 <- res$p # base prices (from EPAH result)
curve_df <- map_dfr(seq_along(p0), function(i) {
map_dfr(mult_grid, function(m) {
# center price vector with only product i moved by multiplier m
p_center <- p0
p_center[i] <- p0[i] * m
p_center <- enforce_monotone_by_flight(p_center) # keep ladder valid
# demand at center
D_center <- predict_D(p_center)
# central-difference around the center for product i
p_up <- p_center; p_up[i] <- p_center[i] * (1 + eps_fd); p_up <- enforce_monotone_by_flight(p_up)
p_dn <- p_center; p_dn[i] <- p_center[i] * (1 - eps_fd); p_dn <- enforce_monotone_by_flight(p_dn)
D_up <- predict_D(p_up)
D_dn <- predict_D(p_dn)
Dk <- D_center[i]
dDdp <- (D_up[i] - D_dn[i]) / (2 * p_center[i] * eps_fd)
elast <- ifelse(Dk > 0, (p_center[i] / Dk) * dDdp, NA_real_)
tibble(
product = opt_products$product[i],
flight = opt_products$flight_no[i],
multiplier = m,
price = p_center[i],
Dk = Dk,
elasticity = elast
)
})
})
# 1) Elasticity curve (ε_k vs price)
ggplot(curve_df, aes(x = price, y = elasticity, color = product)) +
geom_hline(yintercept = -1, linetype = "dashed") +
geom_line(linewidth = 1) +
facet_wrap(~ flight, scales = "free_x", labeller = label_both) +
labs(
title = "Own-price elasticity curves by product",
subtitle = "Dashed line at ε = -1 (unit elasticity)",
x = "Price",
y = "Elasticity (ε_k)"
) +
theme_minimal() +
theme(legend.position = "bottom")
# 2) (Optional) Demand vs price curve to visualize slope dD/dp
ggplot(curve_df, aes(x = price, y = Dk, color = product)) +
geom_line(linewidth = 1) +
facet_wrap(~ flight, scales = "free_x", labeller = label_both) +
labs(
title = "Demand vs price by product",
x = "Price",
y = "Predicted demand D_k"
) +
theme_minimal() +
theme(legend.position = "bottom")
Note. The numerical results presented here are generated from strictly hypothetical data. This explains the unusually high elasticity magnitudes observed and high slack, which should not be interpreted as reflective of actual airline markets.
cat("Parameters driving Eq. (3) aggregation & utility\n",
"gamma_rd =", round(gamma_rd, 4), "\n",
"beta_p =", round(beta_p, 4), "\n",
"lambda_w =", round(lambda_w, 4), "\n",
if (LOCK_PRICES) "Mode: FIXED PRICES (L-JOM only)\n" else "Mode: EPAH price optimisation (L-JOM in loop)\n",
"Calibration strength =", PHI_CALIB, "\n")
## Parameters driving Eq. (3) aggregation & utility
## gamma_rd = 0.0918
## beta_p = -0.02
## lambda_w = 0.0182
## Mode: EPAH price optimisation (L-JOM in loop)
## Calibration strength = 0.5