Calculating Virtual Cycling Power

Calculating Virtual Cycling Power

Understanding Cycling Power: A Physics Breakdown and Python Calculator

·

9 min read

For those who don't know me personally, I am an avid road cyclist (my Strava for those interested). Naturally, I've delved deeply into how to use my power more efficiently. This curiosity led me to a great article (and calculator) by Steve Gribble that explores the relationship between cycling power and speed, which really caught my interest.

In this blog post, I will show you how to program this calculator in Python.

The Physics

Steve Gribble does an excellent job explaining the physics behind the relationship, but for completeness, I will also do my best to explain it here in this post while providing the Python code.

As a cyclist, when you ride your bike, you need to overcome three main forces to move forward. These are:

  1. gravity,

  2. rolling resistance,

  3. and aerodynamic drag.

Gravity

The force of gravity plays a significant role in cycling, either working against you on ascents or aiding you on descents. Here's how it works:

  • Uphill: When cycling uphill, you must overcome the force of gravity pulling you downwards. The steeper the hill, the greater the gravitational force you need to counteract.

  • Downhill: When cycling downhill, gravity acts in your favour, accelerating you without additional effort.

Steepness and Grade: The steepness of a hill is often expressed as a percentage grade (G). This is calculated by dividing the vertical rise by the horizontal distance and multiplying by 100.

Weight and Gravity: The combined weight of you and your bike (W) directly influences the force of gravity you experience. Heavier loads require more energy to overcome gravity on ascents but accelerate faster on descents.

Gravitational Constant: The gravitational force constant is approximately 9.8067m/s². This value is used to calculate the force of gravity acting on objects.

The force of gravity can be calculated using the following formula:

$$F_{gravity} = 9.8067 \cdot \sin(\arctan(\frac{G}{100})) \cdot W$$

import math

def calculate_gravity(gradient, weight):
    """
    Calculates the force of gravity on an object on a slope.

    Args:
        gradient (float): The gradient of the slope (in percentage).
        weight (float): The combined weight of the cyclist and bike (in kg).

    Returns:
        The gravity force (in Newtons).
    """
    return 9.8067 * math.sin(math.atan(gradient / 100)) * weight

Rolling Resistance

Rolling resistance is the friction generated between your tires and the road. This friction acts to slow you down. Key factors that influence the amount of rolling resistance:

  • Road Surface: Rougher roads create more friction, increasing resistance. Smoother surfaces lead to less friction.

  • Tire Quality: High-quality tires and tubes are designed to minimize friction, reducing rolling resistance.

  • Weight: The combined weight of you and your bike impacts rolling resistance. Heavier loads increase the friction between the tires and the road.

Coefficient of Rolling Resistance (Crr)

The coefficient of rolling resistance (Crr) is a dimensionless number that incorporates the effects of road surface conditions and tire quality. A lower Crr value indicates lower rolling resistance. You can find various Crr values for different tires online. The force of rolling resistance can be calculated using the following formula:

$$F_{rolling} = 9.8067 \cdot \cos(\arctan(\frac{G}{100})) \cdot W \cdot C_{rr}$$

import math

def calculate_rolling_resistance(gradient, weight, crr):
    """
    Calculates the rolling resistance force of an object on a slope.

    Args:
        gradient (float): The gradient of the slope (in percentage).
        weight (float): The combined weight of the cyclist and bike (in kg).
        crr (float): Dimensionless coefficient of rolling resistance.

    Returns:
        The rolling resistance force (in Newtons).
    """
    return 9.8067 * math.cos(math.atan(gradient / 100)) * weight * crr

Aerodynamic Drag

Aerodynamic drag is the force of air resistance you experience as you cycle. You can think of it as the air pushing back as you and your bike move through it. Several factors influence the magnitude of aerodynamic drag:

  • Groundspeed: The faster you cycle (Vgs), the greater the force of air resistance you encounter.

  • Headwind: A stronger headwind (Vhw) increases the force you need to overcome.

  • Frontal Area: The combined profile you and your bike present to the wind (A) determines how much air you need to displace. Cyclists and manufacturers work to minimize frontal area for improved aerodynamics.

  • Air Density: Denser air creates greater resistance. Factors like altitude and temperature affect air density (Rho).

  • Aerodynamic Efficiency: The smoothness of your clothing and the way air flows around you and your bike impact drag. This is captured by the drag coefficient (Cd). Optimizing your position helps reduce turbulence and lower drag.

Airspeed, Groundspeed, and Headwind

