Skip to contents

lactater turns the stages of a step test into lactate thresholds, checks them, and puts them to use. Find what you want to do, and the function that does it:

You want to Use Read more
Before the test
Record every test the same way The web form, read_recording(), recording_sheet() Best practices
Analyze it
Estimate every threshold lactate_threshold() The methods and the evidence
See how the methods agree autoplot()
Check the data and the curve lactate_checks(), lactate_diagnostics() Checking the analysis
Know how sure each threshold is ci = TRUE Checking the analysis
Use it
Build three or five training zones lactate_zones() Training zones
Count the training in each zone training_load() Training load
Predict race performance predict_performance(), calibrate_performance() Predicting performance
Compare two tests, or follow a series compare_tests(), curve_shape(), lactate_trend() Comparing tests
Compare with the population of the same age and sex reference_percentiles() Comparing tests
Share it
Report the test to an athlete or a coach test_report()
A table for a paper lactate_report()

Your data

One row per stage of a step test: the intensity (power in W, or speed), the lactate measured at the end of the stage and, if you have them, the heart rate and the rating of perceived exertion (RPE). A resting sample, if you took one, is the row with intensity 0.

demo_data
#> # A tibble: 8 × 6
#>    step length intensity lactate heart_rate   rpe
#>   <int>  <int>     <int>   <dbl>      <int> <int>
#> 1     0      0         0    0.93         96     6
#> 2     1      3        50    0.98        114     7
#> 3     2      3        75    1.23        134     8
#> 4     3      3       100    1.88        154    11
#> 5     4      3       125    2.8         170    12
#> 6     5      3       150    4.21        182    14
#> 7     6      3       175    6.66        193    17
#> 8     7      2       191    8.64        198    19

Your own test comes in with read.csv("test.csv") in those columns, or with read_recording() from the .xlsx of the web form. Rows with a missing lactate (a missed sample) are dropped with a warning. If the last stage was not completed, give the minutes completed in last_stage_duration (with stage_duration) and its intensity is time-weighted for you: 2 of 3 min at 200 W after 175 W count as 191.7 W (demo_data already has its 2-min last stage at 191 W). read_recording() does the same from the form’s time in the last stage. Or leave the stage out with max_intensity. How to collect data that does the methods justice is in Best practices for a step test.

Estimate the thresholds

results <- lactate_threshold(
  demo_data,
  intensity_column = intensity,
  lactate_column = lactate,
  heart_rate_column = heart_rate,
  rpe_column = rpe,
  plot = FALSE
)
results
#> # Lactate thresholds: cycling, 3rd degree polynomial, 7 stages + rest
#> # LT1:                83 W (Bsln + 0.5)
#> # LT2:                149 W (Log-Poly-ModDmax)
#>    method_category method           fitting   intensity lactate heart_rate   rpe
#>    <fct>           <fct>            <chr>         <dbl>   <dbl>      <dbl> <dbl>
#>  1 Log-log         Log-log          3rd degr…       101     1.8        154  11  
#>  2 OBLA            OBLA 2.0         3rd degr…       106     2          158  11.2
#>  3 OBLA            OBLA 2.5         3rd degr…       119     2.5        166  11.8
#>  4 OBLA            OBLA 3.0         3rd degr…       130     3          172  12.4
#>  5 OBLA            OBLA 3.5         3rd degr…       139     3.5        177  13.1
#>  6 OBLA            OBLA 4.0         3rd degr…       146     4          180  13.7
#>  7 Bsln+           Bsln + 0.5       3rd degr…        83     1.4        140   8.9
#>  8 Bsln+           Bsln + 1.0       3rd degr…       103     1.9        156  11.1
#>  9 Bsln+           Bsln + 1.5       3rd degr…       118     2.4        165  11.7
#> 10 Dmax            Dmax             3rd degr…       132     3.1        173  12.5
#> 11 Dmax            ModDmax          3rd degr…       140     3.6        177  13.2
#> 12 Dmax            Exp-Dmax         Exponent…       135     3.3        175  12.8
#> 13 Dmax            Log-Poly-ModDmax 3rd degr…       149     4.2        182  13.9
#> 14 Dmax            Log-Exp-ModDmax  Exponent…       152     4.4        183  14.2
#> 15 LTP             LTP1             3rd degr…       111     2          161  11.4
#> 16 LTP             LTP2             3rd degr…       160     4.7        186  15.1
#> 17 LTratio         LTratio          B-Spline…        77     1.3        136   8.3
#> 18 IAT             IAT              B-Spline…       125     2.8        170  12

