summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Schlaeger <chris@linux.com>2016-04-14 21:58:10 +0200
committerChris Schlaeger <chris@linux.com>2016-04-14 21:58:10 +0200
commitd8847c6367533f5b0aaf221b3ad73c0fdf81d3dd (patch)
tree2ef80b98577aee63183960c172ed50fef6f696d9
parentae2c2a9c3b78c90a9718107d2d95aeb1b360491a (diff)
downloadpostrunner-d8847c6367533f5b0aaf221b3ad73c0fdf81d3dd.zip
New: Simple sleep analyzer added
Use the 'daily' command to get an overview of the sleep data for a given day.
-rw-r--r--lib/postrunner/DailySleepAnalyzer.rb239
-rw-r--r--lib/postrunner/FFS_Device.rb16
-rw-r--r--lib/postrunner/FFS_Monitoring.rb96
-rw-r--r--lib/postrunner/FitFileStore.rb23
-rw-r--r--lib/postrunner/Main.rb6
5 files changed, 380 insertions, 0 deletions
diff --git a/lib/postrunner/DailySleepAnalyzer.rb b/lib/postrunner/DailySleepAnalyzer.rb
new file mode 100644
index 0000000..ef8d279
--- /dev/null
+++ b/lib/postrunner/DailySleepAnalyzer.rb
@@ -0,0 +1,239 @@
+#!/usr/bin/env ruby -w
+# encoding: UTF-8
+#
+# = DailySleepAnalzyer.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
+
+ # This class extracts the sleep information from a set of monitoring files
+ # and determines when and how long the user was awake or had a light or deep
+ # sleep.
+ class DailySleepAnalyzer
+
+ # Utility class to store the interval of a sleep/wake phase.
+ class SleepPeriod < Struct.new(:from_time, :to_time, :phase)
+ end
+
+ include Fit4Ruby::Converters
+
+ # Create a new DailySleepAnalyzer object to analyze the given monitoring
+ # files.
+ # @param monitoring_files [Array] A set of Monitoring_B objects
+ # @param day [String] Day to analyze as YY-MM-DD string
+ def initialize(monitoring_files, day)
+ @noon_yesterday = @noon_today = @utc_offset = nil
+ @sleep_periods = []
+
+ # Day as Time object. Midnight UTC.
+ day_as_time = Time.parse(day + "-00:00:00+00:00").gmtime
+ extract_data_from_monitor_files(monitoring_files, day_as_time)
+ fill_sleep_activity
+ smoothen_sleep_activity
+ analyze
+ trim_wake_periods_at_ends
+ calculate_totals
+ end
+
+ def to_s
+ unless @noon_today
+ return 'No sleep data available for this day'
+ end
+
+ s = "Sleep periods for #{@noon_today.strftime('%Y-%m-%d')}\n"
+ @sleep_periods.each do |p|
+ s += "#{p.from_time.localtime(@utc_offset).strftime('%H:%M')} - " +
+ "#{p.to_time.localtime(@utc_offset).strftime('%H:%M')} : " +
+ "#{p.phase.to_s.gsub(/_/, ' ')}\n"
+ end
+ s += "\nTotal sleep time: #{secsToHM(@total_sleep)} (" +
+ "Deep: #{secsToHM(@deep_sleep)}, " +
+ "Light: #{secsToHM(@light_sleep)})"
+ end
+
+ private
+
+ def extract_utc_offset(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 (mi = monitoring_file.monitoring_infos).nil? || mi.empty? ||
+ (localtime = mi[0].local_time).nil?
+ return 0
+ end
+
+ # Otherwise the delta in seconds between UTC and localtime is the
+ # offset.
+ localtime - mi[0].timestamp
+ end
+
+ def extract_data_from_monitor_files(monitoring_files, day)
+ # We use an Array with entries for every minute from noon yesterday to
+ # noon today.
+ @sleep_activity = Array.new(24 * 60, nil)
+ monitoring_files.each do |mf|
+ utc_offset = extract_utc_offset(mf)
+ # Noon (local time) the day before the requested day. The time object
+ # is UTC for the noon time in the local time zone.
+ noon_yesterday = day - 12 * 60 * 60 - utc_offset
+ # Noon (local time) of the current day
+ noon_today = day + 12 * 60 * 60 - utc_offset
+
+ mf.monitorings.each do |m|
+ # Ignore all entries outside our 24 hour window from noon the day
+ # before to noon the current day.
+ next if m.timestamp < noon_yesterday || m.timestamp >= noon_today
+
+ if @noon_yesterday.nil? && @noon_today.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.
+ @noon_yesterday = noon_yesterday
+ @noon_today = noon_today
+ @utc_offset = utc_offset
+ end
+
+ if (cati = m.current_activity_type_intensity)
+ activity_type = cati & 0x1F
+
+ # Compute the index in the @sleep_activity Array.
+ index = (m.timestamp - @noon_yesterday) / 60
+ if activity_type == 8
+ intensity = (cati >> 5) & 0x7
+ @sleep_activity[index] = intensity
+ else
+ @sleep_activity[index] = false
+ end
+ end
+ end
+ end
+
+ end
+
+ def fill_sleep_activity
+ current = nil
+ @sleep_activity = @sleep_activity.reverse.map do |v|
+ v.nil? ? current : current = v
+ end.reverse
+
+ if $DEBUG
+ File.open('sleep-data.csv', 'w') do |f|
+ f.puts 'Date;Value'
+ @sleep_activity.each_with_index do |v, i|
+ f.puts "#{@noon_yesterday + i * 60};#{v.is_a?(Fixnum) ? v : 8}"
+ end
+ end
+ end
+ end
+
+ def smoothen_sleep_activity
+ window_size = 30
+
+ @smoothed_sleep_activity = Array.new(24 * 60, nil)
+ 0.upto(24 * 60 - 1).each do |i|
+ window_start_idx = i - window_size
+ window_end_idx = i
+ sum = 0.0
+ (i - window_size + 1).upto(i).each do |j|
+ sum += j < 0 ? 8.0 :
+ @sleep_activity[j].is_a?(Fixnum) ? @sleep_activity[j] : 8
+ end
+ @smoothed_sleep_activity[i] = sum / window_size
+ end
+
+ if $DEBUG
+ File.open('smoothed-sleep-data.csv', 'w') do |f|
+ f.puts 'Date;Value'
+ @smoothed_sleep_activity.each_with_index do |v, i|
+ f.puts "#{@noon_yesterday + i * 60};#{v}"
+ end
+ end
+ end
+ end
+
+ def analyze
+ current_phase = :awake
+ current_phase_start = @noon_yesterday
+ @sleep_periods = []
+
+ @smoothed_sleep_activity.each_with_index do |v, idx|
+ if v < 0.25
+ phase = :deep_sleep
+ elsif v < 1.5
+ phase = :light_sleep
+ else
+ phase = :awake
+ end
+
+ if current_phase != phase
+ t = @noon_yesterday + 60 * idx
+ @sleep_periods << SleepPeriod.new(current_phase_start, t,
+ current_phase)
+ current_phase = phase
+ current_phase_start = t
+ end
+ end
+ @sleep_periods << SleepPeriod.new(current_phase_start, @noon_today,
+ current_phase)
+ end
+
+ def trim_wake_periods_at_ends
+ first_deep_sleep_idx = last_deep_sleep_idx = nil
+
+ @sleep_periods.each_with_index do |p, idx|
+ if p.phase == :deep_sleep ||
+ (p.phase == :light_sleep && ((p.to_time - p.from_time) > 15 * 60))
+ first_deep_sleep_idx = idx unless first_deep_sleep_idx
+ last_deep_sleep_idx = idx
+ end
+ end
+
+ return unless first_deep_sleep_idx && last_deep_sleep_idx
+
+ if first_deep_sleep_idx > 0 &&
+ @sleep_periods[first_deep_sleep_idx - 1].phase == :light_sleep
+ first_deep_sleep_idx -= 1
+ end
+ if last_deep_sleep_idx < @sleep_periods.length - 2 &&
+ @sleep_periods[last_deep_sleep_idx + 1].phase == :light_sleep
+ last_deep_sleep_idx += 1
+ end
+
+ @sleep_periods = @sleep_periods[first_deep_sleep_idx..last_deep_sleep_idx]
+ end
+
+ def calculate_totals
+ @total_sleep = @light_sleep = @deep_sleep = 0
+ @sleep_periods.each do |p|
+ if p.phase != :awake
+ seconds = p.to_time - p.from_time
+ @total_sleep += seconds
+ if p.phase == :light_sleep
+ @light_sleep += seconds
+ else
+ @deep_sleep += seconds
+ end
+ end
+ end
+ end
+
+ # Return the begining of the current day in local time as Time object.
+ def begining_of_today(time = Time.now)
+ sec, min, hour, day, month, year = time.to_a
+ sec = min = hour = 0
+ Time.new(*[ year, month, day, hour, min, sec, 0 ]).localtime
+ end
+
+ end
+
+end
+
diff --git a/lib/postrunner/FFS_Device.rb b/lib/postrunner/FFS_Device.rb
index 98f5c87..183e8bd 100644
--- a/lib/postrunner/FFS_Device.rb
+++ b/lib/postrunner/FFS_Device.rb
@@ -123,6 +123,22 @@ module PostRunner
@monitorings.find { |a| a.fit_file_name == file_name }
end
+ # Return all monitorings that overlap with the time interval given by
+ # from_time and to_time.
+ # @param from_time [Time] start time of the interval
+ # @param to_time [Time] end time of the interval (not included)
+ # @return [Array] list of overlapping FFS_Monitoring objects.
+ def monitorings(from_time, to_time)
+ list = []
+ @monitorings.each do |m|
+ if (from_time <= m.period_start && m.period_start < to_time) ||
+ (from_time <= m.period_end && m.period_end < to_time)
+ list << m
+ end
+ end
+ list
+ end
+
end
end
diff --git a/lib/postrunner/FFS_Monitoring.rb b/lib/postrunner/FFS_Monitoring.rb
new file mode 100644
index 0000000..a5c52da
--- /dev/null
+++ b/lib/postrunner/FFS_Monitoring.rb
@@ -0,0 +1,96 @@
+#!/usr/bin/env ruby -w
+# encoding: UTF-8
+#
+# = FFS_Monitoring.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'
+require 'perobs'
+
+module PostRunner
+
+ # The FFS_Monitoring objects can store a reference to the FIT file data and
+ # caches some frequently used values.
+ class FFS_Monitoring < PEROBS::Object
+
+ include DirUtils
+
+ po_attr :device, :fit_file_name, :name, :period_start, :period_end
+
+ # Create a new FFS_Monitoring object.
+ # @param p [PEROBS::Handle] PEROBS handle
+ # @param fit_file_name [String] The fully qualified file name of the FIT
+ # file to add
+ # @param fit_entity [Fit4Ruby::FitEntity] The content of the loaded FIT
+ # file
+ def initialize(p, device, fit_file_name, fit_entity)
+ super(p)
+
+ self.device = device
+ self.fit_file_name = fit_file_name ? File.basename(fit_file_name) : nil
+ self.name = fit_file_name ? File.basename(fit_file_name) : nil
+
+ extract_summary_values(fit_entity)
+ end
+
+ # Store a copy of the given FIT file in the corresponding directory.
+ # @param fit_file_name [String] Fully qualified name of the FIT file.
+ def store_fit_file(fit_file_name)
+ # Get the right target directory for this particular FIT file.
+ dir = @store['file_store'].fit_file_dir(File.basename(fit_file_name),
+ @device.long_uid, 'monitor')
+ # Create the necessary directories if they don't exist yet.
+ create_directory(dir, 'Device monitoring diretory')
+
+ # Copy the file into the target directory.
+ begin
+ FileUtils.cp(fit_file_name, dir)
+ rescue StandardError
+ Log.fatal "Cannot copy #{fit_file_name} into #{dir}: #{$!}"
+ end
+ end
+
+ # FFS_Monitoring objects are sorted by their start time values and then by
+ # their device long_uids.
+ def <=>(a)
+ @period_start == a.period_start ?
+ a.device.long_uid <=> self.device.long_uid :
+ a.period_start <=> @period_start
+ end
+
+ private
+
+ def extract_summary_values(fit_entity)
+ self.period_start = fit_entity.monitoring_infos[0].timestamp
+
+ period_end = @period_start
+ fit_entity.monitorings.each do |monitoring|
+ period_end = monitoring.timestamp if monitoring.timestamp
+ end
+ self.period_end = period_end
+
+ puts "#{@period_start} - #{@period_end}"
+ end
+
+ def decode_activity_type(activity_type)
+ types = [ :generic, :running, :cycling, :transition,
+ :fitness_equipment, :swimming, :walking, :unknown7,
+ :resting, :unknown9 ]
+ if (decoded_type = types[activity_type])
+ decoded_type
+ else
+ Log.error "Unknown activity type #{activity_type}"
+ :generic
+ end
+ end
+
+ end
+
+end
+
diff --git a/lib/postrunner/FitFileStore.rb b/lib/postrunner/FitFileStore.rb
index 89c8e81..a088e7b 100644
--- a/lib/postrunner/FitFileStore.rb
+++ b/lib/postrunner/FitFileStore.rb
@@ -18,6 +18,7 @@ require 'postrunner/DirUtils'
require 'postrunner/FFS_Device'
require 'postrunner/ActivityListView'
require 'postrunner/ViewButtons'
+require 'postrunner/DailySleepAnalyzer'
module PostRunner
@@ -318,6 +319,28 @@ module PostRunner
end
end
+ def daily_report(day)
+ monitorings = []
+ # 'day' specifies the current day. But we don't know what timezone the
+ # watch was set to for a given date. The files are always named after
+ # the moment of finishing the recording expressed as GMT time.
+ # Each file contains information about the time zone for the specific
+ # file. Recording is always flipped to a new file at midnight GMT but
+ # there are usually multiple files per GMT day.
+ day_as_time = Time.parse(day).gmtime
+ @store['devices'].each do |id, device|
+ # We are looking for all files that potentially overlap with our
+ # localtime day.
+ monitorings += device.monitorings(day_as_time - 36 * 60 * 60,
+ day_as_time + 36 * 60 * 60)
+ end
+ monitoring_files = monitorings.map do |m|
+ read_fit_file(File.join(fit_file_dir(m.fit_file_name, m.device.long_uid,
+ 'monitor'), m.fit_file_name))
+ end
+ puts DailySleepAnalyzer.new(monitoring_files, day).to_s
+ end
+
private
def read_fit_file(fit_file_name)
diff --git a/lib/postrunner/Main.rb b/lib/postrunner/Main.rb
index e835507..503c49c 100644
--- a/lib/postrunner/Main.rb
+++ b/lib/postrunner/Main.rb
@@ -293,6 +293,12 @@ EOT
else
process_files_or_activities(args, :check)
end
+ when 'daily'
+ # Get the date of requested day in 'YY-MM-DD' format. If no argument
+ # is given, use the current date.
+ day = (args.empty? ? Time.now : Time.parse(args[0])).
+ localtime.strftime('%Y-%m-%d')
+ @ffs.daily_report(day)
when 'delete'
process_activities(args, :delete)
when 'dump'