Airspeed (Vas) represents the speed at which the wind hits you. It's calculated by combining your groundspeed (Vgs) and any headwind (Vhw):

$$V_{as} = V_{gs} + V_{hw}$$

Formula for Aerodynamic Drag

The force of aerodynamic drag can be calculated using the following formula:

$$F_{drag} = 0.5 \cdot C_d \cdot A \cdot Rho \cdot V_{as}^2$$

def calculate_aerodynamic_drag(drag_coefficient, frontal_area, air_density, groundspeed, headwind):
    """
    Calculates the aerodynamic drag force experienced by a cyclist.

    Args:
        drag_coefficient (float): Dimensionless coefficient representing aerodynamic efficiency.
        frontal_area (float): The combined frontal area of the cyclist and bike (m^2).
        air_density (float): Density of the air (in kg/m^3).
        groundspeed (float): The cyclist's speed over the ground (in m/s).
        headwind (float): The speed of the wind opposing the cyclist (in m/s).

    Returns:
        float: The aerodynamic drag force (in Newtons).
    """

    airspeed = groundspeed + headwind
    return 0.5 * drag_coefficient * frontal_area * air_density * math.pow(airspeed, 2)

Total Resistive Force

To put it simply, the total force resisting you is the sum of gravity, rolling resistance, and aerodynamic drag.

$$F_{resistance} = F_{gravity} + F_{rolling} + F_{drag}$$

def calculate_total_resistance(gravity, rolling_resistance, aerodynamic_drag):
    """
    Calculates the total resistive force acting on a cyclist.

    Args:
        gravity (float): The force of gravity acting on the cyclist (in Newtons).
        rolling_resistance (float): The force of rolling resistance (in Newtons).
        aerodynamic_drag (float): The force of aerodynamic drag (in Newtons).

    Returns:
        float: The total resistive force experienced by the cyclist (in Newtons).
    """    

    return gravity + rolling_resistance + aerodynamic_drag

Drivetrain Loss

As the cyclist, you are the source of power for your bike. However, not all the power you generate with your legs reaches the wheels because of losses in the drivetrain, which includes the chain, gears, and bearings. This loss (Lossdt), often about 2%, occurs in a clean and well-lubricated drivetrain.

Calculating Power

Power represents the rate at which you expend energy. When cycling at a groundspeed (Vgs), the power (Plegs) needed to overcome the total resistive force (Fresistance) and drivetrain loss is calculated as follows:

$$P_{legs} = (1-\frac{Loss_{dt}}{100})^{-1} \cdot F_{resistance} \cdot V_{gs}$$

def calculate_power_at_legs(drivetrain_loss, total_resistance, groundspeed):
    """
    Calculates the power required from a cyclist's legs to maintain a given groundspeed.

    Args:
        drivetrain_loss (float): Percentage of power lost in the bicycle's drivetrain.
        total_resistance (float): The total resistive force acting on the cyclist (in Newtons).
        groundspeed (float): The cyclist's speed over ground (in m/s).

    Returns:
        float: The power the cyclist must produce (in watts).
    """
    return math.pow((1 - (drivetrain_loss / 100)), -1) * total_resistance * groundspeed

Putting It All Together

For all the calculations, we're going to be using the metric system.

Now for the complete formula:

$$P_{legs} = (1 – \frac{Loss_{dt}}{100})^{-1} \cdot [(9.8067 \cdot W \cdot [\sin(\arctan(\frac{G}{100})) + C_{rr} \cdot cos(\arctan(\frac{G}{100}))]) + (0.5 \cdot C_{d} \cdot A \cdot Rho \cdot (V_{gs} + V_{hw})^{2})]$$

Let's imagine a cyclist weighing 75kg, riding a bike that weighs 10kg up a 5% gradient with the following scenario parameters:

  • Groundspeed: 5m/s (approximately 18km/h)

  • Coefficient of Rolling Resistance: 0.005 (typical for road tires on asphalt)

  • Air Density: 1.2kg/m³ (average at sea level)

  • Frontal Area: 0.5 m²

  • Drag Coefficient: 0.7

  • Headwind: 2m/s (approximately 7.2km/h)

  • Drivetrain Loss: 2%

import math

def calculate_gravity(gradient, weight):
    return 9.8067 * math.sin(math.atan(gradient / 100)) * weight

def calculate_rolling_resistance(gradient, weight, crr):
    return 9.8067 * math.cos(math.atan(gradient / 100)) * weight * crr