The column arguments can also go by position, as in the calls below: lactate_threshold(demo_data, intensity, lactate, heart_rate). Every method runs by default; pick some with method, e.g. method = c("Dmax", "LTP"), and set sport for running, swimming or rowing. rpe_column gives the RPE at each threshold (add rpe_scale = "CR10" for the 0-10 scale; the RPE of demo_data is simulated).

Two of these thresholds are what training needs. LT1 is the first rise of lactate above resting values, the top of the moderate intensity domain; LT2 is the second, abrupt rise, the top of the heavy domain. The methods aim at one of them or at a fixed lactate value, the three categories of Faude et al. (2009): the first rise (Log-log, Bsln + 0.5 and 1.0, LTratio, LTP1), the second rise or the maximal lactate steady state (Bsln + 1.5, the Dmax family, LTP2, IAT, Stegmann, and Keul and Simon for running), and fixed values (OBLA). autoplot() groups them this way, and The methods and the evidence says which to trust. A threshold is NA when its method found nothing in the tested range: the Log-log lines crossing outside the stages, a Dmax curve never below its line, LTP2 in a test that stopped early. lactate_checks() names them and says what to do.

Look at them

The overview puts every threshold on the same intensity axis as the curve, one row per method, with a table of the values at each threshold:

autoplot(results)

Or look at one method in detail:

autoplot(results, method = "ModDmax")

Check the analysis

lactate_checks() flags what is easy to miss in a table of numbers, and lactate_diagnostics() checks the fitted curve and how much each threshold rests on single stages:

lactate_checks(results)
#> 10 checks: 9 passed, 1 note, 0 warnings
#> ℹ Stages             7 stages: the LTP fit has 6 parameters, so 1 degree of freedom is left and LTP can move a lot with one stage (at least 8 stages are better); the fitted curves rest on few points too.
#> ✔ Resting sample     Present.
#> ✔ Thresholds found   All thresholds were found.
#> ✔ Edge of the range  No threshold sits at the first or last stage.
#> ✔ Early lactate dip  No early dip.
#> ✔ Final stage        Lactate rose in the last stage.
#> ✔ Peak lactate       8.6 mmol/L.
#> ✔ Sample type        blood lactate.
#> ✔ LT1                Within 18% of peak (5 methods, LTratio 77 to LTP1 111).
#> ✔ LT2                Within 22% of peak (8 methods, Bsln + 1.5 118 to LTP2 160).
diagnostics <- lactate_diagnostics(results)
diagnostics
#> Lactate curve: 3rd degree polynomial on 7 points; residual SD 0.07 mmol/L.
#> Influential stage(s): 50 W (Cook's distance 6.05).
#> Most sensitive to single stages: LTP1 (jackknife SE 31 W), Log-log (jackknife SE 29 W), LTP2 (jackknife SE 18 W).
#> Best-predicting curve: Exponential, which missed each stage by 0.14 mmol/L on average when fitted without it (root-mean-square leave-one-out error).
autoplot(diagnostics)

How sure are we?

With 6 to 10 stages, a threshold reported to 0.1 W is false precision. ci = TRUE adds bootstrap confidence intervals (this takes a few seconds; use more replicates than this for real work):

set.seed(2024)
with_ci <- demo_data |>
  lactate_threshold(
    intensity, lactate, heart_rate,
    method = c("OBLA", "Dmax", "LTP"), plot = FALSE, ci = TRUE, n_boot = 200
  )

with_ci |>
  select(method, intensity, intensity_low, intensity_high)
#> Warning: Unknown or uninitialised column: `method_category`.
#> # Lactate thresholds: cycling, 3rd degree polynomial, 7 stages + rest
#> # LT2:                149 W (Log-Poly-ModDmax)
#>    method           intensity intensity_low intensity_high
#>    <fct>                <dbl>         <dbl>          <dbl>
#>  1 OBLA 2.0               106           100            110
#>  2 OBLA 2.5               119           116            123
#>  3 OBLA 3.0               130           127            133
#>  4 OBLA 3.5               139           136            141
#>  5 OBLA 4.0               146           143            149
#>  6 Dmax                   132           124            138
#>  7 ModDmax                140           132            151
#>  8 Exp-Dmax               135           133            137
#>  9 Log-Poly-ModDmax       149           146            161
#> 10 Log-Exp-ModDmax        152           150            162
#> 11 LTP1                   111            75            123
#> 12 LTP2                   160           140            171

