From 1ae228fe1dfa65a343a28f94cdf82553cecd11e5 Mon Sep 17 00:00:00 2001 From: Chris Schlaeger Date: Mon, 10 Jul 2017 07:59:54 +0200 Subject: New: Include heart rate zone in activity reports. --- lib/postrunner/ActivitySummary.rb | 83 +++++++++++++++++++++++++++++++++++++-- lib/postrunner/HRZoneDetector.rb | 77 ++++++++++++++++++++++++++++++++++++ lib/postrunner/PersonalRecords.rb | 3 ++ 3 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 lib/postrunner/HRZoneDetector.rb diff --git a/lib/postrunner/ActivitySummary.rb b/lib/postrunner/ActivitySummary.rb index 97df15d..2ec4ec8 100644 --- a/lib/postrunner/ActivitySummary.rb +++ b/lib/postrunner/ActivitySummary.rb @@ -16,6 +16,7 @@ require 'postrunner/FlexiTable' require 'postrunner/ViewFrame' require 'postrunner/HRV_Analyzer' require 'postrunner/Percentiles' +require 'postrunner/HRZoneDetector' module PostRunner @@ -33,9 +34,12 @@ module PostRunner end def to_s - summary.to_s + "\n" + - (@activity.note ? note.to_s + "\n" : '') + - laps.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 def to_html(doc) @@ -45,6 +49,10 @@ module PostRunner 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 private @@ -178,6 +186,75 @@ module PostRunner t end + 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 + each_hr_zone_with_index { |secs_in_zone, i| total_secs += secs_in_zone } + + hr_mins = HRZoneDetector::detect_zones( + @fit_activity.records, @fit_activity.sessions[0].time_in_hr_zone[0..5]) + each_hr_zone_with_index do |secs_in_zone, i| + t.cell(i) + t.cell([ '', 'Warm Up', 'Easy', 'Aerobic', 'Threshold', 'Maximum' ][i]) + t.cell(hr_mins[i] || '-') + t.cell(i == HRZoneDetector::GARMIN_ZONES ? + session.max_heart_rate || '-' : + hr_mins[i + 1].nil? || hr_mins[i + 1] == 0 ? '-' : + (hr_mins[i + 1] - 1)) + t.cell(secsToHMS(secs_in_zone)) + t.cell('%.0f%%' % ((secs_in_zone / total_secs) * 100.0)) + + t.new_row + end + + t + end + + def has_hr_zones? + 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 + + counted_zones == 5 && total_time_in_zone > 0.0 + end + + 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 + def local_value(fdr, field, format, units) unit = units[@unit_system] value = fdr.get_as(field, unit) diff --git a/lib/postrunner/HRZoneDetector.rb b/lib/postrunner/HRZoneDetector.rb new file mode 100644 index 0000000..2e9c003 --- /dev/null +++ b/lib/postrunner/HRZoneDetector.rb @@ -0,0 +1,77 @@ +#!/usr/bin/env ruby -w +# encoding: UTF-8 +# +# = HRZoneDetector.rb -- PostRunner - Manage the data from your Garmin sport devices. +# +# Copyright (c) 2017 by Chris Schlaeger +# +# 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. +# + +module PostRunner + + module HRZoneDetector + + # Number of heart rate zones supported by Garmin devices. + GARMIN_ZONES = 5 + # Maximum heart rate that can be stored in FIT files. + MAX_HR = 255 + + def HRZoneDetector::detect_zones(fit_records, secs_in_zones) + if fit_records.empty? + raise RuntimeError, "records must not be empty" + end + if secs_in_zones.size != GARMIN_ZONES + 1 + raise RuntimeError, "secs_in_zones must have #{GARMIN_ZONES + 1} " + + "elements" + end + + # We generate a histogram of the time spent at each integer heart rate. + histogram = Array.new(MAX_HR + 1, 0) + + last_timestamp = nil + fit_records.each do |record| + next unless record.heart_rate + + if last_timestamp + # We ignore all intervals that are larger than 10 seconds. This + # potentially conflicts with smart recording, but I can't see how a + # larger sampling interval can yield usable results. + if (delta_t = record.timestamp - last_timestamp) <= 10 + histogram[record.heart_rate] += delta_t + end + end + last_timestamp = record.timestamp + end + + # We'll process zones 5 downto 1. + zone = GARMIN_ZONES + hr_mins = Array.new(GARMIN_ZONES) + # Sum of time spent in current zone. + secs_in_current_zone = 0 + # We process the histogramm from highest to smallest HR value. Whenever + # we have accumulated the provided amount of time we have found a HR + # zone boundary. We complete the current zone and continue with the next + # one. + MAX_HR.downto(0) do |i| + secs_in_current_zone += histogram[i] + + if secs_in_current_zone > secs_in_zones[zone] + # In case we have collected more time than was specified for the + # zone we carry the delta over to the next zone. + secs_in_current_zone -= secs_in_zones[zone] + # puts "Zone #{zone}: #{secs_in_current_zone} #{secs_in_zones[zone]}" + break if (zone -= 1) < 0 + end + hr_mins[zone] = i + end + + hr_mins + end + + end + +end + diff --git a/lib/postrunner/PersonalRecords.rb b/lib/postrunner/PersonalRecords.rb index 098badd..8dfcf7e 100644 --- a/lib/postrunner/PersonalRecords.rb +++ b/lib/postrunner/PersonalRecords.rb @@ -346,6 +346,9 @@ module PostRunner # meters) speed_records = {} + # Ignore FIT files that don't have an activity or session + return unless activity.fit_activity && activity.fit_activity.sessions + segment_start_time = activity.fit_activity.sessions[0].start_time segment_start_distance = 0.0 -- cgit v1.2.3