This year I’ve decided unilaterally that we’re tackling Alpe du Zwift instead. This is a approximately 1 hour effort for most normal people, so I may need to refine my methodology. People might not necessarily have done a full gas 60+ minute effort in the last 90 days, and a critical power model won’t necessarily be valid over this duration. So Let’s explore some approaches we can take.
12.2km at an average gradient of 8%. Graphic from VeloViewer.
Use best previous efforts?
The most straightforward way would be to look at each rider’s best effort up the Alpe:
The problem here is the most recent of these efforts was over a year ago, and the latest half a decade ago. And one of the contestents has never attempted it. So we’re going to have to use a model to account for people’s changes in fitness.
Estimate plausible times from a model?
Modelling time to complete the climb for a given w/kg
We can start by building a model of time to climb the segment. Similar to previous approaches, I can pull the best efforts for my team:
Show code
data_for_plot <- segment_data_tv %>%select(zwid, power, weight, elapsed) %>%mutate(wkg = power/weight,elapsed_h = elapsed /3600)data_for_plot %>%ggplot() +aes(x = wkg, y = elapsed_h) +geom_point() +scale_x_continuous(name ="Average W/kg", breaks =seq(0.5, 5, by =0.5), minor_breaks =seq(0.5, 5, by =0.1)) +scale_y_continuous(name ="Elapsed time (hours)") +theme_bw()
This once again looks quite power law so we can try to fit \(t = \alpha (\text{w/kg}) ^ \beta\), given that on Zwift the effects of weather changes are non-existence and rolling resistance shouldn’t change (unless someone tried their best effort on a mountain bike):
Turning power-curves into plausible power-duration models
Next step is to pull everyone’s power curves from ZwiftPower:
Show code
power_curves %>%ggplot() +aes(x = x, y = y_90) +geom_line() +scale_x_log10() +labs(x ="Duration (seconds)", y ="Maximal power (W)") +facet_wrap(~id, nrow =4) +theme_bw() +theme(strip.background =element_blank(),strip.text =element_blank() )
The problem here is that it’s clear some people haven’t done maximal efforts beyond 40-50 minutes in the last 90 days, as shown by dramatic steps in what should be a smooth curve. This means that modelling time to climb a ~60 minute effort will potentially be a huge under-estimate, and their time delay will be inappapriately huge, leading to a fairly boring chase race.
Here’s one particular offender. Names are removed to protect the innocent, but despite clearly exceptional sprint power he hasn’t done a maximal effort beyond 40 minutes (dotted line) in the last three months:
Show code
mmp_offender %>%ggplot() +aes(x = x, y = y_90) +geom_point() +scale_x_log10() +geom_vline(xintercept =40*60, linetype ="dotted") +labs(x ="Duration (seconds)", y ="Maximal power (W)") +theme_bw()
Option 1: Critical power model
We could try to fit a CP model to this using their 1 to 15 minute power:
This might work, but CP tends to over-estimate the true power that can be held for longer duration efforts - you can already see the model diverging around the 30 minute mark.
Option 2: Power-law tail model
Instead we need a “credible” modelled power-duration function, using the observed maximal power over a reasonable endurance-ish timeframe (say 10 to 30 minutes) and extrapolating a tail from this using a power law. We also need a function to detect if they’ve been sandbagging even within this window, and cut it off at any obvious step.
Show code
# Clean up the power curveprep_mmp <-function(mmp) { mmp %>%transmute(t =as.numeric(x), P =as.numeric(y_90)) %>%filter(is.finite(t), is.finite(P), t >0, P >0) %>%group_by(t) %>%summarise(P =max(P), .groups ="drop") %>%arrange(t)}# Fit a power-law tail on 10-30 minute effortsfit_tail_powerlaw <-function(df,t_min =10*60,t_max =30*60) { d <- df %>%filter(t >= t_min, t <= t_max) %>%mutate(logt =log(t), logP =log(P)) fit <-lm(logP ~ logt, data = d)list(a =exp(unname(coef(fit)[["(Intercept)"]])),b =unname(coef(fit)[["logt"]]),fit = fit )}# Detect significant steps in the power curvetail_missing_flag <-function(df, tail,check_s =3600,ratio_cut =0.90) { P_obs <-approxfun(df$t, df$P, rule =2) P_pred <- tail$a * (check_s ^ tail$b)P_obs(check_s) < ratio_cut * P_pred}# Predict the power curvemake_power_predictor <-function(mmp,switch_s =1800) { df <-prep_mmp(mmp) tail <-fit_tail_powerlaw(df, t_min =600, t_max =1800) missing_tail <-tail_missing_flag(df, tail, check_s =3600, ratio_cut =0.90)# Cut at 20 min if tail missing switch_eff <-if (missing_tail) 20*60else switch_s P_obs <-approxfun(df$t, df$P, rule =2) P_tail <-function(t) tail$a * (t ^ tail$b) P_hat <-function(t) ifelse(t <= switch_eff, P_obs(t), P_tail(t))list(P_hat = P_hat,tail_a = tail$a, tail_b = tail$b,switch_s = switch_eff,tail_missing = missing_tail,df = df )}
Given we now have more reliable looking estimates of power-duration, we can solve for time up the Alpe in a similar way to before. Plotting these predictions on the previous attempts looks reassuring:
Show code
# Helper function to turn wkg into a predicted timetime_from_wkg <-function(wkg, wf_fit) {stopifnot(all(is.finite(wkg)), all(wkg >0)) new_data <-tibble(ln_wkg =log(wkg)) ln_t_hat <-predict(wf_fit, new_data = new_data) %>%pull(.pred)as.numeric(exp(ln_t_hat))}# Function to predict time up AdZ which we can itterate over riderspredict_adz_time <-function(weight_kg, power_model, time_model = lm_fit,t_lower =20*60, t_upper =120*60,use_mean =FALSE) { time_fun <-if (use_mean) time_from_wkg_mean else time_from_wkg f <-function(T) { wkg <- power_model$P_hat(T) / weight_kgtime_fun(wkg, time_model) - T } out <-tryCatch(uniroot(f, lower = t_lower, upper = t_upper),error =function(e) NULL)if (is.null(out)) return(NA_real_) out$root}# Do the predictionriders_pred <- riders %>%mutate(power_model =map(mmp, make_power_predictor),adz_time_s =map2_dbl( weight_kg, power_model,~predict_adz_time(weight_kg = .x, power_model = .y, time_model = lm_fit,t_lower =10*60, t_upper =150*60, use_mean =FALSE) ),wkg_at_pred =map2_dbl(power_model, adz_time_s, \(pm, t) pm$P_hat(t)) / weight_kg,tail_missing =map_lgl(power_model, "tail_missing"),switch_min =map_dbl(power_model, "switch_s")/60 )data_for_plot %>%ggplot() +aes(x = wkg, y = elapsed_h) +geom_point() +geom_point(data = riders_pred, aes(x = wkg_at_pred, y = adz_time_s/(60*60)), color ="red") +scale_x_continuous(name ="Average W/kg", breaks =seq(0.5, 5, by =0.5), minor_breaks =seq(0.5, 5, by =0.1)) +scale_y_continuous(name ="Elapsed time (hours)") +theme_bw()
Finally, it goes without saying that this is very much a back of the envelope calculation to allow me to set vaguely competitive time delays. There are a load of assumptions involved, and the potential error in the power-law model will get wider the further it gets from the window used to derive it, so I’ll be interested to see how the results stack up.