From d8847c6367533f5b0aaf221b3ad73c0fdf81d3dd Mon Sep 17 00:00:00 2001 From: Chris Schlaeger Date: Thu, 14 Apr 2016 21:58:10 +0200 Subject: New: Simple sleep analyzer added Use the 'daily' command to get an overview of the sleep data for a given day. --- lib/postrunner/DailySleepAnalyzer.rb | 239 +++++++++++++++++++++++++++++++++++ lib/postrunner/FFS_Device.rb | 16 +++ lib/postrunner/FFS_Monitoring.rb | 96 ++++++++++++++ lib/postrunner/FitFileStore.rb | 23 ++++ lib/postrunner/Main.rb | 6 + 5 files changed, 380 insertions(+) create mode 100644 lib/postrunner/DailySleepAnalyzer.rb create mode 100644 lib/postrunner/FFS_Monitoring.rb 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 +# +# 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 +# +# 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' -- cgit v1.2.3