Total time accumulated at or below the indicated power rate


  • Just like the chart that shows the total time accumulated/percentage at or above the indicated heartrate in the Activity HR page, it could be great to have an equivalent chart about power in the Activity Power page.

  • the same kind of graphic could be trace on the compare page, to compare the dispatch of the power on different periods/months/seasons, etc

The main use case from my point of view is that I try to ride polarized and/or pyramidal, but in order to optimize the benefits when I ride @I2 I try to stay at te top of the intensity, and several time I go to I3 juste for few watts. But at the end of the ride, I see that the ride was not polarized, or the cumulative power over a training period of few weeks/a year is not polarized. With these charts I could be able to see in detail where is the limit of 80% on the dispatch of the power on my rides. Is the 80% limit very close from the upper limit of I2 which is great or is the 80% limit far from the upper limit of I2 which lead me to adapt my future rides.

Have a nice day,


If you want to polarise your training, do it by sessions; it’s just easier to work with number of sessions than time in zone.

If it’s a hard session, eg. time at VO2, then all recovery is endurance, but the session is considered hard.

1 Like

If you can write a bit of Javascript you could build a custom chart to do that. You could also adjust your zones so the time in zones stuff answers your question.

You could also create a computed activity field to show how much time was under 80%.

Here is a script that will calculate what percentage of the time for the ride was spent at less than 80% of FTP:

  let watts = streams.get("fixed_watts").data
  let time = streams.get("time").data
  let ftp = activity.icu_ftp
  let secsUnder80 = 0, tot = 0
  for (let i = 0; i < watts.length; i++) {
    let w = watts[i]
    let secs = time[i] - (i > 0 ? time[i - 1] : 0)
    if (secs >= 30) continue // coffee stop
    if (w < ftp * 0.8) secsUnder80 += secs
    tot += secs
  tot > 0 ? (secsUnder80 / tot) * 100 : null

This is not ‘optimizing’, it is in fact ruining your Z2 training.
If the purpose of Z2 training was to hit the top of the zone, all coaches and training plans would instruct you to target that top zone.
The target for zone 2 should be the center of the zone, giving you some freedom to deviate from it. And some reserve to account for HR drift when the session gets longer. And account for the fact that your VT1 threshold is slightly changing from day to day.
Constantly targeting the top of the zone is transforming your Z2 ride to a sort of a constant power workout where Z2 riding is intended to wander around within the complete zone from low to high. That’s teaching your body to improve fat burning at those lower intensities.
What you call optimizing is in fact ruining your Z2 training because you have way too much time above that variable and difficult to determine VT1 point. All scientific recommendations always talk about 5-10 beats below your VT1 to determine Z2. As Alan Couzens often says, it is almost impossible to go too low…
And as Gerald pointed out, Polarized is not about TIZ distribution. This got way out of hand around the internet but the initial and basic definition is 4 easy sessions followed by 1 hard session. The target for hard varies during the year from Tempo to Anaerobic, depending on the training phase you’re in and the adaptations you’re after.
Applying the above will make you realize that Polarized is in fact very similar to Pyramidal.
The ‘icing on the cake’ is that it is very simple. Go easy 4 times and then go hard one time…

1 Like

Okay, thank you for all your responses, I understand that maybe I do not have a good approach of Z2 training then. I will adapt my endurance training in order to target the middle of Z2.

I created the curve, I share you the script here, I think it can be useful anyway. Feel free to improve it.

    function pad(str, size, car) {
        while (str.length < size) str = car + str;
        return str;
    function duration_mm_ss(time) {
        let time_m = Math.floor(time / 60);
        let time_s = time - time_m * 60;
        return pad(pad(time_m.toString(), 2, '0'), 3, ' ') + ":" + pad(time_s.toString(), 2, '0')
    function get_pwrZones(power, zones) {
        for (let i = 0; i < zones.length; i++) {
            if (power <= zones[i]) return i+1;
        return -1;
    let activity = icu.activity;    
    let streams = icu.streams;
    let pwr = streams.get("watts").data;
    let d = streams.get("time").data;

    let pwrMin = 30;
    let pwrMax = 0;
    for (let i = 0; i < pwr.length; i++) {
        if (pwrMax < pwr[i]) {
            pwrMax = pwr[i];

    //  Athlete power zones
    colors = ['blue', 'green', 'yellow', 'orange', 'red', 'purple', 'grey', 'black']
    zonesP = []
    for (let i=0; i < activity.icu_power_zones.length; i++) {
        zonesP.push(Math.min(pwrMax, Math.floor(activity.icu_ftp * activity.icu_power_zones[i] / 100)));
    //  Histogram
    let totalTime = 0;
    let watts_histo = [];   
    for (let i = 0; i <= pwrMax; i++) watts_histo.push(0);
    for (let i = 0; i < pwr.length; i++) {
        if (pwr[i] > pwrMin) {
            watts_histo[pwr[i]] += 1;
            totalTime += 1;
    //  Accumulation
    let cumTime = 0;
    let data_Pwr = [];
    let data_Time = [];
    let data_TimeStr = [];
    let data_Text = [];

    for (let i = pwrMin; i < watts_histo.length; i++) {
        cumTime += watts_histo[i];

        let percentUnder = cumTime / totalTime * 100;      
        let percentOver = 100 - cumTime / totalTime * 100;
        let str =   "<b>(Z" + get_pwrZones(i, zonesP) + ") " + i + " watts</b>"
                +   "<br>" + duration_mm_ss(cumTime)             + " Under (" + percentUnder.toFixed(1) + " %)"
                +   "<br>" + duration_mm_ss(totalTime - cumTime) + " Over  (" + percentOver.toFixed(1) + " %)"

    //  Shapes for power zones
    shapesP = [{
        type: 'rect',
        xref: 'x',
        yref: 'y',
        x0: 0,
        y0: 0,
        x1: totalTime,
        y1: zonesP[0],
        layer: 'below',
        opacity: 0.2,
        fillcolor: colors[0],
        line: {
            color: 'black',
            width: 0,
        dash: 'dot'

    for (let i=0; i < activity.icu_power_zones.length-1; i++) {
        temp_shape = {
            type: 'rect',
            xref: 'x',
            yref: 'y',
            x0: 0,
            y0: zonesP[i]+1,
            x1: totalTime,
            y1: zonesP[i+1],
            layer: 'below',
            opacity: 0.2,
            fillcolor: colors[i+1],
            line: { width: 0 }

    //  Trace
    trace1 = {
        x: data_Time,
        y: data_Pwr,
        text: data_Text,
        type: 'scatter',
        yaxis: 'y',
        xaxis: 'x',
        name: 'Cumulative time under/over power',
        mode: 'lines',
        hoverinfo: 'text',
        line: { color: 'black' }

    var data = [trace1];

    //  Layout    
    layout = {
        title: {
            text: "Cumulative time under/over power",
            font: { color: '#999999' },
        xaxis: {
            side: 'bottom',
            range: [0, cumTime],
            showline: true, zeroline: true,
            autotick: false, tick0: 0, dtick: cumTime/10,
            showspikes: true, spikemode: 'across', spikesnap: "cursor+data", spikedash: 'solid', spikethickness: 1,
            color: '#999999',
        yaxis: {
            side: 'left',            
            range: [pwrMin, pwrMax+1],
            showline: true, zeroline: true,
            autotick: false, tick0: 0, dtick: 100,
            showspikes: true, spikemode: 'across', spikesnap: "cursor+data", spikedash: 'solid', spikethickness: 1,
            title: { text: 'Power', },
            color: '#999999',
        margin: { l: 50, r: 20, t: 30, b: 40 },
        hovermode: 'x unified',
        spikedistance: -1,
        shapes: shapesP,
        showlegend: false,
        legend: {"orientation": "h"}

    chart = { data, layout };


Thats very cool. You could use “fixed_watts” instead of watts. It has short dropouts fixed. Also if you want to you could use the standard zone colours:

export const ZoneColours = ["#009e80", "#009e00", "#ffcb0e", "#ff7f0e", "#dd0447", "#6633cc", "#504861" ]
export const ZoneColours4 = ["#009e00", "#ffcb0e", "#ff7f0e", "#dd0447" ]
export const ZoneColours3 = ["#009e00", "#ffcb0e", "#dd0447" ]

export function toZoneColours(zc) {
  if (zc >= 5) return ZoneColours
  if (zc >= 4) return ZoneColours4
  return ZoneColours3

If you aren’t doing high enough volume of endurance training, you might want to extend some of your training to Tempo, especially at the beginning of the base phase.

Your original post says 80% limit; assuming this is time (duration)?

80% of 5 hours vs 80% of 10 or 15 or 20 hours would present a completely different view on your time in zone.

In Tempo (L3) you get the benefits of high intensity workouts without the fatigue. Fatigue resistance, as well as working more muscle fibers. This would be low cadence workouts (50-70 rpm), eg. 2x20m or 2x30m (L3 power) with endurance riding (L2) for the rest of the 60-90 minute workouts. Once per week, plus a long L2 ride.

Hugo / David,
I’ve played around with custom fields at the activity level, but have not used the functionality to build a custom chart. When using the code above, I receive the error when creating a Custom Activity Field “Validation failed: CumPower must be a number”.

Any help would be appreciated - thanks.

NVM - I figured out how to do this. Nice work!