diff options
-rw-r--r-- | lib/postrunner/DailyMonitoringAnalyzer.rb | 239 | ||||
-rw-r--r-- | lib/postrunner/DailySleepAnalyzer.rb | 2 | ||||
-rw-r--r-- | lib/postrunner/Main.rb | 20 | ||||
-rw-r--r-- | lib/postrunner/MonitoringStatistics.rb | 104 |
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 |