Script for Calculating Jack Daniels VDOT

Does anyone have a script for calculating VDOT on intervals.icu? I haven’t coded in years. One of the chat bots gave me the code below, but it doesn’t work.

// Only calculate this for running activities
if (activity.type === ‘Run’ && icu.paceCurve) {

// Retrieve your best 5000m (5K) time in seconds from the activity pace curve
let time5k = icu.paceCurve.getDuration(5000); 

if (time5k && time5k > 0) {
  // Convert total seconds into minutes (decimal format)
  let t = time5k / 60; 
  
  // Calculate velocity in meters per minute
  let v = 5000 / t; 
  
  // 1. Calculate the submaximal oxygen cost for this velocity (Daniels Formula)
  let vo2Cost = -4.60 + 0.182258 * v + 0.000104 * Math.pow(v, 2);
  
  // 2. Calculate the fraction of VO2max sustained for this duration (Drop-off Formula)
  let percentVO2max = 0.8 + 0.2989558 * Math.exp(-0.1932605 * t) + 0.1418916 * Math.exp(-0.0159384 * t);
  
  // 3. Compute VDOT (VO2 Cost / Fraction)
  let vdot = vo2Cost / percentVO2max;
  
  // Round to 1 decimal place to match traditional VDOT tables
  Math.round(vdot * 10) / 10;
} else {
  null; // Returns null if a 5K segment wasn't found in the activity
}

} else {
null; // Returns null for non-running activities
}

Got it! Chat bot used an incorrect function and two incorrect parameters. Also made it public so you should be able to add it as a custom field.

{
  // Only calculate this for running activities
  if (activity.type === 'Run') {
    
    // Retrieve elapsed time (seconds) and convert into minutes (decimal format) 
    let t = icu.activity.elapsed_time / 60;
    
    // Calculate velocity in meters / minute
    let v = icu.activity.distance / t;

    // Calculate the submaximal oxygen cost for this velocity (Daniels Formula)
    let vo2Cost = -4.60 + 0.182258 * v + 0.000104 * Math.pow(v, 2);
      
    // Calculate the fraction of VO2max sustained for this duration (Drop-off Formula)
    let percentVO2max = 0.8  + 0.1894393 * Math.exp(-0.012778 * t) + 0.2989558 * Math.exp(-0.1932605 * t);
      
    // Compute VDOT (VO2 Cost / Fraction)
    let vdot = vo2Cost / percentVO2max;
      
    // Round to 1 decimal place to match traditional VDOT tables
    Math.round(vdot * 10) / 10;
  } 
  else {
    null; // Returns null for non-running activities
  }
}

I would suggest to use
icu.activity.icu_distance / t;

distance isn’t always available at the time of processing custom scripts, so my suggestion is to use icu_distance instead.

@R2Tom thanks for the suggestion! I tried it on three recent runs and two had ‘null’ values for icu.activity.icu_distance and, therefore, calculated an incorrect VDOT. Might be an artifact of these runs having already been uploaded to Intervals. Also not sure if it matters if the activities game straight to Intervals from the original platform (eg Garmin) or via Strava. Thoughts?

Sure, you didn’t have a typo?

Checked on a recent walk from Garmin, and it has the field icu_distance

But may be that this doesn’t exist for Strava ones, this post may suggest something like this

Yes, I double checked to ensure I didn’t have a type-o. Using console.log to show icu_distance resulted in ‘null’.

Where do I go to see the data fields like you screen captured above?

1 Like

I use this script to show all activity fields