Training zones

By default LT1 is Bsln + 0.5 and LT2 Log-Poly-ModDmax, the pair with the best evidence for each boundary (Training zones says why); choose others with lt1 and lt2, and three or five zones with model:

zones <- lactate_zones(results, model = 5)
zones
#> # A tibble: 5 × 11
#>   zone   domain  description intensity_from intensity_to lactate_from lactate_to
#>   <chr>  <chr>   <chr>                <dbl>        <dbl>        <dbl>      <dbl>
#> 1 Zone 1 modera… below LT1               NA           62         NA          1.1
#> 2 Zone 2 modera… below LT1               62           83          1.1        1.4
#> 3 Zone 3 heavy   LT1 to LT2              83          149          1.4        4.2
#> 4 Zone 4 severe  above LT2              149          170          4.2        6.1
#> 5 Zone 5 severe  above LT2              170           NA          6.1       NA  
#> # ℹ 4 more variables: heart_rate_from <dbl>, heart_rate_to <dbl>,
#> #   rpe_from <dbl>, rpe_to <dbl>
autoplot(zones)

A table for your report

Lactate thresholds
Cycling · 3rd degree polynomial fit · 7 stages + rest
Method Intensity (W) Lactate (mmol/L) Heart rate (bpm) RPE (6-20)
Log-log
Log-log 101 1.8 154 11.0
OBLA
OBLA 2.0 106 2.0 158 11.2
OBLA 2.5 119 2.5 166 11.8
OBLA 3.0 130 3.0 172 12.4
OBLA 3.5 139 3.5 177 13.1
OBLA 4.0 146 4.0 180 13.7
Bsln+
Bsln + 0.5 83 1.4 140 8.9
Bsln + 1.0 103 1.9 156 11.1
Bsln + 1.5 118 2.4 165 11.7
Dmax
Dmax 132 3.1 173 12.5
ModDmax 140 3.6 177 13.2
Exp-Dmax 135 3.3 175 12.8
Log-Poly-ModDmax 149 4.2 182 13.9
Log-Exp-ModDmax 152 4.4 183 14.2
LTP
LTP1 111 2.0 161 11.4
LTP2 160 4.7 186 15.1
LTratio
LTratio 77 1.3 136 8.3
IAT
IAT 125 2.8 170 12.0
ℹ Stages: 7 stages: the LTP fit has 6 parameters, so 1 degree of freedom is left and LTP can move a lot with one stage (at least 8 stages are better); the fitted curves rest on few points too.
Made with lactater

For the athlete or the coach, test_report() writes the whole test as one web page (and PDF): their details, LT1 and LT2, the zones, every threshold, the predicted performance, the checks and the stages (see a cyclist and a runner). Give it two to five tests of one athlete and their dates, and it reports the progress over them (see an example).

Many tests at once

Use .by with the columns that identify each test. lactate_tests holds 16 tests across sports and fitness levels; here, the six tests of one cyclist:

lactate_tests |>
  filter(athlete == "cyclist_series") |>
  lactate_threshold(intensity, lactate, method = "Dmax", dmax_methods = "ModDmax", plot = FALSE, .by = "day")
#> # A tibble: 6 × 6
#>     day method_category method  fitting                        intensity lactate
#>   <int> <fct>           <fct>   <chr>                              <dbl>   <dbl>
#> 1     0 Dmax            ModDmax 3rd degree polynomial (defaul…       290     2.3
#> 2    58 Dmax            ModDmax 3rd degree polynomial (defaul…       327     2.9
#> 3   163 Dmax            ModDmax 3rd degree polynomial (defaul…       328     3.1
#> 4  1107 Dmax            ModDmax 3rd degree polynomial (defaul…       270     3  
#> 5  1865 Dmax            ModDmax 3rd degree polynomial (defaul…       325     2.5
#> 6  2046 Dmax            ModDmax 3rd degree polynomial (defaul…       364     4

Learn more