def calculate_aerodynamic_drag(drag_coefficient, frontal_area, air_density, groundspeed, headwind):
    airspeed = groundspeed + headwind
    return 0.5 * drag_coefficient * frontal_area * air_density * math.pow(airspeed, 2)

def calculate_total_resistance(gravity, rolling_resistance, aerodynamic_drag):
    return gravity + rolling_resistance + aerodynamic_drag

def calculate_power_at_legs(drivetrain_loss, total_resistance, groundspeed):
    return math.pow((1 - (drivetrain_loss / 100)), -1) * total_resistance * groundspeed

if __name__ == "__main__":
    # Scenario Parameters
    gradient = 5   
    cyclist_weight = 75 
    bike_weight = 10
    groundspeed = 5  
    crr = 0.005 
    air_density = 1.2
    frontal_area = 0.5 
    drag_coefficient = 0.7
    headwind = 2
    drivetrain_loss = 2

    # Calculations
    weight = cyclist_weight + bike_weight
    gravity_force = calculate_gravity(gradient, weight)
    rolling_resistance = calculate_rolling_resistance(gradient, weight, crr)
    aerodynamic_drag = calculate_aerodynamic_drag(drag_coefficient, frontal_area, air_density, groundspeed, headwind)
    total_resistance = calculate_total_resistance(gravity_force, rolling_resistance, aerodynamic_drag)
    power_at_legs = calculate_power_at_legs(drivetrain_loss, total_resistance, groundspeed)

    # Test
    # Power required from the cyclist: 286.1179684990967 watts
    print("Power required from the cyclist:", power_at_legs, "watts")

Summary

So, there you have it — a detailed yet straightforward explanation of how to use Python to calculate the total power a cyclist needs to maintain their speed. You could use this as a basis to develop a Zwift-style indoor cycling game, or even better, an opposing force power meter like the Velocomp PowerPod. This could be a great topic for a future blog article: "How to build your own opposing force power meter."

Additional Calculations

Just for curiosities sake, below are additional calculations you can do based on the Physics model above.

Total Work

You can also use some of the formula to calculate the total work you will need to expend to move a particular distance (D) given power:

$$Work = F_{resistance} \cdot D$$

def calculate_work(total_resistance, distance):
    """
    Calculates the work to expend to travel a given distance against a given force.

    Args:
        total_resistance(float): The total resistive force experienced by the cyclist (in Newtons).
        distance (float): The total distance travelled at that exact force (in meters).

    Returns:
        float: The work expended by the cyclist (in Joules).
    """
    return total_resistance * distance

What you can also do is given a list of power and distance pairs (tuple form), you can iterate through and determine the total work for a given exercise. As an example (this doesn't pass a .fit file):

def calculate_work(total_resistance, distance):
    """
    Calculates the work to expend to travel a given distance against a given force.

    Args:
        total_resistance(float): The total resistive force experienced by the cyclist (in Newtons).
        distance (float): The total distance travelled at that exact force (in meters).

    Returns:
        float: The work expended by the cyclist (in Joules).
    """

    return total_resistance * distance

def group_efforts(efforts):
    """
    Sums up total distances for the same power values.

    Args:
        efforts (list<tuple(float, float)>): A list of power (in Newtons) and distance (in meters) sets.

    Returns:
        float: The work expended by the cyclist (in Joules).
    """

    grouped_efforts = {}

    for effort in efforts:
        power = effort[0]
        distance = effort[1]
        grouped_efforts[power] = grouped_efforts.get(power, 0) + distance

    return grouped_efforts

def joules_to_calories(joules):
    """
    Converts joules to calories which is 1:0.2390057361

    Args:
        joules (float): The total amount of work in joules.

    Returns:
        float: The work expended in calories.
    """

    return joules * 0.2390057361

if __name__ == "__main__":
    example_efforts = [
            (15.2, 2.5),
            (15.5, 5.3),
            (20.0, 7.1),
            (10.5, 6.5),
            (10.2, 5.6),
            (15.2, 4.9),
            (10.5, 5.4),
            (15.5, 6.1)
        ]

    grouped_efforts = group_efforts(example_efforts)
    total_work_joules = 0

    for effort in grouped_efforts.keys():
        total_work_joules += calculate_work(effort, grouped_efforts[effort])


    total_work_calories = joules_to_calories(total_work_joules)

    # Test
    # Total work from the cyclist: 613.25 joules
    # Total work from the cyclist: 146.570267663325 calories
    print(f"Total work from the cyclist: {total_work_joules} joules")
    print(f"Total work from the cyclist: {total_work_calories} calories")