joule 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,18 @@
1
+ = Joule
2
+
3
+ == Description
4
+
5
+ Joule is a Ruby library for parsing bicycle powermeter data.
6
+
7
+
8
+ == License
9
+
10
+ (The MIT License)
11
+
12
+ Copyright (c) 2008 - 2009 Andrew Olson
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ‘Software’), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED ‘AS IS’, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,48 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+
5
+ require 'rake/gempackagetask'
6
+
7
+ task :default => [:test]
8
+
9
+ desc 'Test Joule.'
10
+ Rake::TestTask.new(:test) do |t|
11
+ t.libs << 'lib'
12
+ t.pattern = 'test/**/test_*.rb'
13
+ t.verbose = true
14
+ end
15
+
16
+
17
+
18
+
19
+
20
+ spec = Gem::Specification.new do |s|
21
+ s.name = "joule"
22
+ s.summary = "A Ruby library for parsing bicycle powermeter data."
23
+ s.description = ""
24
+ s.homepage = "http://github.com/anolson/joule"
25
+
26
+ s.version = "1.0.0"
27
+ s.date = "2010-1-5"
28
+
29
+ s.authors = ["Andrew Olson"]
30
+ s.email = "anolson@gmail.com"
31
+
32
+ s.require_paths = ["lib"]
33
+ s.files = Dir["lib/**/*"] + ["README.rdoc", "Rakefile"]
34
+ s.extra_rdoc_files = ["README.rdoc"]
35
+
36
+ s.has_rdoc = true
37
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Joule", "--main", "README.rdoc"]
38
+
39
+ s.rubygems_version = "1.3.4"
40
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.2")
41
+ s.add_dependency("nokogiri", ">= 1.4.1")
42
+ s.add_dependency("fastercsv", ">=1.4.0")
43
+ end
44
+
45
+
46
+ Rake::GemPackageTask.new(spec) do |pkg|
47
+ pkg.need_tar = true
48
+ end
@@ -0,0 +1,14 @@
1
+ require 'joule/array'
2
+ require 'joule/float'
3
+
4
+ require 'joule/data_point'
5
+ require 'joule/marker'
6
+ require 'joule/calculator'
7
+ require 'joule/units_conversion'
8
+
9
+ require 'joule/csv'
10
+ require 'joule/ibike'
11
+ require 'joule/powertap'
12
+ require 'joule/srm'
13
+ require 'joule/tcx'
14
+
@@ -0,0 +1,23 @@
1
+ class Array
2
+ def summation
3
+ inject{|sum, value|
4
+ sum + value}
5
+ end
6
+
7
+ def maximum
8
+ inject {|max, value| value>max ? value : max}
9
+ end
10
+
11
+ def average
12
+ summation/length
13
+ end
14
+
15
+ def average_maximum(size)
16
+ mean_max = {:start => 0.0, :value=> 0.0}
17
+ each_index do |i|
18
+ mean = slice(i, size).average
19
+ mean > mean_max[:value] && mean_max = {:start => i, :value => mean}
20
+ end
21
+ mean_max
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ require 'joule/calculator/marker_calculator'
2
+ require 'joule/calculator/peak_power_calculator'
3
+ require 'joule/calculator/power_calculator'
@@ -0,0 +1,40 @@
1
+ module Joule
2
+ module Calculator
3
+ module MarkerCalculator
4
+
5
+ def calculate_marker_averages(marker)
6
+ marker.average_power = Joule::Calculator::PowerCalculator::average(
7
+ self.data_points[marker.start..marker.end].collect() {|v| v.power})
8
+
9
+ marker.average_speed = Joule::Calculator::PowerCalculator::average(
10
+ self.data_points[marker.start..marker.end].collect() {|v| v.speed})
11
+
12
+ marker.average_cadence = Joule::Calculator::PowerCalculator::average(
13
+ self.data_points[marker.start..marker.end].collect() {|v| v.cadence})
14
+
15
+ marker.average_heartrate = Joule::Calculator::PowerCalculator::average(
16
+ self.data_points[marker.start..marker.end].collect() {|v| v.heartrate})
17
+ end
18
+
19
+ def calculate_marker_maximums(marker)
20
+ marker.maximum_power = Joule::Calculator::PowerCalculator::maximum(
21
+ self.data_points[marker.start..marker.end].collect() {|value| value.power})
22
+
23
+ marker.maximum_speed = Joule::Calculator::PowerCalculator::maximum(
24
+ self.data_points[marker.start..marker.end].collect() {|value| value.speed})
25
+
26
+ marker.maximum_cadence = Joule::Calculator::PowerCalculator::maximum(
27
+ self.data_points[marker.start..marker.end].collect() {|value| value.cadence})
28
+
29
+ marker.maximum_heartrate = Joule::Calculator::PowerCalculator::maximum(
30
+ self.data_points[marker.start..marker.end].collect() {|value| value.heartrate})
31
+ end
32
+
33
+ def calculate_marker_training_metrics(marker)
34
+ marker.normalized_power = Joule::Calculator::PowerCalculator::normalized_power(
35
+ self.data_points[marker.start..marker.end].collect() {|value| value.power}, self.properties.record_interval)
36
+ end
37
+
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ module Joule
2
+ module Calculator
3
+ module PeakPowerCalculator
4
+
5
+ def calculate_peak_power_values(options = {})
6
+ power_values = @data_points.collect{|v| v.power}
7
+ options[:durations].each { |duration|
8
+ @peak_powers << calculate_peak_power_value(duration, options[:total_duration], power_values)
9
+ }
10
+
11
+ end
12
+
13
+ def calculate_peak_power_value(duration, total_duration, power_values)
14
+ if duration > total_duration
15
+ { :duration => duration,
16
+ :value => 0,
17
+ :start => 0 }
18
+ else
19
+ peak_power = Joule::PowerCalculator::peak_power(power_values, (duration/@properties.record_interval))
20
+ { :duration => duration,
21
+ :value => peak_power[:value],
22
+ :start => peak_power[:start] }
23
+ end
24
+ end
25
+
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,48 @@
1
+ module Joule
2
+ module Calculator
3
+ class PowerCalculator
4
+ def self.average(values)
5
+ values.average
6
+ end
7
+
8
+ def self.maximum(values)
9
+ values.maximum
10
+ end
11
+
12
+ def self.total(values)
13
+ values.sum
14
+ end
15
+
16
+ def self.peak_power(values, size)
17
+ values.average_maximum size
18
+ end
19
+
20
+ def self.training_stress_score(duration_seconds, threshold_power)
21
+ if(threshold_power > 0)
22
+ normalized_work = normalized_power * duration_seconds
23
+ raw_training_stress_score = normalized_work * intensity_factor(threshold_power)
24
+ (raw_training_stress_score/(threshold_power * 3600)) * 100
25
+ end
26
+ end
27
+
28
+ def self.intensity_factor(normalized_power, threshold_power)
29
+ if(threshold_power > 0)
30
+ normalized_power/threshold_power
31
+ end
32
+ end
33
+
34
+ def self.normalized_power(values, record_interval)
35
+ thirty_second_record_count = 30 / record_interval
36
+ thirty_second_rolling_power = Array.new
37
+ if(values.length > thirty_second_record_count)
38
+ values.slice(thirty_second_record_count..-1).each_slice(thirty_second_record_count) { |s|
39
+ thirty_second_rolling_power << s.average ** 4
40
+ }
41
+ thirty_second_rolling_power.average ** 0.25
42
+ else
43
+ 0
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1 @@
1
+ require 'joule/csv/parser'
@@ -0,0 +1,68 @@
1
+ require 'fastercsv'
2
+
3
+ module Joule
4
+ module CSV
5
+ class Parser
6
+ include Joule::Calculator::MarkerCalculator
7
+ include Joule::Calculator::PeakPowerCalculator
8
+ include Joule::UnitsConversion
9
+
10
+ attr_reader :properties, :markers, :data_points, :peak_powers
11
+
12
+
13
+ def initialize(data)
14
+ @data = data
15
+ @markers = Array.new
16
+ @data_points = Array.new
17
+ @peak_powers = Array.new
18
+ end
19
+
20
+ def get_parser
21
+ header = FasterCSV.parse(@data).shift
22
+ if header[0].to_s.downcase.eql?("ibike")
23
+ return IbikeFileParser.new(data)
24
+ else
25
+ return PowertapFileParser.new(data)
26
+ end
27
+ end
28
+
29
+ def parse(options = {})
30
+ parse_header
31
+ parse_markers
32
+ parse_data_points
33
+
34
+ if(options[:calculate_marker_values])
35
+ calculate_marker_values()
36
+ end
37
+
38
+ if(options[:calculate_peak_power_values])
39
+ calculate_peak_power_values(:durations => options[:durations], :total_duration => @markers.first.duration_seconds)
40
+ end
41
+
42
+ end
43
+
44
+ protected
45
+ def create_workout_marker(records)
46
+ Marker.new(:start => 0, :end => records.size - 1, :comment => "")
47
+ end
48
+
49
+ def calculate_marker_values
50
+ @markers.each_with_index { |marker, i|
51
+ calculate_marker_averages marker
52
+ calculate_marker_maximums marker
53
+ calculate_marker_training_metrics marker
54
+
55
+ if i.eql?(0)
56
+ marker.distance = @data_points.last.distance
57
+ marker.duration_seconds = @data_points.last.time
58
+ else
59
+ marker.distance = @data_points[marker.end].distance - @data_points[marker.start].distance
60
+ marker.duration_seconds = @data_points[marker.end].time - @data_points[marker.start].time
61
+ end
62
+ marker.energy = (marker.average_power.round * marker.duration_seconds)/1000
63
+ }
64
+ end
65
+
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,20 @@
1
+ module Joule
2
+ class DataPoint
3
+ attr_accessor :altitude, :cadence, :distance, :heartrate, :latitude, :longitude, :power, :speed, :time, :time_of_day, :time_with_pauses, :torque
4
+
5
+ def initialize()
6
+ @time_of_day = 0
7
+ @time = 0
8
+ @time_with_pauses=0
9
+ @power = 0.0
10
+ @speed = 0.0
11
+ @cadence = 0
12
+ @distance = 0.0
13
+ @altitude = 0.0
14
+ @latitude = 0.0
15
+ @longitude = 0.0
16
+ @heartrate = 0
17
+ @torque = 0.0
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ # Copyright (c) 2004-2009 David Heinemeier Hansson
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ class Float
23
+ remove_method :round
24
+
25
+ # Rounds the float with the specified precision.
26
+ #
27
+ # x = 1.337
28
+ # x.round # => 1
29
+ # x.round(1) # => 1.3
30
+ # x.round(2) # => 1.34
31
+ def round(precision = nil)
32
+ if precision
33
+ magnitude = 10.0 ** precision
34
+ (self * magnitude).round / magnitude
35
+ else
36
+ super()
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,2 @@
1
+ require 'joule/ibike/parser'
2
+ require 'joule/ibike/properties'
@@ -0,0 +1,66 @@
1
+ module Joule
2
+ module IBike
3
+ class Parser < Joule::CSV::Parser
4
+ IBIKE = '.csv'
5
+ SPEED = 0
6
+ WINDSPEED = 1
7
+ POWER = 2
8
+ DISTANCE = 3
9
+ CADENCE = 4
10
+ HEARTRATE = 5
11
+ ELEVATION = 6
12
+ SLOPE = 7
13
+ DFPM_POWER = 11
14
+ LATITUDE = 12
15
+ LONGITUDE = 13
16
+ TIMESTAMP = 14
17
+
18
+ def parse_header()
19
+ records = FasterCSV.parse(@data)
20
+ header = records.shift
21
+
22
+ @properties = Joule::IBike::Properties.new
23
+ @properties.version=header[1]
24
+ @properties.units=header[2]
25
+ header = records.shift
26
+ @properties.date_time = Time.mktime(header[0].to_i, header[1].to_i, header[2].to_i, header[3].to_i, header[4].to_i, header[5].to_i)
27
+ records.shift
28
+ header = records.shift
29
+ @properties.total_weight = header[0]
30
+ @properties.energy = header[1]
31
+ @properties.record_interval = header[4].to_i
32
+ @properties.starting_elevation = header[5]
33
+ @properties.total_climbing = header[6]
34
+ @properties.wheel_size = header[7]
35
+ @properties.temperature = header[8]
36
+ @properties.starting_pressure = header[9]
37
+ @properties.wind_scaling = header[10]
38
+ @properties.riding_tilt = header[11]
39
+ @properties.calibration_weight = header[12]
40
+ @properties.cm = header[13]
41
+ @properties.cda = header[14]
42
+ @properties.crr = header[15]
43
+ end
44
+
45
+ def parse_data_points()
46
+ records = FasterCSV.parse(@data).slice(5..-1)
47
+ records.each_with_index { |record, index|
48
+ data_point = DataPoint.new
49
+ data_point.time = index * @properties.record_interval
50
+ data_point.speed = convert_speed(record[SPEED].to_f)
51
+ data_point.power = record[POWER].to_f
52
+ data_point.distance = convert_distance(record[DISTANCE].to_f)
53
+ data_point.cadence = record[CADENCE].to_i
54
+ data_point.heartrate = record[HEARTRATE].to_i
55
+ @data_points << data_point
56
+ }
57
+ end
58
+
59
+ def parse_markers
60
+ records = FasterCSV.parse(@data).slice(5..-1)
61
+ @markers << create_workout_marker(records)
62
+ end
63
+
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,52 @@
1
+ module Joule
2
+ module IBike
3
+ class Properties
4
+ ENGLISH_UNITS = "english"
5
+ METRIC_UNITS = "metric"
6
+
7
+ attr_accessor :version
8
+ attr_accessor :units
9
+ attr_accessor :date_time
10
+ attr_accessor :total_weight
11
+ attr_accessor :energy
12
+ attr_accessor :record_interval
13
+ attr_accessor :starting_elevation
14
+ attr_accessor :total_climbing
15
+ attr_accessor :wheel_size
16
+ attr_accessor :temperature
17
+ attr_accessor :starting_pressure
18
+ attr_accessor :wind_scaling
19
+ attr_accessor :riding_tilt
20
+ attr_accessor :calibration_weight
21
+ attr_accessor :cm
22
+ attr_accessor :cda
23
+ attr_accessor :crr
24
+
25
+ def distance_units_are_english?
26
+ self.units_are_english?
27
+ end
28
+
29
+ def distance_units_are_metric?
30
+ self.units_are_metric?
31
+ end
32
+
33
+ def speed_units_are_english?
34
+ self.units_are_english?
35
+ end
36
+
37
+ def distance_units_are_metric?
38
+ self.units_are_metric?
39
+ end
40
+
41
+ def units_are_english?
42
+ self.units.eql?(ENGLISH_UNITS)
43
+ end
44
+
45
+ def units_are_metric?
46
+ self.units.eql?(METRIC_UNITS)
47
+ end
48
+
49
+ end
50
+ end
51
+ end
52
+
@@ -0,0 +1,53 @@
1
+ class Marker
2
+ attr_accessor :active
3
+ attr_accessor :average_cadence
4
+ attr_accessor :average_heartrate
5
+ attr_accessor :average_power
6
+ attr_accessor :average_power_to_weight
7
+ attr_accessor :average_speed
8
+ attr_accessor :comment
9
+ attr_accessor :duration_seconds
10
+ attr_accessor :distance
11
+ attr_accessor :end
12
+ attr_accessor :energy
13
+ attr_accessor :intensity_factor
14
+ attr_accessor :maximum_cadence
15
+ attr_accessor :maximum_heartrate
16
+ attr_accessor :maximum_power
17
+ attr_accessor :maximum_power_to_weight
18
+ attr_accessor :maximum_speed
19
+ attr_accessor :normalized_power
20
+ attr_accessor :start
21
+ attr_accessor :start_time
22
+ attr_accessor :training_stress_score
23
+
24
+
25
+ def initialize(options = {})
26
+ @active = true
27
+ @average_cadence = 0
28
+ @average_heartrate = 0
29
+ @average_power = 0.0
30
+ @average_power_to_weight = 0.0
31
+ @average_speed = 0.0
32
+ @comment = ""
33
+ @duration_seconds = 0
34
+ @distance = 0.0
35
+ @end = options[:end]
36
+ @energy = 0
37
+ @intensity_factor = 0
38
+ @maximum_cadence = 0
39
+ @maximum_heartrate = 0
40
+ @maximum_power = 0.0
41
+ @maximum_power_to_weight = 0.0
42
+ @maximum_speed = 0.0
43
+ @normalized_power = 0
44
+ @start = options[:start]
45
+ @training_stress_score = 0.0
46
+
47
+ end
48
+
49
+ def start_time_in_seconds
50
+ (@start_time.hour * 3600) + (@start_time.min * 60) + @start_time.sec
51
+ end
52
+ end
53
+
@@ -0,0 +1,2 @@
1
+ require 'joule/powertap/parser'
2
+ require 'joule/powertap/properties'
@@ -0,0 +1,82 @@
1
+ module Joule
2
+ module PowerTap
3
+ class Parser < Joule::CSV::Parser
4
+ MINUTES = 0
5
+ TORQUE = 1
6
+ SPEED = 2
7
+ POWER = 3
8
+ DISTANCE = 4
9
+ CADENCE = 5
10
+ HEARTRATE = 6
11
+ MARKER = 7
12
+
13
+ def parse_header()
14
+ header = FasterCSV.parse(@data).shift
15
+ records = FasterCSV.parse(@data)
16
+ @properties = Joule::PowerTap::Properties.new
17
+ @properties.speed_units = header[SPEED].to_s.downcase
18
+ @properties.power_units = header[POWER].to_s.downcase
19
+ @properties.distance_units = header[DISTANCE].to_s.downcase
20
+ calculate_record_interval(records)
21
+ end
22
+
23
+ def parse_data_points()
24
+ records = FasterCSV.parse(@data)
25
+ records.shift
26
+
27
+ records.each { |record|
28
+ data_point = DataPoint.new
29
+ data_point.time = (record[MINUTES].to_f * 60).to_i
30
+ data_point.torque = record[TORQUE].to_f
31
+ data_point.speed = convert_speed(record[SPEED].to_f)
32
+ data_point.power = record[POWER].to_f
33
+ data_point.distance = convert_distance(record[DISTANCE].to_f)
34
+ data_point.cadence = record[CADENCE].to_i
35
+ data_point.heartrate = (record[HEARTRATE].to_i < 0) && 0 || record[HEARTRATE].to_i
36
+
37
+ @data_points << data_point
38
+ }
39
+ end
40
+
41
+ def parse_markers
42
+ records = FasterCSV.parse(@data)
43
+ records.shift
44
+ @markers << create_workout_marker(records)
45
+
46
+ current_marker_index = 0
47
+
48
+ records.each_with_index { |record, index|
49
+ if(record[MARKER].to_i > current_marker_index )
50
+ create_marker(index)
51
+ current_marker_index = current_marker_index + 1
52
+ end
53
+ }
54
+
55
+ #set the end of the last marker
56
+ set_previous_marker_end(records.size - 1)
57
+ end
58
+
59
+ def create_marker(start)
60
+ if(@markers.size.eql?(1))
61
+ @markers << Marker.new(:start => 0)
62
+ end
63
+ set_previous_marker_end(start - 1)
64
+ @markers << Marker.new(:start => start)
65
+ end
66
+
67
+ def set_previous_marker_end(value)
68
+ if(@markers.size > 1)
69
+ @markers.last.end = value
70
+ end
71
+ end
72
+
73
+ def calculate_record_interval(records)
74
+ times = Array.new
75
+ records[1..30].each_slice(2) {|s| times << ((s[1][MINUTES].to_f - s[0][MINUTES].to_f) * 60) }
76
+ @properties.record_interval = times.average.round
77
+ end
78
+
79
+ end
80
+
81
+ end
82
+ end
@@ -0,0 +1,32 @@
1
+ module Joule
2
+ module PowerTap
3
+ class Properties
4
+ ENGLISH_SPEED_UNITS = "miles/h"
5
+ ENGLISH_POWER_UNITS = "watts"
6
+ ENGLISH_DISTANCE_UNITS = "miles"
7
+
8
+ METRIC_SPEED_UNITS = "km/h"
9
+ METRIC_POWER_UNITS = "watts"
10
+ METRIC_DISTANCE_UNITS = "km"
11
+
12
+ attr_accessor :speed_units, :power_units, :distance_units, :record_interval
13
+
14
+ def speed_units_are_english?()
15
+ return self.speed_units.eql?(ENGLISH_SPEED_UNITS)
16
+ end
17
+
18
+ def speed_units_are_metric?()
19
+ return self.speed_units.eql?(METRIC_SPEED_UNITS)
20
+ end
21
+
22
+ def distance_units_are_english?()
23
+ return self.distance_units.eql?(ENGLISH_DISTANCE_UNITS)
24
+ end
25
+
26
+ def distance_units_are_metric?()
27
+ return self.distance_units.eql?(METRIC_DISTANCE_UNITS)
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,2 @@
1
+ require 'joule/srm/parser'
2
+ require 'joule/srm/properties'
@@ -0,0 +1,152 @@
1
+ require 'kconv'
2
+
3
+ module Joule
4
+ module SRM
5
+ class Parser
6
+ include Joule::Calculator::MarkerCalculator
7
+ include Joule::Calculator::PeakPowerCalculator
8
+
9
+ SRM = '.srm'
10
+ HEADER_SIZE=86
11
+ MARKER_SIZE=270
12
+ BLOCK_SIZE=6
13
+
14
+ attr_reader :properties, :markers, :data_points, :peak_powers
15
+
16
+ def initialize(data)
17
+ @data = data
18
+ @data_points = Array.new
19
+ @markers = Array.new
20
+ @peak_powers = Array.new
21
+ end
22
+
23
+ def parse(options = {})
24
+ parse_header
25
+ parse_markers
26
+ parse_blocks
27
+ parse_data_points
28
+ parse_data_point_times
29
+
30
+ if(options[:calculate_marker_values])
31
+ calculate_marker_values()
32
+ end
33
+
34
+ if(options[:calculate_peak_power_values])
35
+ calculate_peak_power_values(:durations => options[:durations], :total_duration => @markers.first.duration_seconds)
36
+ end
37
+ end
38
+
39
+ def parse_header()
40
+ str = @data.slice(0, HEADER_SIZE)
41
+ @properties = Joule::SRM::Properties.new
42
+ @properties.ident=str.slice(0,4)
43
+ @properties.srm_date = str.slice(4,2).unpack('S')[0]
44
+ @properties.wheel_size = str.slice(6,2).unpack('S')[0]
45
+ @properties.record_interval_numerator = str.slice(8,1).unpack('C')[0]
46
+ @properties.record_interval_denominator = str.slice(9,1).unpack('C')[0]
47
+ @properties.block_count = str.slice(10,2).unpack('S')[0]
48
+ @properties.marker_count = str.slice(12,2).unpack('S')[0]
49
+ @properties.comment = str.slice(16,70).toutf8.strip
50
+
51
+ str=@data.slice(HEADER_SIZE +
52
+ (MARKER_SIZE * (@properties.marker_count + 1 )) +
53
+ (BLOCK_SIZE * @properties.block_count) , 6)
54
+
55
+ @properties.zero_offset = str.slice(0,2).unpack('S')[0]
56
+ @properties.slope = str.slice(2,2).unpack('S')[0]
57
+ @properties.record_count = str.slice(4,2).unpack('S')[0]
58
+ end
59
+
60
+ def parse_markers
61
+ marker_offset = HEADER_SIZE
62
+
63
+ (@properties.marker_count + 1).times { |i|
64
+ str = @data.slice(marker_offset + (i * MARKER_SIZE), MARKER_SIZE)
65
+
66
+ marker = Marker.new
67
+ marker.comment = str.slice(0, 255).strip
68
+ marker.active = str.slice(255)
69
+ marker.start = str.slice(256,2).unpack('S')[0] - 1
70
+ marker.end = str.slice(258,2).unpack('S')[0] - 1
71
+ @markers << marker
72
+ }
73
+
74
+ end
75
+
76
+ def parse_blocks
77
+ block_offset = HEADER_SIZE + (MARKER_SIZE * (@properties.marker_count + 1 ))
78
+
79
+ @blocks = Array.new
80
+ @properties.block_count.times {|i|
81
+ str=@data.slice(block_offset + (i * BLOCK_SIZE), BLOCK_SIZE)
82
+
83
+ block = Hash.new
84
+ block[:time] = str.slice(0,4).unpack('I')[0]
85
+ block[:count] = str.slice(4,2).unpack('S')[0].to_i
86
+ @blocks << block
87
+ }
88
+ end
89
+
90
+ def parse_data_points()
91
+ count = 0
92
+ start = HEADER_SIZE + (MARKER_SIZE * (@properties.marker_count + 1 )) + (BLOCK_SIZE * @properties.block_count) + 7
93
+ total_distance = 0
94
+
95
+ while count < @properties.record_count
96
+ record=@data.slice(start + (count * 5), 5)
97
+ byte1=record.slice(0)
98
+ byte2=record.slice(1)
99
+ byte3=record.slice(2)
100
+ data_point = DataPoint.new
101
+
102
+ data_point.time = count * @properties.record_interval
103
+ data_point.power = ( (byte2 & 0x0F) | (byte3 << 4) ).to_f
104
+ data_point.speed = ( ( ( (byte2 & 0xF0) << 3) | (byte1 & 0x7F) ) * 32 ) #stored in mm/s
105
+ data_point.cadence = record.slice(3)
106
+ data_point.heartrate = record.slice(4)
107
+
108
+ total_distance = total_distance + (data_point.speed * @properties.record_interval)
109
+ data_point.distance = total_distance #in mm
110
+
111
+ @data_points << data_point
112
+ count=count + 1
113
+ end
114
+ end
115
+
116
+ def parse_data_point_times
117
+ count = 0
118
+ @blocks.each { |block|
119
+ relative_count = 0
120
+ while relative_count < block[:count]
121
+ @data_points[count].time_of_day = block[:time]/100 + (@properties.record_interval*relative_count)
122
+ @data_points[count].time_with_pauses = block[:time]/100 - @blocks[0][:time]/100 + (@properties.record_interval*(relative_count + 1))
123
+
124
+ relative_count=relative_count+1
125
+ count=count+1
126
+ end
127
+ }
128
+
129
+ @properties.date_time = data_points.first.time_of_day.to_i
130
+ end
131
+
132
+ def calculate_marker_values
133
+ @markers.each_with_index { |marker, i|
134
+ calculate_marker_averages marker
135
+ calculate_marker_maximums marker
136
+ calculate_marker_training_metrics marker
137
+
138
+ if i.eql?(0)
139
+ marker.distance = @data_points.last.distance
140
+ else
141
+ marker.distance = @data_points[marker.end + 1].distance - @data_points[marker.start].distance
142
+ end
143
+
144
+ marker.duration_seconds = (marker.end - marker.start + 1) * @properties.record_interval
145
+ marker.energy = (marker.average_power.round * marker.duration_seconds)/1000
146
+
147
+ }
148
+ end
149
+
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,31 @@
1
+ module Joule
2
+ module SRM
3
+ class Properties
4
+ attr_accessor :ident, :srm_date, :start_date_time, :wheel_size, :record_interval_numerator,
5
+ :record_interval_denominator, :block_count, :marker_count, :comment,
6
+ :zero_offset, :record_count, :slope
7
+
8
+ def record_interval
9
+ return self.record_interval_numerator/self.record_interval_denominator
10
+ end
11
+
12
+ def date
13
+ Date.new(1880,1,1) + self.srm_date
14
+ end
15
+
16
+ def slope
17
+ @slope/305.58
18
+ end
19
+
20
+ def date_time=(time)
21
+ self.start_date_time = Time.mktime(self.date.year.to_i, self.date.month.to_i, self.date.day.to_i) + time
22
+ end
23
+
24
+ def date_time
25
+ self.start_date_time
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+
@@ -0,0 +1,2 @@
1
+ require 'joule/tcx/parser'
2
+ require 'joule/tcx/properties'
@@ -0,0 +1,177 @@
1
+ require 'nokogiri'
2
+
3
+ module Joule
4
+ module TCX
5
+ class Parser
6
+ include Joule::Calculator::MarkerCalculator
7
+ include Joule::Calculator::PeakPowerCalculator
8
+
9
+ attr_reader :data_points, :markers, :properties, :peak_powers
10
+
11
+ def initialize(string_or_io)
12
+ @string_or_io = string_or_io
13
+ @data_points = Array.new
14
+ @properties = Joule::TCX::Properties.new
15
+ @markers = Array.new
16
+ @peak_powers = Array.new
17
+ @has_native_speed = false
18
+ end
19
+
20
+ def parse(options = {})
21
+ @properties.record_interval = 1
22
+ @total_record_count = 0
23
+ parse_activity("Biking")
24
+ create_workout_marker()
25
+
26
+ if(options[:calculate_marker_values])
27
+ calculate_marker_values()
28
+ end
29
+
30
+ if(options[:calculate_peak_power_values])
31
+ calculate_peak_power_values(:durations => options[:durations], :total_duration => @markers.first.duration_seconds)
32
+ end
33
+
34
+ end
35
+
36
+ private
37
+ def create_workout_marker
38
+ if(@markers.size > 1)
39
+ @markers << Marker.new(:start => 0, :end => @data_points.size - 1)
40
+ end
41
+ end
42
+
43
+ def parse_activity(sport)
44
+ document = Nokogiri::XML::Document.parse(@string_or_io)
45
+ document.xpath("//xmlns:Activity[@Sport='#{sport}']").each do |activity|
46
+ @properties.id = activity.at("./xmlns:Id").content
47
+
48
+ activity.children.each do |child|
49
+ parse_lap(child) if child.name == "Lap"
50
+ end
51
+
52
+ calculate_speed if(!@has_native_speed)
53
+
54
+ end
55
+ end
56
+
57
+ def parse_lap(lap_node)
58
+
59
+ marker = Marker.new
60
+ marker.start_time = DateTime.parse(lap_node.attribute("StartTime").content)
61
+
62
+ if(@markers.size == 0)
63
+ @properties.start_date_time = marker.start_time
64
+ end
65
+
66
+ if @data_points.size > 0
67
+ marker.start = @data_points.last.time + 1
68
+ else
69
+ marker.start= 0
70
+ end
71
+
72
+ lap_node.children.each do |child|
73
+ marker.duration_seconds = child.content.to_i if child.name == "TotalTimeSeconds"
74
+ # puts "Distance in meters: #{child.content}" if child.name == "DistanceMeters"
75
+ # puts "Maximum Speed: #{child.content}" if child.name == "MaximumSpeed"
76
+ # puts "Calories: #{child.content}" if child.name == "Calories"
77
+ # puts "Intensity: #{child.content}" if child.name == "Intensity"
78
+ # puts "Cadence: #{child.content}" if child.name == "Cadence"
79
+ # puts "Trigger Method: #{child.content}" if child.name == "TriggerMethod"
80
+ parse_track(child) if(child.name == "Track")
81
+ end
82
+ marker.end = @data_points.last.time
83
+ @markers << marker
84
+ end
85
+
86
+ def parse_track(track)
87
+ @trackpoint_count = 0
88
+ track.children.each do |trackpoint|
89
+ parse_trackpoint(trackpoint) if(trackpoint.name == "Trackpoint")
90
+
91
+ end
92
+
93
+ end
94
+
95
+ def parse_trackpoint(trackpoint)
96
+ data_point = DataPoint.new
97
+
98
+ trackpoint.children.each do |data|
99
+ parse_times(data, data_point) if(data.name == "Time")
100
+ data_point.altitude = data.content.to_f if data.name == "AltitudeMeters"
101
+ data_point.distance = (data.content.to_f * 1000) if data.name == "DistanceMeters"
102
+ data_point.cadence = data.content.to_i if data.name == "Cadence"
103
+ parse_heartrate(data, data_point) if data.name == "HeartRateBpm"
104
+ parse_extensions(data, data_point) if data.name == "Extensions"
105
+ parse_position(data, data_point) if data.name == "Position"
106
+ end
107
+ @data_points << data_point
108
+ @trackpoint_count = @trackpoint_count + 1
109
+ @total_record_count = @total_record_count + 1
110
+ end
111
+
112
+ def parse_times(data, data_point)
113
+ time_of_day = DateTime.parse(data.content)
114
+ data_point.time_of_day = (time_of_day.hour * 3600) + (time_of_day.min * 60) + time_of_day.sec
115
+ data_point.time = @total_record_count * @properties.record_interval
116
+
117
+ if(@trackpoint_count == 0)
118
+ track_start_time = data_point.time_of_day
119
+ @track_offset_in_seconds = track_start_time - @properties.start_time_in_seconds
120
+ end
121
+ data_point.time_with_pauses = (@trackpoint_count * @properties.record_interval) + @track_offset_in_seconds
122
+ end
123
+
124
+ def parse_heartrate(heartrate, data_point)
125
+ heartrate.children.each do |child|
126
+ data_point.heartrate = child.content.to_i if child.name == "Value"
127
+ end
128
+ end
129
+
130
+ def parse_extensions(extensions, data_point)
131
+ extensions.children.each do |extension|
132
+ extension.children.each do |tpx|
133
+ (data_point.speed = tpx.content.to_f; @has_native_speed = true;) if(tpx.name == "Speed")
134
+ (data_point.power = tpx.content.to_f) if(tpx.name == "Watts")
135
+ end
136
+ end
137
+ end
138
+
139
+ def parse_position(position, data_point)
140
+ position.children.each do |child|
141
+ (data_point.latitude = child.content.to_f) if child.name == "LatitudeDegrees"
142
+ (data_point.longitude = child.content.to_f) if child.name == "LongitudeDegrees"
143
+ end
144
+ end
145
+
146
+ def calculate_marker_values
147
+ @markers.each_with_index { |marker, i|
148
+ calculate_marker_averages marker
149
+ calculate_marker_maximums marker
150
+ calculate_marker_training_metrics marker
151
+
152
+ if i.eql?(0)
153
+ marker.distance = @data_points.last.distance
154
+ else
155
+ marker.distance = @data_points[marker.end + 1].distance - @data_points[marker.start].distance
156
+ end
157
+
158
+ marker.duration_seconds = (marker.end - marker.start + 1) * @properties.record_interval
159
+ marker.energy = (marker.average_power.round * marker.duration_seconds)/1000
160
+
161
+ }
162
+ end
163
+
164
+ def calculate_speed
165
+ @data_points.each_with_index { |v, i|
166
+ if(i == 0)
167
+ delta = v.distance
168
+ else
169
+ delta = v.distance - @data_points[i-1].distance
170
+ end
171
+ v.speed = delta / @properties.record_interval
172
+ }
173
+ end
174
+ end
175
+
176
+ end
177
+ end
@@ -0,0 +1,11 @@
1
+ module Joule
2
+ module TCX
3
+ class Properties
4
+ attr_accessor :id, :record_interval, :start_date_time
5
+
6
+ def start_time_in_seconds
7
+ (@start_date_time.hour * 3600) + (@start_date_time.min * 60) + @start_date_time.sec
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,55 @@
1
+ module Joule
2
+ module UnitsConversion
3
+ def convert_speed(speed)
4
+ #convert to mm/s
5
+ if self.properties.speed_units_are_english?
6
+ miles_per_hour_to_millimeters_per_second speed
7
+ else
8
+ kilometers_per_hour_to_millimeters_per_second speed
9
+ end
10
+ end
11
+
12
+ def convert_distance(distance)
13
+ #convert distance to mm
14
+ if self.properties.distance_units_are_english?
15
+ miles_to_millimeters distance
16
+ else
17
+ kilometers_to_millimeters distance
18
+ end
19
+ end
20
+
21
+ def miles_per_hour_to_millimeters_per_second(speed)
22
+ speed * 447.04
23
+ end
24
+
25
+ def millimeters_per_second_to_miles_per_hour(speed)
26
+ speed / 447.04
27
+ end
28
+
29
+ def kilometers_per_hour_to_millimeters_per_second(speed)
30
+ speed * 277.78
31
+ end
32
+
33
+ def millimeters_per_second_to_kilometers_per_hour(speed)
34
+ speed / 277.78
35
+ end
36
+
37
+ def miles_to_millimeters(distance)
38
+ distance * 1609344
39
+ end
40
+
41
+ def millimeters_to_miles(distance)
42
+ distance / 1609344
43
+ end
44
+
45
+ def kilometers_to_millimeters(distance)
46
+ distance * 1000000
47
+ end
48
+
49
+ def millimeters_to_kilometers(distance)
50
+ distance / 1000000
51
+ end
52
+
53
+ end
54
+ end
55
+
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: joule
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Olson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-01-05 00:00:00 -05:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: nokogiri
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 1.4.1
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: fastercsv
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 1.4.0
34
+ version:
35
+ description: ""
36
+ email: anolson@gmail.com
37
+ executables: []
38
+
39
+ extensions: []
40
+
41
+ extra_rdoc_files:
42
+ - README.rdoc
43
+ files:
44
+ - lib/joule
45
+ - lib/joule/array.rb
46
+ - lib/joule/calculator
47
+ - lib/joule/calculator/marker_calculator.rb
48
+ - lib/joule/calculator/peak_power_calculator.rb
49
+ - lib/joule/calculator/power_calculator.rb
50
+ - lib/joule/calculator.rb
51
+ - lib/joule/csv
52
+ - lib/joule/csv/parser.rb
53
+ - lib/joule/csv.rb
54
+ - lib/joule/data_point.rb
55
+ - lib/joule/float.rb
56
+ - lib/joule/ibike
57
+ - lib/joule/ibike/parser.rb
58
+ - lib/joule/ibike/properties.rb
59
+ - lib/joule/ibike.rb
60
+ - lib/joule/marker.rb
61
+ - lib/joule/powertap
62
+ - lib/joule/powertap/parser.rb
63
+ - lib/joule/powertap/properties.rb
64
+ - lib/joule/powertap.rb
65
+ - lib/joule/srm
66
+ - lib/joule/srm/parser.rb
67
+ - lib/joule/srm/properties.rb
68
+ - lib/joule/srm.rb
69
+ - lib/joule/tcx
70
+ - lib/joule/tcx/parser.rb
71
+ - lib/joule/tcx/properties.rb
72
+ - lib/joule/tcx.rb
73
+ - lib/joule/units_conversion.rb
74
+ - lib/joule.rb
75
+ - README.rdoc
76
+ - Rakefile
77
+ has_rdoc: true
78
+ homepage: http://github.com/anolson/joule
79
+ post_install_message:
80
+ rdoc_options:
81
+ - --line-numbers
82
+ - --inline-source
83
+ - --title
84
+ - Joule
85
+ - --main
86
+ - README.rdoc
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: "0"
94
+ version:
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: "1.2"
100
+ version:
101
+ requirements: []
102
+
103
+ rubyforge_project:
104
+ rubygems_version: 1.3.1
105
+ signing_key:
106
+ specification_version: 2
107
+ summary: A Ruby library for parsing bicycle powermeter data.
108
+ test_files: []
109
+