In trying to create a prototype for Golden Cheetah, I put together this perl script that I submit for your perusal. The script comes with the sample data used by Robert Chung in his recent post to the Wattage Forum, so it may look familiar to many of you.
My aim was to match a known profile by using the exact 2nd-law equations of motion. All the details are contained within, as well as a full set of data. The code is submitted “warts and all” for your healthy criticism. As shown in the header file, the code is fully GPL’d, as it is meant to be used for Golden Cheetah.
Features
- computes Crr and CdA from power file and elevation profile
- stably tries to match the profiles using a global L2 norm
- full equations used for Newton’s 2nd law (no small-angle approximations):
F – m g sin(theta) – Crr m g cos(theta) – CdA rho v2 / 2 = m a - regular iteration solver for slope to cope with nonlinear trig equation
- full Newton-Raphson using numerical derivatives for outer solver
- GPL license
To Do
- recheck numerics over and over to make sure they’re right
- implement Chung method for use when no elevation profile is available
- provide unit tests to bolt-down the validation of the code
- add calculation for rho, air density
Perl dependencies
require Math::Trig;
Inputs
A PowerTap CSV file, pt_data.csv, attached.
An associated elevation file, elevation.csv, attached.
Mass of rider + bike, m. ( Here, 89kg)
Time-sampling interval, dt. ( Here, 1.26s )
Density of air, rho ( Here 1.236kg/m3)
Conversion factor from speed in file to m/s, v_factor (Here, 3.600 )
Starting elevation, elev_start. (Here, 7.59m)
Command line usage
% ve.pl <power_tap_file> <elevation_file> <m> <rho> <dt> <v_factor> <elev_start>
Example
% ve.pl pt_data.csv elevation.csv 89 1.236 1.26 3.6 7.59
crr= -0.00896848, cda= 0.71414735 RESIDUALS: 0.051821 121720394637.221100
crr= -0.01887355, cda= 0.74727538 RESIDUALS: 0.043221 24240094726.978828
crr= -0.06140901, cda= 1.32495998 RESIDUALS: 0.724061 4847607668.734711
crr= 0.04881965, cda= -0.23143669 RESIDUALS: 1.950369 970123080.834633
crr= 0.01233395, cda= 0.28146843 RESIDUALS: 0.642751 195195026.279830
crr= 0.00621468, cda= 0.36746316 RESIDUALS: 0.107765 39145221.731429
crr= 0.00499893, cda= 0.38454789 RESIDUALS: 0.021410 7829464.327135
crr= 0.00475554, cda= 0.38796827 RESIDUALS: 0.004286 1565869.723166
crr= 0.00470688, cda= 0.38865210 RESIDUALS: 0.000857 313175.156131
crr= 0.00469715, cda= 0.38878888 RESIDUALS: 0.000171 62634.927414
crr= 0.00469520, cda= 0.38881621 RESIDUALS: 0.000034 12527.026148
crr= 0.00469481, cda= 0.38882167 RESIDUALS: 0.000007 2505.412917
crr= 0.00469473, cda= 0.38882276 RESIDUALS: 0.000001 501.082018
crr= 0.00469472, cda= 0.38882298 RESIDUALS: 0.000000 100.216611
crr= 0.00469472, cda= 0.38882303 RESIDUALS: 0.000000 20.043254
crr= 0.00469471, cda= 0.38882303 RESIDUALS: 0.000000 4.008642
crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.801726
crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.160310
crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.032088
crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.006452
crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.001279
crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.000221
Results
Shown below is the convergence of the virtual elevation profile to the actual elevation profile. The 10th iteration of the Newton-Raphson solver pretty much nails the elevation correctly.

To see the final results a little better, here is the actual vs. 10th iteration profile. As you can see, the results are virtually identical, indicating the quality of the convergence.

