#!/usr/bin/perl
use strict;
use lib '/usr/built/lib/perl5/site_perl';
use Getopt::Long;
use File::Basename;
use File::Find;
use File::Copy;
use FileHandle;
use POSIX qw(strftime ceil floor pow);
use Date::Manip;
use CGI ':standard';
use Data::Dumper;
use S710;
use GD;
use Statistics::LineFit;
use Math::Trig;
use constant Y_AXIS_POWER => 4;
# Parse command line options
our $opt_srddir = "$ENV{HOME}/Polar";
our $opt_name = "Tux";
our $opt_notes;
our $opt_summary;
our $opt_supplement;
our $opt_force;
our $opt_help;
if (not GetOptions("srddir=s", "name=s", "notes=s", "summary=s", "supplement=s", "force", "help") or
$opt_help) {
die "USAGE: $0 [-srddir directory] [-name name] [-notes file] [-summary file] [-force] [-help]\n";
}
# Find all raw workout data files
my @rawfiles = ( );
find(sub {
if($File::Find::name =~ /\.srd$/) {
push(@rawfiles, $File::Find::name);
}
}, $opt_srddir);
# If there was a notes file, read it in
my $notes = { };
if (defined($opt_notes)) {
my $fh = FileHandle->new($opt_notes);
while (<$fh>) {
if ($_ =~ /^\s*(.*?):\s+(.*)$/) {
my $unixtime = UnixDate(ParseDate($1), "%s");
if (defined($unixtime)) {
$notes->{$unixtime} = $2;
}
}
}
}
# If there was an other workouts file, read it in
my $supplements = [ ];
if (defined($opt_supplement)) {
my $fh = FileHandle->new($opt_supplement);
while (<$fh>) {
if ($_ =~ /^\s*(\S+)\s+(\S+)\s+(.+?)\s+(\S+)\s*$/) {
my $unixtime = UnixDate(ParseDate("01:00 AM $1"), "%s");
if (defined($unixtime)) {
push(@{$supplements}, { unixtime => $unixtime,
date => strftime("%m/%d/%y", localtime($unixtime)),
type => $2,
distance => $3,
duration => $4 });
}
}
}
}
# Style sheet used by the generated pages (see below BEGIN block)
my $style;
# Associative array to hold workout summaries for generating the index page
my $workouts = { };
# For each raw workout data file that was found...
foreach my $rawfile ( @rawfiles ) {
my $basefile = basename($rawfile);
my $rawtime = $opt_force ? time() : (stat($rawfile))[9];
# Get the S710 package to parse the raw workout data
my $workout = S710::full_workout(dirname($rawfile), $basefile);
if (not defined($workout)) {
print STDERR "Error: Could not read raw workout data from file \"$rawfile\".\n";
next;
}
# Filter out unreasonably low or high heart rate readings from the data
for (my $i=1; $i<$workout->{samples}; $i++) {
if ($workout->{hr_data}->[$i] < 50 or $workout->{hr_data}->[$i] > 190) {
$workout->{hr_data}->[$i] = $workout->{hr_data}->[$i-1];
}
}
if (not $workout->{no_speed}) {
if ($workout->{avg_speed} < 1) {
$workout->{no_speed} = 1;
$workout->{no_power} = 1;
}
}
if (not $workout->{no_speed}) {
$workout->{type} = "Bike";
}
# Filter out unreasonably high speed readings from the data
if (not $workout->{no_speed}) {
for (my $i=1; $i<$workout->{samples}; $i++) {
if ($workout->{speed_data}->[$i] > 45) {
$workout->{speed_data}->[$i] = $workout->{speed_data}->[$i-1];
}
}
}
# Compute the sample numbers of the lap boundaries
for (my $i=0; $i<$workout->{laps}; $i++) {
if ($workout->{lap_data}->[$i]->{cumulative} =~ /^(\d+):(\d{2}):(\d{2})/) {
$workout->{lap_data}->[$i]->{end_sample} =
int(($1*3600 + $2*60 + $3) / $workout->{recording_interval});
}
}
# Compute the minimum and maximum heart rates here (filter out
# unreasonably low or high heart rate readings from the raw data)
my ($minhr, $maxhr, $avghr, $counthr) = (300, 0, 0, 0);
foreach my $hr ( @{$workout->{hr_data}} ) {
if ($hr > $maxhr) { $maxhr = $hr; }
if ($hr < $minhr) { $minhr = $hr; }
$avghr += $hr;
$counthr++;
}
$avghr = int($avghr/$counthr);
if ($avghr == 0 and
$maxhr == 0) {
$avghr = "N";
$maxhr = "A";
}
# Compute the maximum speed
my $maxspd = 0;
if (not $workout->{no_speed}) {
$maxspd = (sort { $b <=> $a } @{$workout->{speed_data}})[0];
}
if (not $workout->{no_alt}) {
for (my $i=0; $i<$workout->{samples}; $i++) {
if ($workout->{alt_data}->[$i] != 0) {
for (; $i>=1; $i--) {
$workout->{alt_data}->[$i-1] = $workout->{alt_data}->[$i];
}
last;
}
}
}
# Flatten the power data into a simple array (removing L/R balance and pedaling index)
my ($maxpow, $avgpow);
if (not $workout->{no_power} and
ref($workout->{power_data}->[0]) eq "ARRAY") {
$workout->{power_data} = [ map { $_->[0] } @{$workout->{power_data}} ];
$maxpow = (sort { $a <=> $b } @{$workout->{power_data}})[-1];
$avgpow = 0;
my $count = 0;
for (my $i=0; $i<$workout->{samples}; $i++) {
if ($workout->{power_data}->[$i] > 0) {
$avgpow += $workout->{power_data}->[$i];
$count++;
}
}
if ($count > 0) {
$avgpow = ceil($avgpow / $count * 10) / 10;
}
my $period = 30 / $workout->{recording_interval};
if ($period > 1) {
for (my $i=$workout->{samples}-1; $i>=($period-1); $i--) {
my $sum = 0;
for (my $j=$i-($period-1); $j<=$i; $j++) {
$sum += $workout->{power_data}->[$j];
}
$sum /= $period;
$workout->{power_data}->[$i] = $sum;
}
}
}
$maxpow = ceil($maxpow * 10) / 10;
# If elevation data is available, but the variation
# is very small, treat it as if it doesn't exist
if (not $workout->{no_alt}) {
my ($minalt, $maxalt) = (sort { $a <=> $b } @{$workout->{alt_data}})[0,-1];
if (($maxalt - $minalt) < 50) {
$workout->{no_alt} = 1;
# If elevation data was available and the range was too small, but speed
# data is available, assume this was a bike workout on the indoor trainer
# and compute power data from speed data
if (not $workout->{no_speed}) {
if (not $workout->{no_power}) {
$workout->{power_data2} = $workout->{power_data};
}
$workout->{power_data} = [ ];
for (my $i=0; $i<$workout->{samples}; $i++) {
my $sum = 0;
for (my $j=($i<5)?0:$i-5; $j<=$i; $j++) {
$sum += $workout->{speed_data}->[$j];
}
$sum /= ($i<5) ? $i+1 : 6;
$workout->{power_data}->[$i] = 5.244820 * $sum + 0.019168 * pow($sum, 3);
}
delete($workout->{no_power});
}
}
}
my ($IF, $TSS);
if (not $workout->{no_power}) {
my $avg = 0;
for (my $i=5; $i<@{$workout->{power_data}}; $i++) {
$avg += $workout->{power_data}->[$i] ** 4;
}
$avg = ($avg / ($workout->{samples} - 5)) ** 0.25;
$IF = $avg / 200;
$TSS = $avg * $workout->{samples} * $workout->{recording_interval} * $IF;
$TSS /= 36 * 200;
$IF = ceil($IF * 100) / 100;
$TSS = ceil($TSS);
}
# Compute total climb amount
my $climb = 0;
if (not $workout->{no_alt}) {
for (my $i=6; $i<@{$workout->{alt_data}}; $i++) {
if ($workout->{alt_data}->[$i] > $workout->{alt_data}->[$i-1]) {
$climb += $workout->{alt_data}->[$i] - $workout->{alt_data}->[$i-1];
}
}
}
# If elevation and speed (and therefore distance) data is available...
if (not $workout->{no_alt} and
not $workout->{no_speed} and
($rawtime > (stat("images/$basefile.elt.png"))[9] or
$rawtime > (stat("images/$basefile.eld.png"))[9])) {
my @x;
my @xi;
my @y;
$x[0] = 0;
$xi[0] = 0;
$y[0] = $workout->{alt_data}->[0];
for (my $i=1; $i<$workout->{samples}; $i++) {
if (($workout->{dist_data}->[$i] - $x[-1]) >= 0.1) {
push(@x, $x[-1]+0.1);
push(@xi, $i);
push(@y, $workout->{alt_data}->[$i]);
}
}
# my $fh = FileHandle->new(">elev");
# for (my $i=0; $i<@x; $i++) {
# $fh->print("$x[$i] $y[$i]\n");
# }
# $fh->close();
my @results = piecewise(\@x, \@y);
# my $fh = FileHandle->new(">fit");
# for (my $i=0; $i<@results; $i++) {
# $fh->print("$x[$results[$i]] $y[$results[$i]]\n");
# }
# $fh->close();
for (my $i=0; $i<@results-2; $i++) {
my ($x0, $x1, $x2) = @x[@results[$i..$i+2]];
my ($y0, $y1, $y2) = @y[@results[$i..$i+2]];
my $m1 = ($y1 - $y0) / (5280 * ($x1 - $x0));
my $m2 = ($y2 - $y1) / (5280 * ($x2 - $x1));
my $theta = atan(($m2 - $m1) / (1 + $m1*$m2));
if (abs(rad2deg($theta)) < 1.15) {
splice(@results, $i+1, 1);
$i--;
}
elsif (($m1 * $m2) >= 0 and
abs(rad2deg($theta)) < 2 and
(($x1 - $x0) < 0.25 or
($x2 - $x1) < 0.25)) {
splice(@results, $i+1, 1);
$i--;
}
}
# my $fh = FileHandle->new(">fit2");
# for (my $i=0; $i<@results; $i++) {
# $fh->print("$x[$results[$i]] $y[$results[$i]]\n");
# }
# $fh->close();
for (my $i=0; $i<$#results; $i++) {
my ($x0, $x1) = @x[@results[$i..$i+1]];
my ($y0, $y1) = @y[@results[$i..$i+1]];
my $m = ($y1 - $y0) / (5280 * ($x1 - $x0));
if (($x1 - $x0) >= 0.2 and
$m >= 0.025) {
my $begin = $xi[$results[$i]];
my $end = $xi[$results[$i+1]];
while ($workout->{alt_data}->[$begin+1] <= $workout->{alt_data}->[$begin]) {
$begin++;
}
while ($workout->{alt_data}->[$end-1] >= $workout->{alt_data}->[$end]) {
$end--;
}
$m = ($workout->{alt_data}->[$end] - $workout->{alt_data}->[$begin]) /
(5280 * ($workout->{dist_data}->[$end] - $workout->{dist_data}->[$begin]));
if ($m >= 0.03) {
push(@{$workout->{big_climbs}}, { begin => $begin, end => $end, sample => ($begin+$end)/2, slope => $m });
}
}
}
}
for (my $i=0; $i<$workout->{laps}; $i++) {
my $sum = 0;
my $count = 0;
for (my $sample = $i ? $workout->{lap_data}->[$i-1]->{end_sample}+1 : 0;
$sample < $workout->{lap_data}->[$i]->{end_sample};
$sample++) {
if ($workout->{speed_data}->[$sample] >= 3) {
$sum += $workout->{speed_data}->[$sample];
$count++;
}
}
$workout->{lap_data}->[$i]->{avg_speed} = $count ? $sum/$count : 0;
}
# Generate the heart rate histogram plot
if ($rawtime > (stat("images/$basefile.hrh.png"))[9]) {
S710::plot_byte_histogram($rawfile,
S710::Y_AXIS_HEART_RATE,
'#ff0000',
80,
200,
320,
180,
1,
"images/$basefile.hrh.png");
}
# Generate the heart rate zones plot
if ($rawtime > (stat("images/$basefile.hrz.png"))[9]) {
S710::plot_byte_zones($rawfile,
S710::Y_AXIS_HEART_RATE,
320,
180,
"images/$basefile.hrz.png");
}
# Generate the heart rate throughout workout plot
if ($rawtime > (stat("images/$basefile.hrt.png"))[9]) {
plot_workout_xy($workout,
S710::Y_AXIS_HEART_RATE,
S710::X_AXIS_TIME,
'#ff0000',
S710::TIC_LINES,
800,
240,
"images/$basefile.hrt.png");
}
# If distance (speed) data is also available, generate a plot against distance
if (not $workout->{no_speed}) {
if ($rawtime > (stat("images/$basefile.hrd.png"))[9]) {
plot_workout_xy($workout,
S710::Y_AXIS_HEART_RATE,
S710::X_AXIS_DISTANCE,
'#ff0000',
S710::TIC_LINES,
800,
240,
"images/$basefile.hrd.png");
}
}
# Generate elevation throughout workout plot
if (not $workout->{no_alt}) {
if ($rawtime > (stat("images/$basefile.elt.png"))[9]) {
plot_workout_xy($workout,
S710::Y_AXIS_ALTITUDE,
S710::X_AXIS_TIME,
'#00ff00',
S710::TIC_LINES|S710::TIC_SHADE_GREEN,
800,
240,
"images/$basefile.elt.png");
}
# If distance (speed) data is also available, generate a plot against distance
if (not $workout->{no_speed}) {
if ($rawtime > (stat("images/$basefile.eld.png"))[9]) {
plot_workout_xy($workout,
S710::Y_AXIS_ALTITUDE,
S710::X_AXIS_DISTANCE,
'#00ff00',
S710::TIC_LINES|S710::TIC_SHADE_GREEN,
800,
240,
"images/$basefile.eld.png");
}
}
}
# Generate power throughout workout plot
if (not $workout->{no_power}) {
if ($rawtime > (stat("images/$basefile.pot.png"))[9]) {
plot_workout_xy($workout,
Y_AXIS_POWER,
S710::X_AXIS_TIME,
'#0000ff',
S710::TIC_LINES,
800,
240,
"images/$basefile.pot.png");
}
if ($rawtime > (stat("images/$basefile.pod.png"))[9]) {
plot_workout_xy($workout,
Y_AXIS_POWER,
S710::X_AXIS_DISTANCE,
'#0000ff',
S710::TIC_LINES,
800,
240,
"images/$basefile.pod.png");
}
}
# Generate speed throughout workout plot
if (not $workout->{no_speed}) {
if ($rawtime > (stat("images/$basefile.spt.png"))[9]) {
plot_workout_xy($workout,
S710::Y_AXIS_SPEED,
S710::X_AXIS_TIME,
'#0000ff',
S710::TIC_LINES,
800,
240,
"images/$basefile.spt.png");
}
if ($rawtime > (stat("images/$basefile.spd.png"))[9]) {
plot_workout_xy($workout,
S710::Y_AXIS_SPEED,
S710::X_AXIS_DISTANCE,
'#0000ff',
S710::TIC_LINES,
800,
240,
"images/$basefile.spd.png");
}
}
# Put the workout summary aside for later use (during index generation)
$workouts->{$workout->{unixtime}} = { workout => $workout,
type => undef,
unixtime => $workout->{unixtime},
date => strftime("%m/%d/%y", localtime($workout->{unixtime}-2*60*60)),
duration => $workout->{duration},
distance => $workout->{exercise_distance},
avg_hr => $avghr,
max_hr => $maxhr,
max_spd => $maxspd,
climb => $climb,
avg_pow => $avgpow,
max_pow => $maxpow,
if => $IF,
tss => $TSS,
basefile => $basefile,
url => "$basefile.html",
rawtime => $rawtime };
}
my @workouts = sort { $a->{unixtime} <=> $b->{unixtime} } values(%{$workouts});
map { $_->{rawtime} = time() } @workouts[-13..-1];
for (my $i=0; $i<@workouts; $i++) {
my $workout = $workouts[$i];
if ($workout->{rawtime} < (stat($workout->{url}))[9]) {
next;
}
# print Dumper($workout);
# Create the file that will hold the HTML for this workout
my $fh = FileHandle->new(">$workout->{url}");
# Create the CGI object used for HTML generation
my $cgi = CGI::new();
# Start the HTML
print $fh $cgi->start_html(-title => "${opt_name}'s Training Log: " . strftime("%A, %B %d %G %I:%M %p", localtime($workout->{unixtime})),
-style => { -code => $style },
-bgcolor => "#ffffff",
-script => { -language => 'JAVASCRIPT',
-code => generateJavaScript($workout->{basefile}, $workout->{workout}) });
# Big table holds everything for layout purposes
print $fh "
| \n";
# Heading with the workout date and time
print $fh br(), "\n";
print $fh "\n";
# First section contains workout summary information
print $fh h3("Summary"), "\n";
my @rows;
push(@rows, Tr(td(["Workout length:", "$workout->{workout}->{duration} ",
"Heart rate (avg / max):", "$workout->{avg_hr} / $workout->{max_hr} bpm"])));
if (not $workout->{workout}->{no_speed}) {
push(@rows, Tr(td(["Average speed:", "$workout->{workout}->{avg_speed} mph",
"Maximum speed:", sprintf("%.1f mph", $workout->{max_spd})])));
}
if (not $workout->{workout}->{no_alt}) {
push(@rows, Tr(td(["Average elevation:", "$workout->{workout}->{avg_alt} ft",
"Maximum elevation:", "$workout->{workout}->{max_alt} ft"])));
}
if (not $workout->{workout}->{no_speed} or not $workout->{workout}->{no_alt}) {
push(@rows, Tr(td([$workout->{workout}->{no_speed} ? () : ("Distance ridden:", "$workout->{workout}->{exercise_distance} mi"),
$workout->{workout}->{no_alt} ? () : ("Total climb:", "$workout->{climb} ft")])));
# push(@rows, Tr(td(["Distance ridden:", "$workout->{workout}->{exercise_distance} mi",
# "Total climb:", "$workout->{climb} ft"])));
}
if ($workout->{workout}->{energy} > 0) {
push(@rows, Tr(td(["Calories burned:", "$workout->{workout}->{energy} kCal"])));
}
if (not $workout->{workout}->{no_power}) {
push(@rows, Tr(td(["Power (avg / max):", "$workout->{avg_pow} / $workout->{max_pow} W"])));
push(@rows, Tr(td(["IF / TSS:", "$workout->{if} / $workout->{tss}"])));
}
print $fh table(@rows), "\n";
# If there is a user-provided note for this workout, include it here
if (defined(my $note = $notes->{int($workout->{unixtime}/60)*60})) {
print $fh h3("Notes"), "\n";
print $fh table(Tr(td(font({face => "courier", size => "-1"}, $note)))), "\n";
}
# Next is the heart rate histogram and zone chart
print $fh h3("Heart rate histogram and zone chart"), "\n";
print $fh table({-width => 484},
Tr(td({-align => "right"},
[img({-src => "images/$workout->{basefile}.hrh.png"}),
img({-src => "images/$workout->{basefile}.hrz.png"})]))), "\n";
# Followed by the heart rate vs time chart
if (not $workout->{workout}->{no_speed}) {
print $fh h3(table({-width => "100%", -border => 0, -cellpadding => 0, -cellspacing => 0},
Tr(td({-align => "left", -id => "hrheading"}, "Heart rate vs. time"),
td({-align => "right"}, "[ vs. distance ]")))), "\n";
}
else {
print $fh h3("Heart rate vs. time"), "\n";
}
print $fh img({-id => "hrimage", -src => "images/$workout->{basefile}.hrt.png"}), "\n";
# If elevation data is available, the chart is included
if (not $workout->{workout}->{no_alt}) {
print $fh h3({-id => "elheading"}, "Elevation vs. time"), "\n";
print $fh img({-id => "elimage", -src => "images/$workout->{basefile}.elt.png"}), "\n";
}
# If power data is available, the chart is included
if (not $workout->{workout}->{no_power}) {
print $fh h3({-id => "poheading"}, "Power vs. time"), "\n";
print $fh img({-id => "poimage", -src => "images/$workout->{basefile}.pot.png"}), "\n";
}
# If speed data is available, the chart is included
if (not $workout->{workout}->{no_speed}) {
print $fh h3({-id => "spheading"}, "Speed vs. time"), "\n";
print $fh img({-id => "spimage", -src => "images/$workout->{basefile}.spt.png"}), "\n";
}
# Close the layout table
print $fh " |
\n";
# End the HTML
#print $fh $cgi->end_html();
print $fh "\n";
print $fh "