summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Schlaeger <chris@linux.com>2016-05-17 22:43:18 +0200
committerChris Schlaeger <chris@linux.com>2016-05-17 22:43:18 +0200
commit535f0daf9b18aa73916769664be16104e07219b1 (patch)
treeed53d474ff346a2f5815bbd40dd78fc8a7ba31bd
parent49634d7c5125440ad771850e210208869390ef75 (diff)
downloadpostrunner-535f0daf9b18aa73916769664be16104e07219b1.zip
Improved daily monitoring report.
-rw-r--r--lib/postrunner/DailyMonitoringAnalyzer.rb239
-rw-r--r--lib/postrunner/DailySleepAnalyzer.rb2
-rw-r--r--lib/postrunner/Main.rb20
-rw-r--r--lib/postrunner/MonitoringStatistics.rb104
4 files changed, 332 insertions, 33 deletions
diff --git a/lib/postrunner/DailyMonitoringAnalyzer.rb b/lib/postrunner/DailyMonitoringAnalyzer.rb
new file mode 100644
index 0000000..cb4b2d1
--- /dev/null
+++ b/lib/postrunner/DailyMonitoringAnalyzer.rb
@@ -0,0 +1,239 @@
+#!/usr/bin/env ruby -w
+# encoding: UTF-8
+#
+# = DailyMonitoringAnalzyer.rb -- PostRunner - Manage the data from your Garmin sport devices.
+#
+# Copyright (c) 2016 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'
+
+module PostRunner
+
+ class DailyMonitoringAnalyzer
+
+ attr_reader :window_start_time, :window_end_time
+
+ class MonitoringSample
+
+ attr_reader :timestamp, :activity_type, :cycles, :steps,
+ :floors_climbed, :floors_descended, :distance,
+ :active_calories, :weekly_moderate_activity_minutes,
+ :weekly_vigorous_activity_minutes
+
+ def initialize(m)
+ @timestamp = m.timestamp
+ types = [
+ 'generic', 'running', 'cycling', 'transition',
+ 'fitness_equipment', 'swimming', 'walking', 'unknown7',
+ 'resting', 'unknown9'
+ ]
+ if (cati = m.current_activity_type_intensity)
+ @activity_type = types[cati & 0x1F]
+ @activity_intensity = (cati >> 5) & 0x7
+ else
+ @activity_type = m.activity_type
+ end
+ @active_time = m.active_time
+ @active_calories = m.active_calories
+ @ascent = m.ascent
+ @descent = m.descent
+ @floors_climbed = m.floors_climbed
+ @floors_descended = m.floors_descended
+ @cycles = m.cycles
+ @distance = m.distance
+ @duration_min = m.duration_min
+ @heart_rate = m.heart_rate
+ @steps = m.steps
+ @weekly_moderate_activity_minutes = m.weekly_moderate_activity_minutes
+ @weekly_vigorous_activity_minutes = m.weekly_vigorous_activity_minutes
+ end
+
+ end
+
+ def initialize(monitoring_files, day)
+ # Day as Time object. Midnight UTC.
+ day_as_time = Time.parse(day + "-00:00:00+00:00").gmtime
+
+ @samples = []
+ extract_data_from_monitor_files(monitoring_files, day_as_time)
+
+ # We must have information about the local time zone the data was
+ # recorded in. Abort if not available.
+ return unless @utc_offset
+ end
+
+ def total_distance
+ distance = 0.0
+ @samples.each do |s|
+ if s.distance && s.distance > distance
+ distance = s.distance
+ end
+ end
+
+ distance
+ end
+
+ def total_floors
+ floors_climbed = floors_descended = 0.0
+
+ @samples.each do |s|
+ if s.floors_climbed && s.floors_climbed > floors_climbed
+ floors_climbed = s.floors_climbed
+ end
+ if s.floors_descended && s.floors_descended > floors_descended
+ floors_descended = s.floors_descended
+ end
+ end
+
+ { :floors_climbed => (floors_climbed / 3.048).floor,
+ :floors_descended => (floors_descended / 3.048).floor }
+ end
+
+ def steps_distance_calories
+ total_cycles = Hash.new(0.0)
+ total_distance = Hash.new(0.0)
+ total_calories = Hash.new(0.0)
+
+ @samples.each do |s|
+ at = s.activity_type
+ if s.cycles && s.cycles > total_cycles[at]
+ total_cycles[at] = s.cycles
+ end
+ if s.distance && s.distance > total_distance[at]
+ total_distance[at] = s.distance
+ end
+ if s.active_calories && s.active_calories > total_calories[at]
+ total_calories[at] = s.active_calories
+ end
+ end
+
+ distance = calories = 0.0
+ if @monitoring_info
+ if @monitoring_info.activity_type &&
+ @monitoring_info.cycles_to_distance &&
+ @monitoring_info.cycles_to_calories
+ walking_cycles_to_distance = running_cycles_to_distance = nil
+ walking_cycles_to_calories = running_cycles_to_calories = nil
+
+ @monitoring_info.activity_type.each_with_index do |at, idx|
+ if at == 'walking'
+ walking_cycles_to_distance =
+ @monitoring_info.cycles_to_distance[idx]
+ walking_cycles_to_calories =
+ @monitoring_info.cycles_to_calories[idx]
+ elsif at == 'running'
+ running_cycles_to_distance =
+ @monitoring_info.cycles_to_distance[idx]
+ running_cycles_to_calories =
+ @monitoring_info.cycles_to_calories[idx]
+ end
+ end
+ distance = total_distance.values.inject(0.0, :+)
+ calories = total_calories.values.inject(0.0, :+) +
+ @monitoring_info.resting_metabolic_rate
+ end
+ end
+
+ { :steps => ((total_cycles['walking'] + total_cycles['running']) * 2 +
+ total_cycles['generic']).to_i,
+ :distance => distance, :calories => calories }
+ end
+
+ def intensity_minutes
+ moderate_minutes = vigorous_minutes = 0.0
+ @samples.each do |s|
+ if s.weekly_moderate_activity_minutes &&
+ s.weekly_moderate_activity_minutes > moderate_minutes
+ moderate_minutes = s.weekly_moderate_activity_minutes
+ end
+ if s.weekly_vigorous_activity_minutes &&
+ s.weekly_vigorous_activity_minutes > vigorous_minutes
+ vigorous_minutes = s.weekly_vigorous_activity_minutes
+ end
+ end
+
+ { :moderate_minutes => moderate_minutes,
+ :vigorous_minutes => vigorous_minutes }
+ end
+
+ def steps_goal
+ if @monitoring_info && @monitoring_info.goal_cycles &&
+ @monitoring_info.goal_cycles[0]
+ @monitoring_info.goal_cycles[0]
+ else
+ 0
+ end
+ end
+
+ def samples
+ @samples.length
+ end
+
+ private
+
+ def get_monitoring_info(monitoring_file)
+ # The monitoring files have a monitoring_info section that contains a
+ # timestamp in UTC and a local_time field for the same time in the local
+ # time. If any of that isn't present, we use an offset of 0.
+ if (mis = monitoring_file.monitoring_infos).nil? || mis.empty? ||
+ (mi = mis[0]).nil? || mi.local_time.nil? || mi.timestamp.nil?
+ return nil
+ end
+
+ mi
+ end
+
+ # Load monitoring data from monitoring_b FIT files into Arrays.
+ # @param monitoring_files [Array of Monitoring_B] FIT files to read
+ # @param day [Time] Midnight UTC of the day to analyze
+ def extract_data_from_monitor_files(monitoring_files, day)
+ monitoring_files.each do |mf|
+ next unless (mi = get_monitoring_info(mf))
+
+ utc_offset = mi.local_time - mi.timestamp
+ # Midnight (local time) of the requested day.
+ window_start_time = day - utc_offset
+ # Midnight (local time) of the next day
+ window_end_time = window_start_time + 24 * 60 * 60
+
+ # Ignore all files with data prior to the potential time window.
+ next if mf.monitorings.empty? ||
+ mf.monitorings.last.timestamp < window_start_time
+
+ if @utc_offset.nil?
+ # The instance variables will only be set once we have found our
+ # first monitoring file that matches the requested day. We use the
+ # local time setting for this first file even if it changes in
+ # subsequent files.
+ @window_start_time = window_start_time
+ @window_end_time = window_end_time
+ @utc_offset = utc_offset
+ end
+
+ if @monitoring_info.nil? && @window_start_time <= mi.local_time &&
+ mi.local_time < @window_end_time
+ @monitoring_info = mi
+ end
+
+ mf.monitorings.each do |m|
+ # Ignore all entries outside our time window. It's important to note
+ # that records with a midnight timestamp contain totals from the day
+ # before.
+ next if m.timestamp <= @window_start_time ||
+ m.timestamp > @window_end_time
+
+ @samples << MonitoringSample.new(m)
+ end
+ end
+
+ end
+
+ end
+
+end
+
diff --git a/lib/postrunner/DailySleepAnalyzer.rb b/lib/postrunner/DailySleepAnalyzer.rb
index 4779f9a..fbc6669 100644
--- a/lib/postrunner/DailySleepAnalyzer.rb
+++ b/lib/postrunner/DailySleepAnalyzer.rb
@@ -41,7 +41,7 @@ module PostRunner
attr_reader :sleep_cycles, :utc_offset,
:total_sleep, :rem_sleep, :deep_sleep, :light_sleep,
- :resting_heart_rate
+ :resting_heart_rate, :window_start_time, :window_end_time
TIME_WINDOW_MINUTES = 24 * 60
diff --git a/lib/postrunner/Main.rb b/lib/postrunner/Main.rb
index 3fa835a..3d568a0 100644
--- a/lib/postrunner/Main.rb
+++ b/lib/postrunner/Main.rb
@@ -170,6 +170,13 @@ check [ <fit file> | <ref> ... ]
Check the provided FIT file(s) for structural errors. If no file or
reference is provided, the complete archive is checked.
+daily [ <YYYY-MM-DD> ]
+ Print the monitoring statistics for the requested day and the
+ following night. If no date is given, yesterday's date will be used.
+
+delete <ref>
+ Delete the activity from the archive.
+
dump <fit file> | <ref>
Dump the content of the FIT file.
@@ -181,18 +188,13 @@ import [ <fit file> | <directory> ]
file or directory is provided, the directory that was used for the
previous import is being used.
-daily [ <date> ]
- Print a report summarizing the current day or the specified day.
-
-delete <ref>
- Delete the activity from the archive.
-
list
List all FIT files stored in the data base.
-monthly [ <date> ]
+monthly [ <YYYY-MM-DD> ]
+
Print a table with various statistics for each day of the specified
- month.
+ month. If no date is given, yesterday's month will be used.
records
List all personal records.
@@ -554,7 +556,7 @@ EOT
def day_in_localtime(args, format)
begin
- (args.empty? ? Time.now : Time.parse(args[0])).
+ (args.empty? ? Time.now - 24 * 60 * 60 : Time.parse(args[0])).
localtime.strftime(format)
rescue ArgumentError
Log.abort("#{args[0]} is not a valid date. Use YYYY-MM-DD format.")
diff --git a/lib/postrunner/MonitoringStatistics.rb b/lib/postrunner/MonitoringStatistics.rb
index c6d2da1..528629f 100644
--- a/lib/postrunner/MonitoringStatistics.rb
+++ b/lib/postrunner/MonitoringStatistics.rb
@@ -35,35 +35,22 @@ module PostRunner
# @param day [String] Date of the day as YYYY-MM-DD string.
def daily(day)
sleep_analyzer = DailySleepAnalyzer.new(@monitoring_files, day,
- -12 * 60 * 60)
+ +12 * 60 * 60)
monitoring_analyzer = DailyMonitoringAnalyzer.new(@monitoring_files, day)
- str = ''
+ str = "Daily Monitoring Report for #{day}\n\n" +
+ "#{daily_goals_table(monitoring_analyzer)}\n" +
+ "#{daily_stats_table(monitoring_analyzer, sleep_analyzer)}\n"
if sleep_analyzer.sleep_cycles.empty?
str += 'No sleep data available for this day'
else
- str += "Sleep Statistics for #{day}\n\n" +
- daily_sleep_cycle_table(sleep_analyzer).to_s +
- "\nResting heart rate: #{sleep_analyzer.resting_heart_rate} BPM\n"
+ str += "Sleep Statistics for " +
+ "#{sleep_analyzer.window_start_time.strftime('%Y-%m-%d')} - " +
+ "#{sleep_analyzer.window_end_time.strftime('%Y-%m-%d')}\n\n" +
+ daily_sleep_cycle_table(sleep_analyzer).to_s
end
- steps_distance_calories = monitoring_analyzer.steps_distance_calories
- steps = steps_distance_calories[:steps]
- steps_goal = monitoring_analyzer.steps_goal
- str += "Steps: #{steps} " +
- "(#{percent(steps, steps_goal)} of daily goal #{steps_goal})\n"
- intensity_minutes =
- monitoring_analyzer.intensity_minutes[:moderate_minutes] +
- 2 * monitoring_analyzer.intensity_minutes[:vigorous_minutes]
- str += "Intensity Minutes: #{intensity_minutes} " +
- "(#{percent(intensity_minutes, 150)} of weekly goal 150)\n"
- floors = monitoring_analyzer.total_floors
- floors_climbed = floors[:floors_climbed]
- str += "Floors climbed: #{floors_climbed} " +
- "(#{percent(floors_climbed, 10)} of daily goal 10)\n" +
- "Floors descended: #{floors[:floors_descended]}\n"
- str += "Distance: " +
- "#{'%.1f' % (steps_distance_calories[:distance] / 1000.0)} km\n"
- str += "Calories: #{steps_distance_calories[:calories].to_i}\n"
+
+ str
end
# Generate a report for a certain month.
@@ -152,6 +139,58 @@ module PostRunner
ti
end
+ def daily_goals_table(monitoring_analyzer)
+ t = FlexiTable.new
+
+ t.head
+ t.row([ 'Steps', 'Intensity Minutes', 'Floors Climbed' ])
+
+ t.body
+ t.set_column_attributes(Array.new(3, { :halign => :center}))
+
+ steps_distance_calories = monitoring_analyzer.steps_distance_calories
+ steps = steps_distance_calories[:steps]
+ steps_goal = monitoring_analyzer.steps_goal
+ t.cell(steps)
+
+ intensity_minutes = weekly_intensity_minutes(monitoring_analyzer)
+ t.cell(intensity_minutes)
+
+ floors = monitoring_analyzer.total_floors
+ floors_climbed = floors[:floors_climbed]
+ t.cell(floors_climbed)
+ t.new_row
+
+ t.cell("#{percent(steps, steps_goal)} of daily goal #{steps_goal}")
+ t.cell("#{percent(intensity_minutes, 150)} of weekly goal 150")
+ t.cell("#{percent(floors_climbed, 10)} of daily goal 10")
+ t.new_row
+
+ t
+ end
+
+ def daily_stats_table(monitoring_analyzer, sleep_analyzer)
+ t = FlexiTable.new
+ t.set_column_attributes(Array.new(4, { :halign => :center}))
+
+ t.head
+ t.row([ 'Distance', 'Calories', 'Floors descended',
+ 'Resting Heart Rate' ])
+
+ t.body
+ steps_distance_calories = monitoring_analyzer.steps_distance_calories
+ t.cell("#{'%.1f' % (steps_distance_calories[:distance] / 1000.0)} km")
+
+ t.cell("#{steps_distance_calories[:calories].to_i}")
+
+ floors = monitoring_analyzer.total_floors
+ t.cell("#{floors[:floors_descended]}")
+
+ t.cell("#{sleep_analyzer.resting_heart_rate} BPM")
+
+ t
+ end
+
def monthly_goal_table(year, month, last_day_of_month)
t = FlexiTable.new
left = { :halign => :left }
@@ -294,6 +333,25 @@ module PostRunner
t
end
+ def weekly_intensity_minutes(monitoring_analyzer)
+ current_date = monitoring_analyzer.window_start_time
+
+ intensity_minutes = 0
+ # Need to find a way to get intensity minutes for previous days.
+ #1.upto(current_date.wday) do |i|
+ # date = current_date - 24 * 60 * 60 * i
+ # ma = DailyMonitoringAnalyzer.new(date.strftime('%Y-%m-%d'))
+ # intensity_minutes +=
+ # ma.intensity_minutes[:moderate_minutes] +
+ # 2 * ma.intensity_minutes[:vigorous_minutes]
+ #end
+ intensity_minutes +=
+ monitoring_analyzer.intensity_minutes[:moderate_minutes] +
+ 2 * monitoring_analyzer.intensity_minutes[:vigorous_minutes]
+
+ intensity_minutes
+ end
+
end
end