From de95cd6f65baace2b8f92689b7221cf9bb2f9ecc Mon Sep 17 00:00:00 2001 From: Chris Schlaeger Date: Sat, 4 Apr 2015 20:40:56 +0200 Subject: New: More flexible personal records --- lib/postrunner/ActivitiesDB.rb | 11 +- lib/postrunner/Activity.rb | 129 ++++++++++++-- lib/postrunner/PersonalRecords.rb | 351 +++++++++++++++++++++++++++++--------- 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 -- cgit v1.2.3