summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Schlaeger <chris@linux.com>2015-04-04 20:40:56 +0200
committerChris Schlaeger <chris@linux.com>2015-04-04 20:40:56 +0200
commitde95cd6f65baace2b8f92689b7221cf9bb2f9ecc (patch)
tree79dbf1ac42cd68372f8940e4ad8739c5659dbc66
parent09481a2a108da1a43d78dce529f90f0d522b900c (diff)
downloadpostrunner-de95cd6f65baace2b8f92689b7221cf9bb2f9ecc.zip
New: More flexible personal records
-rw-r--r--lib/postrunner/ActivitiesDB.rb11
-rw-r--r--lib/postrunner/Activity.rb129
-rw-r--r--lib/postrunner/PersonalRecords.rb351
3 files changed, 392 insertions, 99 deletions
diff --git a/lib/postrunner/ActivitiesDB.rb b/lib/postrunner/ActivitiesDB.rb
index ecf778d..3ba080c 100644
--- a/lib/postrunner/ActivitiesDB.rb
+++ b/lib/postrunner/ActivitiesDB.rb
@@ -23,7 +23,7 @@ module PostRunner
class ActivitiesDB
- attr_reader :db_dir, :cfg, :fit_dir, :activities
+ attr_reader :db_dir, :cfg, :fit_dir, :activities, :records
def initialize(db_dir, cfg)
@db_dir = db_dir
@@ -115,7 +115,7 @@ module PostRunner
a2.timestamp <=> a1.timestamp
end
- activity.register_records(@records)
+ activity.register_records
# Generate HTML file for this activity.
activity.generate_html_view
@@ -141,6 +141,7 @@ module PostRunner
succ = successor(activity)
@activities.delete(activity)
+ @records.delete_activity(activity)
# The HTML activity views contain links to their predecessors and
# successors. After deleting an activity, we need to re-generate these
@@ -162,7 +163,11 @@ module PostRunner
end
def check
- @activities.each { |a| a.check }
+ @records.delete_all_records
+ @activities.sort! do |a1, a2|
+ a1.timestamp <=> a2.timestamp
+ end.each { |a| a.check }
+ @records.sync
# Ensure that HTML index is up-to-date.
ActivityListView.new(self).update_html_index
end
diff --git a/lib/postrunner/Activity.rb b/lib/postrunner/Activity.rb
index ea809f5..5c73acb 100644
--- a/lib/postrunner/Activity.rb
+++ b/lib/postrunner/Activity.rb
@@ -28,7 +28,7 @@ module PostRunner
# We also store some additional information in the archive index.
@@CachedAttributes = @@CachedActivityValues + %w( fit_file name )
- @@ActivityTypes = {
+ ActivityTypes = {
'generic' => 'Generic',
'running' => 'Running',
'cycling' => 'Cycling',
@@ -50,7 +50,7 @@ module PostRunner
'paddling' => 'Paddling',
'all' => 'All'
}
- @@ActivitySubTypes = {
+ ActivitySubTypes = {
'generic' => 'Generic',
'treadmill' => 'Treadmill',
'street' => 'Street',
@@ -114,6 +114,7 @@ module PostRunner
def check
generate_html_view
+ register_records
Log.info "FIT file #{@fit_file} is OK"
end
@@ -182,17 +183,22 @@ module PostRunner
when 'name'
@name = value
when 'type'
- unless @@ActivityTypes.values.include?(value)
+ @fit_activity = load_fit_file unless @fit_activity
+ unless ActivityTypes.values.include?(value)
Log.fatal "Unknown activity type '#{value}'. Must be one of " +
- @@ActivityTypes.values.join(', ')
+ ActivityTypes.values.join(', ')
end
- @sport = @@ActivityTypes.invert[value]
+ @sport = ActivityTypes.invert[value]
+ # Since the activity changes the records from this Activity need to be
+ # removed and added again.
+ @db.records.delete_activity(self)
+ register_records
when 'subtype'
- unless @@ActivitySubTypes.values.include?(value)
+ unless ActivitySubTypes.values.include?(value)
Log.fatal "Unknown activity subtype '#{value}'. Must be one of " +
- @@ActivitySubTypes.values.join(', ')
+ ActivitySubTypes.values.join(', ')
end
- @sub_sport = @@ActivitySubTypes.invert[value]
+ @sub_sport = ActivitySubTypes.invert[value]
else
Log.fatal "Unknown activity attribute '#{attribute}'. Must be one of " +
'name, type or subtype'
@@ -200,15 +206,102 @@ module PostRunner
generate_html_view
end
- def register_records(db)
- @fit_activity.personal_records.each do |r|
- if r.longest_distance == 1
- # In case longest_distance is 1 the distance is stored in the
- # duration field in 10-th of meters.
- db.register_result(r.duration * 10.0 , 0, r.start_time, @fit_file)
- else
- db.register_result(r.distance, r.duration, r.start_time, @fit_file)
+ def register_records
+ distance_record = 0.0
+ distance_record_sport = nil
+ # Array with popular distances (in meters) in ascending order.
+ record_distances = nil
+ # Speed records for popular distances (seconds hashed by distance in
+ # meters)
+ speed_records = {}
+
+ segment_start_time = @fit_activity.sessions[0].start_time
+ segment_start_distance = 0.0
+
+ sport = nil
+ last_timestamp = nil
+ last_distance = nil
+
+ @fit_activity.records.each do |record|
+ if record.distance.nil?
+ # All records must have a valid distance mark or the activity does
+ # not qualify for a personal record.
+ Log.warn "Found a record without a valid distance"
+ return
+ end
+ if record.timestamp.nil?
+ Log.warn "Found a record without a valid timestamp"
+ return
+ end
+
+ unless sport
+ # If the Activity has sport set to 'multisport' or 'all' we pick up
+ # the sport from the FIT records. Otherwise, we just use whatever
+ # sport the Activity provides.
+ if @sport == 'multisport' || @sport == 'all'
+ sport = record.activity_type
+ else
+ sport = @sport
+ end
+ return unless PersonalRecords::SpeedRecordDistances.include?(sport)
+
+ record_distances = PersonalRecords::SpeedRecordDistances[sport].
+ keys.sort
+ end
+
+ segment_start_distance = record.distance unless segment_start_distance
+ segment_start_time = record.timestamp unless segment_start_time
+
+ # Total distance covered in this segment so far
+ segment_distance = record.distance - segment_start_distance
+ # Check if we have reached the next popular distance.
+ if record_distances.first &&
+ segment_distance >= record_distances.first
+ segment_duration = record.timestamp - segment_start_time
+ # The distance may be somewhat larger than a popular distance. We
+ # normalize the time to the norm distance.
+ norm_duration = segment_duration / segment_distance *
+ record_distances.first
+ # Save the time for this distance.
+ speed_records[record_distances.first] = {
+ :time => norm_duration, :sport => sport
+ }
+ # Switch to the next popular distance.
+ record_distances.shift
+ end
+
+ # We've reached the end of a segment if the sport type changes, we
+ # detect a pause of more than 30 seconds or when we've reached the
+ # last record.
+ if (record.activity_type && sport && record.activity_type != sport) ||
+ (last_timestamp && (record.timestamp - last_timestamp) > 30) ||
+ record.equal?(@fit_activity.records.last)
+
+ # Check for a total distance record
+ if segment_distance > distance_record
+ distance_record = segment_distance
+ distance_record_sport = sport
+ end
+
+ # Prepare for the next segment in this Activity
+ segment_start_distance = nil
+ segment_start_time = nil
+ sport = nil
end
+
+ last_timestamp = record.timestamp
+ last_distance = record.distance
+ end
+
+ # Store the found records
+ start_time = @fit_activity.sessions[0].timestamp
+ if @distance_record_sport
+ @db.records.register_result(self, distance_record_sport,
+ distance_record, nil, start_time)
+ end
+ speed_records.each do |dist, info|
+ @db.records.register_result(self, info[:sport], dist, info[:time],
+ start_time)
end
end
@@ -219,11 +312,11 @@ module PostRunner
end
def activity_type
- @@ActivityTypes[@sport] || 'Undefined'
+ ActivityTypes[@sport] || 'Undefined'
end
def activity_sub_type
- @@ActivitySubTypes[@sub_sport] || 'Undefined'
+ ActivitySubTypes[@sub_sport] || 'Undefined'
end
private
diff --git a/lib/postrunner/PersonalRecords.rb b/lib/postrunner/PersonalRecords.rb
index fc8c2cd..2b1cf30 100644
--- a/lib/postrunner/PersonalRecords.rb
+++ b/lib/postrunner/PersonalRecords.rb
@@ -20,78 +20,268 @@ module PostRunner
class PersonalRecords
+ include Fit4Ruby::Converters
+
+ SpeedRecordDistances = {
+ 'cycling' => {
+ 5000.0 => '5 km',
+ 8000.0 => '8 km',
+ 9000.0 => '9 km',
+ 10000.0 => '10 km',
+ 20000.0 => '20 km',
+ 40000.0 => '40 km',
+ 80000.0 => '80 km',
+ 90000.0 => '90 km',
+ 12000.0 => '120 km',
+ 18000.0 => '180 km',
+ },
+ 'running' => {
+ 1000.0 => '1 km',
+ 1609.0 => '1 mi',
+ 2000.0 => '2 km',
+ 3000.0 => '3 km',
+ 5000.0 => '5 km',
+ 10000.0 => '10 km',
+ 20000.0 => '20 km',
+ 30000.0 => '30 km',
+ 21097.5 => 'Half Marathon',
+ 42195.0 => 'Marathon'
+ },
+ 'swimming' => {
+ 100.0 => '100 m',
+ 300.0 => '300 m',
+ 400.0 => '400 m',
+ 750.0 => '750 m',
+ 1500.0 => '1.5 km',
+ 1930.0 => '1.2 mi',
+ 3000.0 => '3 km',
+ 4000.0 => '4 km',
+ 3860.0 => '2.4 mi'
+ },
+ 'walking' => {
+ 1000.0 => '1 km',
+ 1609.0 => '1 mi',
+ 5000.0 => '5 km',
+ 10000.0 => '10 km',
+ 21097.5 => 'Half Marathon',
+ 42195.0 => 'Marathon'
+ }
+ }
+
class Record
- attr_accessor :distance, :duration, :start_time, :fit_file
+ include Fit4Ruby::Converters
- def initialize(distance, duration, start_time, fit_file)
+ attr_accessor :activity, :sport, :distance, :duration, :start_time
+
+ def initialize(activity, sport, distance, duration, start_time)
+ @activity = activity
+ @sport = sport
@distance = distance
@duration = duration
@start_time = start_time
- @fit_file = fit_file
+ end
+
+ def to_table_row(t)
+ t.row((@duration.nil? ?
+ [ 'Longest Run', '%.1f m' % @distance, '-' ] :
+ [ PersonalRecords::SpeedRecordDistances[@sport][@distance],
+ secsToHMS(@duration),
+ speedToPace(@distance / @duration) ]) +
+ [ @activity.db.ref_by_fit_file(@activity.fit_file),
+ @activity.name, @start_time.strftime("%Y-%m-%d") ])
end
end
- include Fit4Ruby::Converters
+ class RecordSet
- def initialize(activities)
- @activities = activities
- @db_dir = activities.db_dir
- @records_file = File.join(@db_dir, 'records.yml')
- @records = []
+ include Fit4Ruby::Converters
- load_records
- end
+ attr_reader :year
+
+ def initialize(sport, year)
+ @sport = sport
+ @year = year
+ @distance = nil
+ @speed_records = {}
+ PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
+ @speed_records[dist] = nil
+ end
+ end
- def register_result(distance, duration, start_time, fit_file)
- @records.each do |record|
- if record.duration > 0
- if duration > 0
- # This is a speed record for a popular distance.
- if distance == record.distance
- if duration < record.duration
- record.duration = duration
- record.start_time = start_time
- record.fit_file = fit_file
- Log.info "New record for #{distance} m in " +
- "#{secsToHMS(duration)}"
- return true
- else
- # No new record for this distance.
- return false
- end
- end
+ def register_result(result)
+ if result.duration
+ # We have a potential speed record for a known distance.
+ unless PersonalRecords::SpeedRecordDistances[@sport].
+ include?(result.distance)
+ Log.fatal "Unknown record distance #{result.distance}"
+ end
+
+ old_record = @speed_records[result.distance]
+ if old_record.nil? || old_record.duration > result.duration
+ @speed_records[result.distance] = result
+ Log.info "New #{@year ? @year.to_s : 'all-time'} " +
+ "#{result.sport} speed record for " +
+ "#{PersonalRecords::SpeedRecordDistances[@sport][
+ result.distance]}: " +
+ "#{secsToHMS(result.duration)}"
+ return true
end
else
- if distance > record.distance
- # This is a new distance record.
- record.distance = distance
- record.duration = 0
- record.start_time = start_time
- record.fit_file = fit_file
- Log.info "New distance record #{distance} m"
+ # We have a potential distance record.
+ if @distance.nil? || result.distance > @distance.distance
+ @distance = result
+ Log.info "New #{@year ? @year.to_s : 'all-time'} " +
+ "#{result.sport} distance record: #{result.distance} m"
return true
- else
- # No new distance record.
- return false
end
end
+
+ false
end
- # We have not found a record.
- @records << Record.new(distance, duration, start_time, fit_file)
- if duration == 0
- Log.info "New distance record #{distance} m"
- else
- Log.info "New record for #{distance}m in #{secsToHMS(duration)}"
+ def delete_activity(activity)
+ if @distance && @distance.activity == activity
+ @distance = nil
+ end
+ PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
+ if @speed_records[dist] && @speed_records[dist].activity == activity
+ @speed_records[dist] = nil
+ end
+ end
+ end
+
+ # Return true if no Record is stored in this RecordSet object.
+ def empty?
+ return false if @distance
+ @speed_records.each_value { |r| return false if r }
+
+ true
+ end
+
+ # Iterator for all Record objects that are stored in this data structure.
+ def each(&block)
+ yield(@distance) if @distance
+ @speed_records.each_value do |record|
+ yield(record) if record
+ end
+ end
+
+ def to_s
+ return '' if empty?
+
+ t = FlexiTable.new
+ t.head
+ t.row([ 'Record', 'Time/Dist.', 'Avg. Pace', 'Ref.', 'Activity',
+ 'Date' ],
+ { :halign => :center })
+ t.set_column_attributes([
+ {},
+ { :halign => :right },
+ { :halign => :right },
+ { :halign => :right },
+ { :halign => :left },
+ { :halign => :left }
+ ])
+ t.body
+
+ records = @speed_records.values.delete_if { |r| r.nil? }
+ records << @distance if @distance
+
+ records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
+ r.to_table_row(t)
+ end
+ t.to_s + "\n"
end
- true
end
- def delete_activity(fit_file)
- @records.delete_if { |r| r.fit_file == fit_file }
+ class SportRecords
+
+ def initialize(sport)
+ @sport = sport
+ @all_time = RecordSet.new(@sport, nil)
+ @yearly = {}
+ end
+
+ def register_result(result)
+ year = result.start_time.year
+ unless @yearly[year]
+ @yearly[year] = RecordSet.new(@sport, year)
+ end
+
+ new_at = @all_time.register_result(result)
+ new_yr = @yearly[year].register_result(result)
+
+ new_at || new_yr
+ end
+
+ def delete_activity(activity)
+ ([ @all_time ] + @yearly.values).each do |r|
+ r.delete_activity(activity)
+ end
+ end
+
+ # Return true if no record is stored in this SportRecords object.
+ def empty?
+ return false unless @all_time.empty?
+ @yearly.each_value { |r| return false unless r.empty? }
+
+ true
+ end
+
+ # Iterator for all Record objects that are stored in this data structure.
+ def each(&block)
+ records = @yearly.values
+ records << @all_time if @all_time
+ records.each { |r| r.each(&block) }
+ end
+
+ def to_s
+ return '' if empty?
+
+ str = "All-time records:\n\n#{@all_time.to_s}" unless @all_time.empty?
+ @yearly.values.sort{ |r1, r2| r2.year <=> r1.year }.each do |record|
+ unless record.empty?
+ str += "Records of #{record.year}:\n\n#{record.to_s}"
+ end
+ end
+
+ str
+ end
+
+ end
+
+ def initialize(activities)
+ @activities = activities
+ @db_dir = activities.db_dir
+ @records_file = File.join(@db_dir, 'records.yml')
+ delete_all_records
+
+ load_records
+ end
+
+ def register_result(activity, sport, distance, duration, start_time)
+ unless @sport_records.include?(sport)
+ Log.info "Ignoring records for activity type '#{sport}' in " +
+ "#{activity.fit_file}"
+ return false
+ end
+
+ result = Record.new(activity, sport, distance, duration, start_time)
+ @sport_records[sport].register_result(result)
+ end
+
+ def delete_all_records
+ @sport_records = {}
+ SpeedRecordDistances.keys.each do |sport|
+ @sport_records[sport] = SportRecords.new(sport)
+ end
+ end
+
+ def delete_activity(activity)
+ @sport_records.each_value { |r| r.delete_activity(activity) }
end
def sync
@@ -99,31 +289,18 @@ module PostRunner
end
def to_s
- record_names = { 1000.0 => '1 km', 1609.0 => '1 mi', 5000.0 => '5 km',
- 21097.5 => '1/2 Marathon', 42195.0 => 'Marathon' }
- t = FlexiTable.new
- t.head
- t.row([ 'Record', 'Time/Dist.', 'Avg. Pace', 'Ref.', 'Activity', 'Date' ],
- { :halign => :center })
- t.set_column_attributes([
- {},
- { :halign => :right },
- { :halign => :right },
- { :halign => :right },
- { :halign => :left },
- { :halign => :left }
- ])
- t.body
- @records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
- activity = @activities.activity_by_fit_file(r.fit_file)
- t.row((r.duration == 0 ?
- [ 'Longest Run', '%.1f m' % r.distance, '-' ] :
- [ record_names[r.distance], secsToHMS(r.duration),
- speedToPace(r.distance / r.duration) ]) +
- [ @activities.ref_by_fit_file(r.fit_file),
- activity.name, r.start_time.strftime("%Y-%m-%d") ])
- end
- t.to_s
+ str = ''
+ @sport_records.each do |sport, record|
+ next if record.empty?
+ str += "Records for activity type #{sport}:\n\n#{record.to_s}"
+ end
+
+ str
+ end
+
+ # Iterator for all Record objects that are stored in this data structure.
+ def each(&block)
+ @sport_records.each_value { |r| r.each(&block) }
end
private
@@ -131,23 +308,41 @@ module PostRunner
def load_records
begin
if File.exists?(@records_file)
- @records = YAML.load_file(@records_file)
+ @sport_records = YAML.load_file(@records_file)
else
Log.info "No records file found at '#{@records_file}'"
end
- rescue StandardError
+ rescue IOError
Log.fatal "Cannot load records file '#{@records_file}': #{$!}"
end
- unless @records.is_a?(Array)
+ unless @sport_records.is_a?(Hash)
Log.fatal "The personal records file '#{@records_file}' is corrupted"
end
+
+ # Convert FIT file names into Activity references.
+ each do |record|
+ # Record objects can be referenced multiple times.
+ if record.activity.is_a?(String)
+ record.activity = @activities.activity_by_fit_file(record.activity)
+ end
+ end
end
def save_records
+ # Convert Activity references into FIT file names.
+ each do |record|
+ # Record objects can be referenced multiple times.
+ unless record.activity.is_a?(String)
+ record.activity = record.activity.fit_file
+ end
+ end
+
begin
- BackedUpFile.open(@records_file, 'w') { |f| f.write(@records.to_yaml) }
- rescue StandardError
+ BackedUpFile.open(@records_file, 'w') do |f|
+ f.write(@sport_records.to_yaml)
+ end
+ rescue IOError
Log.fatal "Cannot write records file '#{@records_file}': #{$!}"
end
end