{
sl=icu.activity;
for (const property in sl) {
  console.log(\`${property}: ${sl\[property\]}\`);
}
}

So, I think more and more to use distance for Strava activities and icu_distance for non Strava ones.

Thanks! I was thinking the same … will need to work on it this afternoon. Thanks for the help!!

1 Like

Here is the updated script. I could probably do some additional error handling but it seems to work.

{
  // Only calculate this for running activities
  if (activity.type === 'Run') {
    
    // Get activity distance, preferably from native activity source,
    // otherwise from Strava
    let dist = null;

    if (icu.activity.icu_distance != null) {
      dist = icu.activity.icu_distance;
    }
    else if (icu.activity.distance != null) {
      dist = icu.activity.distance;
    }

    // Calculate VDOT if there is a valid distance, otherwise return 0
    if (dist != null) {
      // Retrieve elapsed time (seconds) and convert into minutes (decimal format)
      let t = icu.activity.elapsed_time / 60;

      // Calculate velocity in meters / minute
      let v = dist / t;

      // Calculate the submaximal oxygen cost for this velocity (Daniels Formula)
      let vo2Cost = -4.60 + 0.182258 * v + 0.000104 * Math.pow(v, 2);
      
      // Calculate the fraction of VO2max sustained for this duration (Drop-off Formula)
      let percentVO2max = 0.8
        + 0.1894393 * Math.exp(-0.012778 * t)
        + 0.2989558 * Math.exp(-0.1932605 * t);
      
      // Compute VDOT (VO2 Cost / Fraction)
      let vdot = vo2Cost / percentVO2max;
      
      // Round to 1 decimal place to match traditional VDOT tables
      Math.round(vdot * 10) / 10;
    }
    else {
      0;
    }
  }
  else {
    null; // Returns null for non-running activities
  }
}
1 Like

Interesting. I challenged it with Claude:

The core math is right — Daniels’ oxygen-cost and %VO2max formulas are transcribed correctly, and the VO2/%VO2max division matches the standard VDOT derivation. But there are a few real issues:

**1. `elapsed_time` instead of moving/moving_time**

Elapsed time includes stoplights, water stops, GPS pauses. That understates velocity and understates VDOT. Use `icu_moving_time` (or `icu.activity.moving_time`) instead.

**2. Whole-activity average pace is the biggest problem**

Daniels’ formula assumes a steady, near-maximal effort (like a race or time trial). Applying it to the average pace of an *entire* activity — easy runs, warm-up/cooldown, interval sessions with recovery jogs — produces a VDOT that’s not physiologically meaningful. A hard interval session with slow rest reps will show a garbage-low VDOT; an easy run will show something arbitrarily low too. This script will output a number for every run, but it’s only valid for races/time-trial-style efforts.

**3. No guard against zero/degenerate values**

`dist != null` lets `dist = 0` through (0 is not null). If distance is 0 with nonzero time, you get `v = 0` → `vo2Cost = -4.60` → negative VDOT. Add `dist > 0 && t > 0` checks.

**4. No sanity bounds**

Daniels’ drop-off formula is calibrated roughly for 3–150 minute efforts. Feeding it a 90-second strides or a 4-hour ultra will extrapolate the exponential terms outside their validated range and give you numbers that look plausible but aren’t backed by the original data.

**Suggested fix direction:**

- Switch to moving time.

- Add `dist > 0 && t > 3 && t < 180` (or similar) sanity guard, returning `null`/`0` outside it.

- Consider restricting the calculation to activities tagged as races or tempo/threshold-type efforts, not everything — or better, run it against best-effort pace over a chosen distance rather than whole-activity average, since that’s how VDOT is actually meant to be derived.

If you’re using this only on races/steady tempo runs and already discard the number for interval days, points 1 and 3 are still worth fixing, but 2 is less of a concern for you specifically.

  1. elapsed_timemoving_time as the time source — elapsed time counts stops/pauses.
  2. dist != nulldist > 0 (plus a moving_time > 0 check) — closes the zero-distance edge case that produced negative VDOT.
  3. Added duration bounds (3–180 min) — outside that range Daniels’ formula isn’t valid, so it returns 0 instead of an extrapolated number.
  4. Calculation basis changed from whole-activity average to per-interval, filtered to zone >= 4, duration-weighted — instead of one calculation using total distance/time, it loops icu.activity.icu_intervals, computes VDOT for each interval at zone ≥4, and takes a weighted average — because whole-activity average pace mixes in recovery/warm-up and isn’t a valid single input.
  5. Requires ≥6 min of total qualifying (zone ≥4) time before returning a value, else returns 0 — replaces the original’s single “else 0” (missing distance) with a broader “else 0” that also covers insufficient/no qualifying work, so easy and recovery days consistently return 0 instead of a number.
{
  const valid = new Set(['Run', 'VirtualRun']);
  if (!valid.has(activity.type)) {
    null;
  } else {

    const times = icu.streams.time;
    const dists = icu.streams.distance;
    const intervals = icu.activity.icu_intervals || [];

    const ZONE_THRESHOLD = 4;      // zone >= 4 treated as qualifying "work"
    const MIN_WORK_SECONDS = 360;  // require >=6 min of qualifying work
    const COARSE = [180, 240, 300, 360, 480, 600, 720, 900,
                     1200, 1500, 1800, 2400, 3000, 3600, 4500, 5400];
    const FINE_EVALS = 12;

    let result = 0;

    function evalWindow(windowSec, s, e) {
      let tMinutes = windowSec / 60;
      let percentVO2max = 0.8
        + 0.1894393 * Math.exp(-0.012778 * tMinutes)
        + 0.2989558 * Math.exp(-0.1932605 * tMinutes);

      let bestV = null;
      let rightIdx = s;
      let stride = (e - s) > 2400 ? 4 : ((e - s) > 1200 ? 3 : ((e - s) > 600 ? 2 : 1));

      for (let i = s; i <= e; i += stride) {
        let tEnd = times[i] + windowSec;
        if (tEnd > times[e]) break;
        while (rightIdx <= e && times[rightIdx] < tEnd) rightIdx++;

        let distDiff = dists[rightIdx] - dists[i];
        if (distDiff > 0) {
          let v = (distDiff / windowSec) * 60;
          let vo2Cost = -4.60 + 0.182258 * v + 0.000104 * v * v;
          let vdot = vo2Cost / percentVO2max;
          if (bestV === null || vdot > bestV) bestV = vdot;
        }
      }
      return bestV;
    }

    if (times && dists && times.length === dists.length && times.length > 1) {

      // Only zone >=4 segments qualify as genuine work effort
      let qualifying = intervals.filter(iv =>
        iv.zone != null && iv.zone >= ZONE_THRESHOLD &&
        iv.start_index != null && iv.end_index != null &&
        iv.end_index > iv.start_index
      );
      qualifying.sort((a, b) => a.start_index - b.start_index);

      // Merge contiguous qualifying laps into continuous work blocks
      let blocks = [];
      for (let iv of qualifying) {
        let last = blocks[blocks.length - 1];
        if (last && iv.start_index <= last.end_index) {
          last.end_index = Math.max(last.end_index, iv.end_index);
        } else {
          blocks.push({ start_index: iv.start_index, end_index: iv.end_index });
        }
      }

      let vdotSum = 0;
      let weightSum = 0;

      for (let block of blocks) {
        let s = block.start_index;
        let e = Math.min(block.end_index, times.length - 1);
        if (e <= s) continue;

        let blockDuration = times[e] - times[s];
        if (blockDuration < COARSE[0]) continue;

        // Coarse pass: locate the best neighborhood
        let bestVdot = null, bestWindow = 0, bestIdx = -1;
        let validCoarse = COARSE.filter(w => w <= blockDuration);

        for (let ci = 0; ci < validCoarse.length; ci++) {
          let v = evalWindow(validCoarse[ci], s, e);
          if (v !== null && (bestVdot === null || v > bestVdot)) {
            bestVdot = v; bestWindow = validCoarse[ci]; bestIdx = ci;
          }
        }
        if (bestIdx === -1) continue;

        // Fine pass: refine within the neighboring coarse gap
        let lower = bestIdx > 0 ? validCoarse[bestIdx - 1] : COARSE[0];
        let upper = bestIdx < validCoarse.length - 1 ? validCoarse[bestIdx + 1] : blockDuration;
        let fineStep = Math.max(5, Math.ceil((upper - lower) / FINE_EVALS));

        for (let w = lower; w <= upper; w += fineStep) {
          let v = evalWindow(w, s, e);
          if (v !== null && v > bestVdot) {
            bestVdot = v; bestWindow = w;
          }
        }

        // Duration-weighted: each block's real duration stays honest
        vdotSum += bestVdot * bestWindow;
        weightSum += bestWindow;
      }

      if (weightSum >= MIN_WORK_SECONDS) {
        result = Math.round((vdotSum / weightSum) * 10) / 10;
      }
    }

    result;
  }
}

Edit: update script to reduce samples.

1 Like

My VDOT script:

{
  const MIN_WINDOW = 300;  // 5 minutes
  const MAX_WINDOW = 5400; // 90 minutes
  const STEP_SEC = 60;     // 1-minute window steps

  let peakVdot = null;

  // Strict check for activity types
  const actType = activity?.type;
  const isRunType = actType === 'Run' || actType === 'VirtualRun';

  if (isRunType) {
    // Direct stream access
    const times = streams?.time;
    const dists = streams?.distance;

    if (times?.length && dists?.length && times.length === dists.length && times.length > 1) {
      const totalDuration = times[times.length - 1] - times[0];

      // Adaptive stride
      const stride = times.length > 7200 ? 4 : (times.length > 3600 ? 3 : 2);

      let windowSec, tMinutes, percentVO2max, rightIdx, tStart, tEnd, dEnd;
      let distDiff, speedMps, vMetersPerMin, vo2Cost, vdot;

      for (windowSec = MIN_WINDOW; windowSec <= MAX_WINDOW; windowSec += STEP_SEC) {
        if (totalDuration < windowSec) continue;

        tMinutes = windowSec / 60;
        percentVO2max = 0.8
          + 0.1894393 * Math.exp(-0.012778 * tMinutes)
          + 0.2989558 * Math.exp(-0.1932605 * tMinutes);

        rightIdx = 0;

        for (let i = 0; i < times.length; i += stride) {
          tStart = times[i];
          tEnd = tStart + windowSec;

          if (tEnd > times[times.length - 1]) break;

          while (rightIdx < times.length && times[rightIdx] < tEnd) {
            rightIdx++;
          }

          // Direct distance lookup
          dEnd = dists[rightIdx];

          distDiff = dEnd - dists[i];
          if (distDiff > 0) {
            speedMps = distDiff / windowSec;
            vMetersPerMin = speedMps * 60;

            vo2Cost = -4.60 + 0.182258 * vMetersPerMin + 0.000104 * vMetersPerMin * vMetersPerMin;
            vdot = vo2Cost / percentVO2max;

            if (peakVdot === null || vdot > peakVdot) {
              peakVdot = vdot;
            }
          }
        }
      }
    }
  }

  // Final evaluated expression
  peakVdot !== null ? Math.round(peakVdot * 10) / 10 : null;
}

It uses Elapsed Time rather than Moving Time for Peak VDOT calculations.

The Daniels & Gilbert VDOT model is strictly calibrated for continuous physical output, which Moving Time distorts.

Prevents “Stitching” Interval Rests: If an athlete runs 5×1,000m intervals with 2-minute rest stops, removing paused time stitches those 5 separate sprints into a single continuous block.

The script would evaluate a 5k interval workout as a continuous 5k race effort, resulting in an artificially inflated, impossible VDOT score.

Physiological Validity: The VDOT formula models continuous oxygen uptake). Resting allows heart rate and blood lactate to drop; treating non-continuous segments as unbroken exertion invalidates the math.

1 Like

Thanks @pepe , sliding window is definitely the way to go. I updated with that important change.

{
  const valid = new Set(['Run', 'Virtual Run']);
  if (!valid.has(activity.type)) {
    null;
  } else {

    const times = icu.streams.time;
    const dists = icu.streams.distance;
    const intervals = icu.activity.icu_intervals || [];

    const ZONE_THRESHOLD = 4;
    const MIN_WORK_SECONDS = 360; // 6 min minimum total qualifying effort
    const MIN_WINDOW = 180;       // 3 min minimum single search window
    const MAX_WINDOW = 5400;      // 90 min cap
    const STEP_SEC = 15;

    let result = 0;

    if (times && dists && times.length === dists.length && times.length > 1) {

      // 1. Zone >=4 intervals, merged into contiguous "work blocks"
      let qualifying = intervals.filter(iv =>
        iv.zone != null && iv.zone >= ZONE_THRESHOLD &&
        iv.start_index != null && iv.end_index != null &&
        iv.end_index > iv.start_index
      );
      qualifying.sort((a, b) => a.start_index - b.start_index);

      let blocks = [];
      for (let iv of qualifying) {
        let last = blocks[blocks.length - 1];
        if (last && iv.start_index <= last.end_index) {
          last.end_index = Math.max(last.end_index, iv.end_index);
        } else {
          blocks.push({ start_index: iv.start_index, end_index: iv.end_index });
        }
      }

      // 2. Sliding-window best-effort search within each block only
      let vdotSum = 0;
      let weightSum = 0;

      for (let block of blocks) {
        let s = block.start_index;
        let e = Math.min(block.end_index, times.length - 1);
        if (e <= s) continue;

        let blockDuration = times[e] - times[s];
        if (blockDuration < MIN_WINDOW) continue;

        let maxWindow = Math.min(blockDuration, MAX_WINDOW);
        let bestVdot = null;
        let bestWindow = 0;

        for (let windowSec = MIN_WINDOW; windowSec <= maxWindow; windowSec += STEP_SEC) {
          let tMinutes = windowSec / 60;
          let percentVO2max = 0.8
            + 0.1894393 * Math.exp(-0.012778 * tMinutes)
            + 0.2989558 * Math.exp(-0.1932605 * tMinutes);

          let rightIdx = s;
          for (let i = s; i <= e; i++) {
            let tEnd = times[i] + windowSec;
            if (tEnd > times[e]) break;

            while (rightIdx <= e && times[rightIdx] < tEnd) rightIdx++;

            let distDiff = dists[rightIdx] - dists[i];
            if (distDiff > 0) {
              let v = (distDiff / windowSec) * 60; // meters/min
              let vo2Cost = -4.60 + 0.182258 * v + 0.000104 * v * v;
              let vdot = vo2Cost / percentVO2max;

              if (bestVdot === null || vdot > bestVdot) {
                bestVdot = vdot;
                bestWindow = windowSec;
              }
            }
          }
        }

        if (bestVdot !== null) {
          vdotSum += bestVdot * bestWindow;
          weightSum += bestWindow;
        }
      }

      if (weightSum >= MIN_WORK_SECONDS) {
        result = Math.round((vdotSum / weightSum) * 10) / 10;
      }
    }

    result;
  }
}

bTW I reviewed with Claude, watch these points when I shared your script:

Fixed their bug: streams are accessed as icu.streams.time / icu.streams.distance, not a bare streams global.

Kept the zone gate: still only looks at zone >= 4 segments, merged into contiguous “blocks” — this is what stops an easy run’s downhill km or GPS noise from producing a fake VDOT, which their version had no defense against.

Replaced the fixed-interval average with a sliding-window best-effort search inside each block: instead of using each 1km auto-lap’s average pace, it now searches all window lengths (3–90 min, 15s steps) within the block and finds the fastest sustained sub-effort — closer to true best-effort methodology, and immune to auto-lap boundaries cutting a rep at an awkward point.

Duration-weighted average across blocks using each block’s best-window length, same principle as before (keeps each block’s real duration honest rather than stitching blocks together).

6-minute minimum total qualifying time, same as before, to avoid firing on trivial/noisy zone-4 blips

Great you corrected you script. But:

Tried your script and got: Memory limit exceeded

Declaring local letvariables inside a nested loop running up to 390,000+ times creates millions of temporary variable allocations

While mine script VDOT 54.2

Check my script how to minimize total number of inner loop iterations

2 Likes

@pepe , @Povedano awesome work. Thanks for the help! Not a programmer, so potentially dumb question. Does the “zone >= 4” require users have their pace zones set somewhat accurately? What happens if they are way off?

Hi Robert, my script doesn’t need to. Povedano script uses “zone >= 4” as a filter.

VDOT should be calculated at race effort by definition, that filter just tries to read high efforts during training, and remove data not valuable of warm-up/recovery phases or base sessions. And this is not purely VDOT value, knowing that training pace is submaximal by design.

Can you publish it as public custom field?
I’m interested on compare values for a full season. I’m not 100% sure yet on these approximations.

@Povedano you can paste the @pepe code into your own custom field to use it on your training history.

I also get a Memory Limit Exceeded error when using the @Povedano script.

image

Yeah, I’m getting old and lazy… But you’re right, I will add it.

Regarding my script, I think I updated the wrong post. I updated my script in the first post, not in the second one. So in my first post the script should be correct with less samples to process.