Executable
#!/usr/bin/perl -I .
#
# ve - a perl script to perform virtual elevation calculations
#
# Copyright (C) 2009 Andy M. Froncioni
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
#
# Description:
# A program to determine CdA and Crr by matching virtual
# elevation with actual elevation.
#
# Commandline Usage:
# % ve power_tap_file elevation_file m rho dt v_factor elev_start
#
# Example:
# % ./ve pt_data.csv elevation.csv 89 1.236 1.26 3.6 7.59
# crr= -0.00896848, cda= 0.71414735 RESIDUALS: 0.051821 121720394637.221100
# crr= -0.01887355, cda= 0.74727538 RESIDUALS: 0.043221 24240094726.978828
# crr= -0.06140901, cda= 1.32495998 RESIDUALS: 0.724061 4847607668.734711
# crr= 0.04881965, cda= -0.23143669 RESIDUALS: 1.950369 970123080.834633
# crr= 0.01233395, cda= 0.28146843 RESIDUALS: 0.642751 195195026.279830
# crr= 0.00621468, cda= 0.36746316 RESIDUALS: 0.107765 39145221.731429
# crr= 0.00499893, cda= 0.38454789 RESIDUALS: 0.021410 7829464.327135
# crr= 0.00475554, cda= 0.38796827 RESIDUALS: 0.004286 1565869.723166
# crr= 0.00470688, cda= 0.38865210 RESIDUALS: 0.000857 313175.156131
# crr= 0.00469715, cda= 0.38878888 RESIDUALS: 0.000171 62634.927414
# crr= 0.00469520, cda= 0.38881621 RESIDUALS: 0.000034 12527.026148
# crr= 0.00469481, cda= 0.38882167 RESIDUALS: 0.000007 2505.412917
# crr= 0.00469473, cda= 0.38882276 RESIDUALS: 0.000001 501.082018
# crr= 0.00469472, cda= 0.38882298 RESIDUALS: 0.000000 100.216611
# crr= 0.00469472, cda= 0.38882303 RESIDUALS: 0.000000 20.043254
# crr= 0.00469471, cda= 0.38882303 RESIDUALS: 0.000000 4.008642
# crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.801726
# crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.160310
# crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.032088
# crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.006452
# crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.001279
# crr= 0.00469471, cda= 0.38882304 RESIDUALS: 0.000000 0.000221
#
#
use strict;
use Math::Trig;
#
# These commandline parameters assume file-scope. I need this to
# simplify the f, fx, fy, fxx, etc... functions that are derived
# from compute_virtual_elevation():
my $power_file = shift;
my $elev_file = shift;
my $m = shift;
my $rho = shift;
my $dt = shift;
my $v_factor = shift;
my $start_elev = shift;
#
# read the input files:
my $power_data = read_power_data( $power_file );
my $actual_elevation = read_elevation_data( $elev_file );
print_elev( "000.dat", $actual_elevation );
# Use a nonlinear solver to determine CdA and Crr.
#
# The idea is that we have a function F(x,y) which we want to
# minimize. F is the L2-norm of the difference between our
# actual and computed elevation profiles. (x,y) is the ( crr, cda )
# input vector.
#
# The problem: find (x,y) that minimizes F.
#
# Solution:
#
# Set dF/dx and DF/dy to zero and get 2 equations in 2 unknowns.
# Follow the gradient downhill to get x*,y* that sets F(x*,y*) to zero.
#
#
# Seed:
my $cda = 0.700;
my $crr = 0.030;
#
# Outer loop for (crr,cda) solve:
my $error_norm = 10000000000;
my $tolerance = 0.001;
my $delta = 0.0000001;
my $x = $crr;
my $y = $cda;
my $alpha = 0.8;
my $correction_norm = 100000000;
my $i = 0;
while( ($error_norm > $tolerance) || ($correction_norm > $tolerance) )
{
# RHS vector:
my $b0 = fx($x,$y,$delta);
my $b1 = fy($x,$y,$delta);
my @b = ( -1.0 * $b0 , -1.0 * $b1 );
# Hessian:
my @A;
$A[0][0] = fxx( $x, $y, $delta );
$A[0][1] = fxy( $x, $y, $delta );
$A[1][0] = fyx( $x, $y, $delta );
$A[1][1] = fyy( $x, $y, $delta );
# Solve for new (dx,dy) vector:
#
# A * dx = b
my $dx = solve_2x2_matrix( \@A, \@b );
#
# Apply correction to (x,y):
$x += $alpha * $dx->[0];
$y += $alpha * $dx->[1];
my $ve = compute_virtual_elevation( $x, $y );
print_elev( sprintf("%02d\.dat", $i ), $ve );
#
# Compute both correction norm and rhs norm.
# This ensures that ill-conditioned problems converge completely:
$error_norm = sqrt( $b0*$b0 + $b1*$b1 );
$correction_norm = sqrt( $dx->[0]*$dx->[0] + $dx->[1]*$dx->[1] );
print sprintf("crr=%12.8f, cda=%12.8f", $x, $y ) . " RESIDUALS:" . sprintf( "%12.6f %12.6f\n", $correction_norm, $error_norm );
$i++;
}
#
# dummy function for l2-norm of actual vs virtual elevation error:
sub f
{
my $x = shift;
my $y = shift;
my $ve = compute_virtual_elevation( $x, $y);
return l2_error( $ve, $actual_elevation);
}
#
# dummy function dfdx for l2-norm of actual vs virtual elevation error:
sub fx
{
my $x = shift;
my $y = shift;
my $delta = shift;
my $f1 = f( $x+$delta, $y);
my $f0 = f( $x, $y);
return ( $f1 - $f0 ) / $delta;
}
#
# dummy function dfdx for l2-norm of actual vs virtual elevation error:
sub fy
{
my $x = shift;
my $y = shift;
my $delta = shift;
my $f1 = f( $x, $y+$delta);
my $f0 = f( $x, $y);
return ( $f1 - $f0 ) / $delta;
}
#
# dummy function fxx for l2-norm of actual vs virtual elevation error:
sub fxx
{
my $x = shift;
my $y = shift;
my $delta = shift;
my $f1 = fx( $x+$delta, $y, $delta);
my $f0 = fx( $x, $y, $delta);
return ( $f1 - $f0 )/ $delta;
}
#
# dummy function fxy for l2-norm of actual vs virtual elevation error:
sub fxy
{
my $x = shift;
my $y = shift;
my $delta = shift;
my $f1 = fx( $x, $y+$delta, $delta);
my $f0 = fx( $x, $y, $delta);
return ( $f1 - $f0 )/ $delta;
}
#
# dummy function fyx for l2-norm of actual vs virtual elevation error:
sub fyx
{
my $x = shift;
my $y = shift;
my $delta = shift;
my $f1 = fy( $x+$delta, $y, $delta);
my $f0 = fy( $x, $y, $delta);
return ( $f1 - $f0 )/ $delta;
}
#
# dummy function fyy for l2-norm of actual vs virtual elevation error:
sub fyy
{
my $x = shift;
my $y = shift;
my $delta = shift;
my $f1 = fy( $x, $y+$delta, $delta);
my $f0 = fy( $x, $y, $delta);
return ( $f1 - $f0 )/ $delta;
}
#
# 2x2 Matrix solver
#
# Solves the system A x = b using Cramer's rule:
sub solve_2x2_matrix
{
my $A = shift; #reference to 2x2 matrix
my $b = shift; # column vector rhs
my $det_A = ($A->[0][0] * $A->[1][1]) - ($A->[1][0] * $A->[0][1]);
if( $det_A == 0 )
{
die( "Determinant of matrix system is zero!\n");
}
my $cramer_0 = $b->[0] * $A->[1][1] - $b->[1] * $A->[0][1];
my $cramer_1 = $A->[0][0] * $b->[1] - $A->[1][0] * $b->[0];
my @solution = ( $cramer_0 / $det_A , $cramer_1 / $det_A );
return \@solution;
}
#
# Determine elevation vector:
sub compute_virtual_elevation
{
my $crr = shift;
my $cda = shift;
# my $power_data = shift;
# my $m = shift;
# my $rho = shift;
# my $dt = shift;
# my $v_factor = shift;
# my $start_elev = shift;
#
# Unpack:
my $p = $power_data->{p};
my $d = $power_data->{d};
my $v = $power_data->{v};
#
# With the input crr and cda, compute the virtual elevation
# profile:
my @ve;
#
# Unroll first row of calculation, to avoid if-statement in loop:
my $v1 = $v->[ 0 ]/$v_factor;
my $v2 = $v->[ 0 ]/$v_factor;
my $elev = $start_elev;
my @ve;
$ve[ 0 ] = 0; # CAUTION: assume ZERO starting elevation
#
# Now compute the acceleration, slope, and elevation change:
for( my $i=1; $i<$power_data->{n}; $i++ )
{
#
# unpack:
$v2 = $v->[ $i ]/$v_factor;
# use v2^2 - v1^2 = 2 * a * d to solve for a:
my $a = ( ($v2*$v2) - ($v1*$v1) ) / ( 2 * $dt * $v2 );
my $slope1 = slope( 0, $p->[$i]/$v2, $a, $m, $crr, $cda, $rho, $v2 );
#
my $delta_elev = $slope1 * $v2 * $dt;
$elev += $delta_elev;
#print "DATA:". join( ",", ( $i+1, $v->[$i], $p->[$i], $d->[$i], $v2, $a, $slope1, $delta_elev, $elev ) ) . "\n";
$ve[ $i ] = $elev;
$v1 = $v2;
}
return \@ve;
}
#
# Compute the L^2 error of 2 vectors:
sub l2_error
{
my $a = shift;
my $b = shift;
#
# are the 2 vectors the same length?
# no: die
if( scalar( @$a ) != scalar( @$b ) )
{
die "Cannot compute errors: " .
"vector a has length " . scalar( @$a ) . " and " .
"vector b has length " . scalar( @$b );
}
#
my $l2 = 0;
for( my $i=0; $i[ $i ] - $b->[ $i ];
$l2 += $delta * $delta;
}
return $l2;
}
#
# Given the formula
#
# F_drive - m g sin(theta) - Crr m g cos(theta) - CdA rho v^2 = m a
#
# compute theta (slope angle) given all the other variables, using
# regular iteration.
#
# NOTE:
# This equation is the exact form of the equations of motion, with
# no small-angle approximation as in the original Chung formulation.
# Whether this is justified or not, I don't personally believe in
# introducing more noise than necessary in the equations of motion.
# Only during the solution process should one decide on the
# tolerances.
#
#
# Test with:
# my $p = 321;
# my $m = 89;
# my $v_factor = 1.0;
# my $v = 7.111111;
# my $a = -0.665251;
# my $cda = 0.3845;
# my $crr = 0.005;
# my $rho = 1.236;
#
# slope( $p/$v, $a, $m, $crr, $cda, $rho, $v );
#
# should give: a return value of 0.039771537080168
#
# (Presently the slope is over-toleranced, which leads to
# a slow calculation. I will determine the tolerance that
# works.)
#
sub slope
{
my $compute_mode = shift;
my $f = shift;
my $a = shift;
my $m = shift;
my $crr = shift;
my $cda = shift;
my $rho = shift;
my $v = shift;
my $g = 9.80665;
#print "slope: " . join( ",", ( $f, $a, $m, $crr, $cda, $rho, $v ) ) . "\n";;
if( $compute_mode == 0 )
{
return ( ($f/($m*$g)) - $crr - ($cda* $rho*$v*$v/2)/($m*$g) - ($a/$g) );
}
my $tol = 0.000001;
my $error = 100000000;
my $theta = 0.01;
my $u = sin( $theta );
my $u_last = 0.01;
my $n_iters = 0;
while( ($error > $tol) && ($n_iters < 10) )
{
#print " " . tan( asin( $u ) ) . ":\n";
my $numerator = ( $f - $m * $a - $crr * $m * $g * sqrt( 1 - ($u*$u) ) - ($cda* $rho*$v*$v)/2);
my $denominator = ( $m * $g );
$u = $numerator / $denominator;
$error = abs( $u - $u_last );
$u_last = $u;
$n_iters++;
}
$theta = asin( $u );
return tan( $theta );
}
#
# read the power data from a CSV file:
# Minutes, Torq (N-m), Km/h, Watts, Km, Cadence, Hrate, ID
sub read_power_data
{
my $file = shift;
open POWER, $file;
my $i = 0;
my @p;
my @v;
my @d;
foreach my $line (
)
{
chomp $line;
my ( $t, $torque, $vel, $power, $dist, @others ) = split( /,/, $line );
$p[ $i ] = $power;
$d[ $i ] = $dist;
$v[ $i ] = $vel;
$i++;
}
close POWER;
my %power_data;
$power_data{n} = $i;
$power_data{p} = \@p;
$power_data{v} = \@v;
$power_data{d} = \@d;
return \%power_data;
}
#
# read elevation data:
sub read_elevation_data
{
my $elev_file = shift;
open ELEV, $elev_file;
my @e;
my $i = 0;
foreach my $line ( )
{
chomp $line;
$e[ $i ] = $line;
$i++;
}
close ELEV;
return \@e;
}
#
# print the elevation datai to file:
sub print_elev
{
my $file = shift;
my $vec = shift;
open ELEV, ">$file";
foreach my $v ( @$vec )
{
print ELEV $v . "\n";
}
close ELEV;
return 1;
}