summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/postrunner/ActivitySummary.rb83
-rw-r--r--lib/postrunner/HRZoneDetector.rb77
-rw-r--r--lib/postrunner/PersonalRecords.rb3
3 files changed, 160 insertions, 3 deletions
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 <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.
+#
+
+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