class PostRunner::ActivitySummary
Public Class Methods
new(activity, unit_system, custom_fields)
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 31 def initialize(activity, unit_system, custom_fields) @activity = activity @fit_activity = activity.fit_activity @name = custom_fields[:name] @type = custom_fields[:type] @sub_type = custom_fields[:sub_type] @unit_system = unit_system end
Public Instance Methods
to_html(doc)
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 49 def to_html(doc) width = 600 ViewFrame.new('activity', "Activity: #{@name}", width, summary).to_html(doc) ViewFrame.new('note', 'Note', width, note, true).to_html(doc) if @activity.note ViewFrame.new('laps', 'Laps', width, laps, true).to_html(doc) if has_hr_zones? ViewFrame.new('hr_zones', 'Heart Rate Zones', width, hr_zones, true). to_html(doc) end end
to_s()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 40 def to_s s = summary.to_s + "\n" + (@activity.note ? note.to_s + "\n" : '') + laps.to_s s += hr_zones.to_s if has_hr_zones? s end
Private Instance Methods
each_hr_zone_with_index() { |secs_in_zone, i| ... }
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 341 def each_hr_zone_with_index return unless (zones = @fit_activity.sessions[0].time_in_hr_zone) zones.each_with_index do |secs_in_zone, i| # There seems to be a zone 0 in the FIT files that isn't displayed on # the watch or Garmin Connect. Just ignore it. next if i == 0 # There are more zones in the FIT file, but they are not displayed on # the watch or on the GC. break if i >= 6 yield(secs_in_zone, i) end end
gather_hr_zones()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 286 def gather_hr_zones zones = [] if @fit_activity.heart_rate_zones.empty? # The FIT file has no heart_rate_zone records. It might have a # time_in_hr_zone record for the session. counted_zones = 0 total_time_in_zone = 0 each_hr_zone_with_index do |secs_in_zone, i| if secs_in_zone counted_zones += 1 total_time_in_zone += secs_in_zone end end if counted_zones == 5 && total_time_in_zone > 0.0 session = @fit_activity.sessions[0] hr_mins = HRZoneDetector::detect_zones( @fit_activity.records, session.time_in_hr_zone[0..5]) 0.upto(4) do |i| low = hr_mins[i + 1] high = i == HRZoneDetector::GARMIN_ZONES - 1 ? session.max_heart_rate || '-' : hr_mins[i + 2].nil? || hr_mins[i + 2] == 0 ? '-' : (hr_mins[i + 2] - 1) tiz = @fit_activity.sessions[0].time_in_hr_zone[i + 1] piz = tiz / total_time_in_zone * 100.0 zones << HRZone.new(i, low, high, tiz, piz) end end else @fit_activity.heart_rate_zones.each do |zone| if zone.type == 18 total_time = 0.0 if zone.time_in_hr_zone zone.time_in_hr_zone.each { |tiz| total_time += tiz if tiz } end break if total_time <= 0.0 if zone.heart_rate_zones zone.heart_rate_zones.each_with_index do |hr, i| break if i > 4 zones << HRZone.new(i, hr, zone.heart_rate_zones[i + 1], zone.time_in_hr_zone[i + 1], zone.time_in_hr_zone[i + 1] / total_time * 100.0) end end break end end end zones end
has_hr_zones?()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 258 def has_hr_zones? # Depending on the age of the device we may have heart rate zone data # with zone boundaries, without zone boundaries or no data at all. if @fit_activity.heart_rate_zones.empty? # The FIT file has no heart_rate_zone records. It might have a # time_in_hr_zone record for the session. counted_zones = 0 total_time_in_zone = 0 each_hr_zone_with_index do |secs_in_zone, i| if secs_in_zone counted_zones += 1 total_time_in_zone += secs_in_zone end end return counted_zones == 5 && total_time_in_zone > 0.0 else # The FIT file has explicit heart_rate_zones records. We need the # session record that has type 19. @fit_activity.heart_rate_zones.each do |hrz| if hrz.type == 18 && hrz.heart_rate_zones && !hrz.heart_rate_zones.empty? return true end end end end
hr_zones()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 222 def hr_zones session = @fit_activity.sessions[0] t = FlexiTable.new t.head t.row([ 'Zone', 'Exertion', 'Min. HR [bpm]', 'Max. HR [bpm]', 'Time in Zone', '% of Time in Zone' ]) t.set_column_attributes([ { :halign => :right }, { :halign => :left}, { :halign => :right }, { :halign => :right }, { :halign => :right }, { :halign => :right }, ]) t.body # Calculate the total time in all the 5 relevant zones. We'll need this # later as the basis for the percentage values. total_secs = 0 zones = gather_hr_zones zones.each do |zone| t.cell(zone.index + 1) t.cell([ 'Warm Up', 'Easy', 'Aerobic', 'Threshold', 'Maximum' ][zone.index]) t.cell(zone.low) t.cell(zone.high) t.cell(secsToHMS(zone.time_in_zone)) t.cell('%.0f%%' % zone.percent_in_zone) t.new_row end t end
laps()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 188 def laps session = @fit_activity.sessions[0] t = FlexiTable.new t.head t.row([ 'Lap', 'Duration', 'Distance', @activity.sport == 'running' ? 'Avg. Pace' : 'Avg. Speed', 'Stride', 'Cadence', 'Avg. HR', 'Max. HR' ]) t.set_column_attributes(Array.new(8, { :halign => :right })) t.body session.laps.each.with_index do |lap, index| t.cell(index + 1) t.cell(secsToHMS(lap.total_timer_time)) t.cell(local_value(lap, 'total_distance', '%.2f', { :metric => 'km', :statute => 'mi' })) if @activity.sport == 'running' t.cell(pace(lap, 'avg_speed', false)) else t.cell(local_value(lap, 'avg_speed', '%.1f', { :metric => 'km/h', :statute => 'mph' })) end t.cell(local_value(lap, 'avg_stride_length', '%.2f', { :metric => 'm', :statute => 'ft' })) t.cell(lap.avg_running_cadence && lap.avg_fractional_cadence ? '%.1f' % (2 * lap.avg_running_cadence + (2 * lap.avg_fractional_cadence) / 100.0) : '') t.cell(lap.avg_heart_rate.to_s) t.cell(lap.max_heart_rate.to_s) t.new_row end t end
local_value(fdr, field, format, units)
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 356 def local_value(fdr, field, format, units) unit = units[@unit_system] value = fdr.get_as(field, unit) if value.nil? && field == 'avg_speed' # New fit files used 'enhanced_avg_speed' instead of the older # 'avg_speed'. value = fdr.get_as('enhanced_avg_speed', unit) end return '-' unless value "#{format % [value, unit]}" end
note()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 64 def note t = FlexiTable.new t.enable_frame(false) t.body t.row([ @activity.note ]) t end
pace(fdr, field, show_unit = true)
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 368 def pace(fdr, field, show_unit = true) speed = fdr.get(field) if speed.nil? && field == 'avg_speed' # New fit files used 'enhanced_avg_speed' instead of the older # 'avg_speed'. speed = fdr.get('enhanced_avg_speed') end case @unit_system when :metric "#{speedToPace(speed)}#{show_unit ? ' min/km' : ''}" when :statute "#{speedToPace(speed, 1609.34)}#{show_unit ? ' min/mi' : ''}" else Log.fatal "Unknown unit system #{@unit_system}" end end
peak_epoc()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 445 def peak_epoc # Peak EPOC value according to figure 2 in the following white paper by # FristBeat: # https://www.firstbeat.com/wp-content/uploads/2015/10/white_paper_training_effect.pdf unless @fit_activity.physiological_metrics && (pm = @fit_activity.physiological_metrics.last) && (te = pm.aerobic_training_effect) return 0.0 end unless (user_data = @fit_activity.user_data.first) && (ac = user_data.activity_class) return 0.0 end # The following formula was taken from # http://www.movescount.com/apps/app10020404-EPOC_from_TE # It apparently approximates the graph in figure 2 in the FirstBeat # paper. epoc = -11.0 + te * (20.0 + te * (-47.0/4.0 + te * (3.0 - te / 4.0))) (-102.0 + te * (759.0 / 4.0 + te * (-2867.0 / 24.0 + te * (139.0 / 4.0 - 73.0 / 24.0 * te))) - epoc) / 10.0 * ac + epoc end
summary()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 72 def summary session = @fit_activity.sessions[0] t = FlexiTable.new t.enable_frame(false) t.body t.row([ 'Type:', @type ]) t.row([ 'Sub Type:', @sub_type ]) t.row([ 'Start Time:', session.start_time.localtime]) t.row([ 'Elapsed Time:', secsToHMS(session.total_elapsed_time) ]) t.row([ 'Moving Time:', secsToHMS(session.total_timer_time) ]) t.row([ 'Distance:', local_value(session, 'total_distance', '%.2f %s', { :metric => 'km', :statute => 'mi'}) ]) if session.has_geo_data? t.row([ 'GPS Data based Distance:', local_value(@fit_activity, 'total_gps_distance', '%.2f %s', { :metric => 'km', :statute => 'mi'}) ]) end t.row([ 'Avg. Speed:', local_value(session, 'avg_speed', '%.1f %s', { :metric => 'km/h', :statute => 'mph' }) ]) if @activity.sport == 'running' || @activity.sport == 'multisport' t.row([ 'Avg. Pace:', pace(session, 'avg_speed') ]) t.row([ 'Avg. Run Cadence:', session.avg_running_cadence ? "#{(2 * session.avg_running_cadence).round} spm" : '-' ]) t.row([ 'Avg. Stride Length:', local_value(session, 'avg_stride_length', '%.2f %s', { :metric => 'm', :statute => 'ft' }) ]) t.row([ 'Avg. Vertical Oscillation:', local_value(session, 'avg_vertical_oscillation', '%.1f %s', { :metric => 'cm', :statute => 'in' }) ]) t.row([ 'Vertical Ratio:', session.avg_vertical_ratio ? "#{session.avg_vertical_ratio}%" : '-' ]) t.row([ 'Avg. Ground Contact Time:', session.avg_stance_time ? "#{session.avg_stance_time.round} ms" : '-' ]) t.row([ 'Avg. Stance Time Balance:', session.avg_stance_time_balance ? "#{session.avg_stance_time_balance}% L / " + "#{100.0 - session.avg_stance_time_balance}% R" : ';' ]) end if @activity.sport == 'cycling' t.row([ 'Avg. Cadence:', session.avg_cadence ? "#{(session.avg_cadence).round} rpm" : '-' ]) end t.row([ 'Total Ascent:', local_value(session, 'total_ascent', '%.0f %s', { :metric => 'm', :statute => 'ft' }) ]) t.row([ 'Total Descent:', local_value(session, 'total_descent', '%.0f %s', { :metric => 'm', :statute => 'ft' }) ]) t.row([ 'Calories:', "#{session.total_calories} kCal" ]) if (est_sweat_loss = session.est_sweat_loss) t.row([ 'Est. Sweat Loss:', "#{est_sweat_loss} ml" ]) end t.row([ 'Avg. HR:', session.avg_heart_rate ? "#{session.avg_heart_rate} bpm" : '-' ]) t.row([ 'Max. HR:', session.max_heart_rate ? "#{session.max_heart_rate} bpm" : '-' ]) if @fit_activity.physiological_metrics && (physiological_metrics = @fit_activity.physiological_metrics.last) if physiological_metrics.anaerobic_training_effect t.row([ 'Anaerobic Training Effect:', physiological_metrics.anaerobic_training_effect ]) end if physiological_metrics.aerobic_training_effect t.row([ 'Aerobic Training Effect:', physiological_metrics.aerobic_training_effect ]) end elsif session.total_training_effect t.row([ 'Aerobic Training Effect:', session.total_training_effect ]) end if (p_epoc = peak_epoc) > 0.0 t.row([ 'Peak EPOC:', "%.0f ml/kg" % p_epoc ]) end if (trimp = trimp_exp) > 0.0 t.row([ 'TRIMP:', trimp.round ]) end rec_info = @fit_activity.recovery_info t.row([ 'Ignored Recovery Time:', rec_info ? secsToDHMS(rec_info * 60) : '-' ]) rec_hr = @fit_activity.recovery_hr end_hr = @fit_activity.ending_hr t.row([ 'Recovery HR:', rec_hr && end_hr ? "#{rec_hr} bpm [#{end_hr - rec_hr} bpm]" : '-' ]) rec_time = @fit_activity.recovery_time t.row([ 'Suggested Recovery Time:', rec_time ? secsToDHMS(rec_time * 60) : '-' ]) hrv = HRV_Analyzer.new(@activity) # If we have HRV data for more than 120s we compute the PostRunner HRV # Score for the 2nd and 3rd minute. The first minute is ignored as it # often contains erratic data due to body movements and HRM adjustments. # Clinical tests usually recommend a 5 minute measure time, but that's # probably too long for daily tests. if hrv.has_hrv_data? && hrv.duration > 180 if (hrv_score = hrv.hrv_score(60, 120)) > 0.0 && hrv_score < 100.0 t.row([ 'PostRunner HRV Score:', "%.1f" % hrv_score ]) end end t end
trimp_exp()
click to toggle source
# File lib/postrunner/ActivitySummary.rb, line 385 def trimp_exp # According to Bannister/Morton # TRIMPexp = sum(D x HRr x 0.64e^y) # Where # # D is the duration in minutes at a particular Heart Rate # HRr is the Heart Rate as a fraction of Heart Rate Reserve # y is the HRr multiplied by 1.92 for men and 1.67 for women. return 0.0 unless (user_data = @fit_activity.user_data.first) user_profile = @fit_activity.user_profiles.first hr_zones = @fit_activity.heart_rate_zones.first session = @fit_activity.sessions[0] unless (user_profile && (rest_hr = user_profile.resting_heart_rate)) || (hr_zones && (rest_hr = hr_zones.resting_heart_rate)) # We must have a valid resting heart rate to compute TRIMP. return 0.0 end unless (user_data && (max_hr = user_data.max_hr)) || (hr_zones && (max_hr = hr_zones.max_heart_rate)) # We must have a valid maximum heart rate to compute TRIMP. return 0.0 end unless (session && session.avg_heart_rate && avg_hr = session.avg_heart_rate) return 0.0 end sex_factor = user_data.gender == 'male' ? 1.92 : 1.67 # Instead of using the average heart rate for the whole activity we # apply the equation for each heart rate sample and accumulate them. sum = 0.0 prev_timestamp = nil @activity.fit_activity.records.each do |r| # We need a valid timestmap and a valid previous timestamp. If they # are more than 10 seconds appart we discard the values as there was # likely a pause in the activity. if prev_timestamp && r.timestamp && r.heart_rate && r.timestamp - prev_timestamp <= 10 # Compute the heart rate as fraction of the heart rate reserve hr_r = (r.heart_rate - rest_hr).to_f / (max_hr - rest_hr) duration_min = (r.timestamp - prev_timestamp) / 60.0 #sum += duration_min * hr_r * 0.64 * Math.exp(sex_factor * hr_r) sum += duration_min * hr_r * 0.64 * Math.exp(sex_factor * hr_r) end prev_timestamp = r.timestamp end sum # Alternatively here is an avarage HR based implementation # hr_r = (session.avg_heart_rate - rest_hr).to_f / (max_hr - rest_hr) # duration_min = session.total_elapsed_time / 60.0 # duration_min * hr_r * 0.64 * Math.exp(sex_factor * hr_r) end