#!/usr/bin/env ruby -w # encoding: UTF-8 # # = Activity.rb -- PostRunner - Manage the data from your Garmin sport devices. # # Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org> # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as # published by the Free Software Foundation. # require 'fit4ruby' require 'postrunner/ActivitySummary' require 'postrunner/DataSources' require 'postrunner/ActivityView' require 'postrunner/Schema' require 'postrunner/QueryResult' module PostRunner class Activity attr_reader :db, :fit_file, :name, :fit_activity # This is a list of variables that provide data from the fit file. To # speed up access to it, we cache the data in the activity database. @@CachedActivityValues = %w( sport sub_sport timestamp total_distance total_timer_time avg_speed ) # We also store some additional information in the archive index. @@CachedAttributes = @@CachedActivityValues + %w( fit_file name norecord ) @@Schemata = { 'long_date' => Schema.new('long_date', 'Date', { :func => 'timestamp', :column_alignment => :left, :format => 'date_with_weekday' }), 'sub_type' => Schema.new('sub_type', 'Subtype', { :func => 'activity_sub_type', :column_alignment => :left }), 'type' => Schema.new('type', 'Type', { :func => 'activity_type', :column_alignment => :left }) } ActivityTypes = { 'generic' => 'Generic', 'running' => 'Running', 'cycling' => 'Cycling', 'transition' => 'Transition', 'fitness_equipment' => 'Fitness Equipment', 'swimming' => 'Swimming', 'basketball' => 'Basketball', 'soccer' => 'Soccer', 'tennis' => 'Tennis', 'american_football' => 'American Football', 'walking' => 'Walking', 'cross_country_skiing' => 'Cross Country Skiing', 'alpine_skiing' => 'Alpine Skiing', 'snowboarding' => 'Snowboarding', 'rowing' => 'Rowing', 'mountaineering' => 'Mountaneering', 'hiking' => 'Hiking', 'multisport' => 'Multisport', 'paddling' => 'Paddling', 'all' => 'All' } ActivitySubTypes = { 'generic' => 'Generic', 'treadmill' => 'Treadmill', 'street' => 'Street', 'trail' => 'Trail', 'track' => 'Track', 'spin' => 'Spin', 'indoor_cycling' => 'Indoor Cycling', 'road' => 'Road', 'mountain' => 'Mountain', 'downhill' => 'Downhill', 'recumbent' => 'Recumbent', 'cyclocross' => 'Cyclocross', 'hand_cycling' => 'Hand Cycling', 'track_cycling' => 'Track Cycling', 'indoor_rowing' => 'Indoor Rowing', 'elliptical' => 'Elliptical', 'stair_climbing' => 'Stair Climbing', 'lap_swimming' => 'Lap Swimming', 'open_water' => 'Open Water', 'flexibility_training' => 'Flexibility Training', 'strength_training' => 'Strength Training', 'warm_up' => 'Warm up', 'match' => 'Match', 'exercise' => 'Excersize', 'challenge' => 'Challenge', 'indoor_skiing' => 'Indoor Skiing', 'cardio_training' => 'Cardio Training', 'all' => 'All' } def initialize(db, fit_file, fit_activity, name = nil) @fit_file = fit_file @fit_activity = fit_activity @name = name || fit_file @unset_variables = [] late_init(db) @@CachedActivityValues.each do |v| v_str = "@#{v}" instance_variable_set(v_str, fit_activity.send(v)) self.class.send(:attr_reader, v.to_sym) end end # YAML::load() does not call initialize(). We don't have all attributes # stored in the YAML file, so we need to make sure these are properly set # after a YAML::load(). def late_init(db) @db = db @html_file = File.join(@db.cfg[:html_dir], "#{@fit_file[0..-5]}.html") @unset_variables.each do |name_without_at| # The YAML file does not yet have the instance variable cached. # Load the Activity data and extract the value to set the instance # variable. @fit_activity = load_fit_file unless @fit_activity instance_variable_set('@' + name_without_at, @fit_activity.send(name_without_at)) end end def check generate_html_view register_records Log.info "FIT file #{@fit_file} is OK" end def dump(filter) @fit_activity = load_fit_file(filter) end # This method is called during YAML::load() to initialize the class # objects. The initialize() is NOT called during YAML::load(). Any # additional initialization work is done in late_init(). def init_with(coder) @unset_variables = [] @@CachedAttributes.each do |name_without_at| # Create attr_readers for cached variables. self.class.send(:attr_reader, name_without_at.to_sym) if coder.map.include?(name_without_at) # The YAML file has a value for the instance variable. So just set # it. instance_variable_set('@' + name_without_at, coder[name_without_at]) else if @@CachedActivityValues.include?(name_without_at) @unset_variables << name_without_at elsif name_without_at == 'norecord' @norecord = false else Log.fatal "Don't know how to initialize the instance variable " + "#{name_without_at}." end end end end # This method is called during Activity::to_yaml() calls. It's being used # to prevent some instance variables from being saved in the YAML file. # Only attributes that are listed in @@CachedAttributes are being saved. def encode_with(coder) instance_variables.each do |a| name_with_at = a.to_s name_without_at = name_with_at[1..-1] next unless @@CachedAttributes.include?(name_without_at) coder[name_without_at] = instance_variable_get(name_with_at) end end def query(key) unless @@Schemata.include?(key) raise ArgumentError, "Unknown key '#{key}' requested in query" end schema = @@Schemata[key] if schema.func value = send(schema.func) else unless instance_variable_defined?(key) raise ArgumentError, "Don't know how to query '#{key}'" end value = instance_variable_get(key) end QueryResult.new(value, schema) end def show generate_html_view #unless File.exists?(@html_file) @db.show_in_browser(@html_file) end def sources @fit_activity = load_fit_file unless @fit_activity puts DataSources.new(self, @db.cfg[:unit_system]).to_s end def summary @fit_activity = load_fit_file unless @fit_activity puts ActivitySummary.new(self, @db.cfg[:unit_system], { :name => @name, :type => activity_type, :sub_type => activity_sub_type }).to_s end def rename(name) @name = name generate_html_view end def set(attribute, value) case attribute when 'name' @name = value when 'type' @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(', ') end @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) Log.fatal "Unknown activity subtype '#{value}'. Must be one of " + ActivitySubTypes.values.join(', ') end @sub_sport = ActivitySubTypes.invert[value] when 'norecord' unless %w( true false).include?(value) Log.fatal "norecord must either be 'true' or 'false'" end @norecord = value == 'true' else Log.fatal "Unknown activity attribute '#{attribute}'. Must be one of " + 'name, type or subtype' end generate_html_view end def register_records # If we have the @norecord flag set, we ignore this Activity for the # record collection. return if @norecord 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 # Return true if this activity generated any personal records. def has_records? !@db.records.activity_records(self).empty? end def generate_html_view @fit_activity = load_fit_file unless @fit_activity ActivityView.new(self, @db.cfg[:unit_system]) end def activity_type ActivityTypes[@sport] || 'Undefined' end def activity_sub_type ActivitySubTypes[@sub_sport] || 'Undefined' end private def load_fit_file(filter = nil) fit_file = File.join(@db.fit_dir, @fit_file) begin fit_activity = Fit4Ruby.read(fit_file, filter) rescue Fit4Ruby::Error Log.fatal "#{@fit_file} corrupted: #{$!}" end unless fit_activity Log.fatal "#{fit_file} does not contain any activity records" end fit_activity end end end