diff options
-rw-r--r-- | lib/postrunner/DailySleepAnalyzer.rb | 498 | ||||
-rw-r--r-- | lib/postrunner/FFS_Monitoring.rb | 2 | ||||
-rw-r--r-- | lib/postrunner/FitFileStore.rb | 4 | ||||
-rw-r--r-- | lib/postrunner/FlexiTable.rb | 7 | ||||
-rw-r--r-- | lib/postrunner/Main.rb | 1 | ||||
-rw-r--r-- | lib/postrunner/SleepCycle.rb | 198 | ||||
-rw-r--r-- | lib/postrunner/SleepStatistics.rb | 138 |
7 files changed, 708 insertions, 140 deletions
diff --git a/lib/postrunner/DailySleepAnalyzer.rb b/lib/postrunner/DailySleepAnalyzer.rb index a120d95..fd3340c 100644 --- a/lib/postrunner/DailySleepAnalyzer.rb +++ b/lib/postrunner/DailySleepAnalyzer.rb @@ -12,35 +12,83 @@ require 'fit4ruby' +require 'postrunner/SleepCycle' + 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. + # sleep. Determining the sleep state of a person purely based on wrist + # movement data is not very accurate. It gets a lot more accurate when heart + # rate data is available as well. The heart rate describes a sinus-like + # curve that aligns with the sleep cycles. Each sinus cycle corresponds to a + # sleep cycle. Unfortunately, current Garmin devices only use a default + # sampling time of 15 minutes. Since a sleep cycle is broken down into + # various sleep phases that normally last 10 - 15 minutes, there is a fairly + # high margin of error to determine the exact timing of the sleep cycle. + # + # HR High -----+ +-------+ +------+ + # HR Low +---+ +--------+ +--- + # Mov High --+ +-------+ +-----+ +-- + # Mov Low +---------+ +--+ +-----+ + # Phase wk n1 n3 n2 rem n2 n3 n2 rem n2 n3 n2 + # Cycle 1 2 3 + # + # Legend: wk: wake n1: NREM1, n2: NREM2, n3: NREM3, rem: REM sleep + # + # Too frequent or too strong movements abort the cycle to wake. class DailySleepAnalyzer - # Utility class to store the interval of a sleep/wake phase. - class SleepInterval < Struct.new(:from_time, :to_time, :phase) - end + attr_reader :sleep_cycles, :utc_offset, + :total_sleep, :rem_sleep, :deep_sleep, :light_sleep, + :resting_heart_rate - attr_reader :sleep_intervals, :utc_offset, - :total_sleep, :deep_sleep, :light_sleep + TIME_WINDOW_MINUTES = 24 * 60 # 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_intervals = [] + # @param window_offest_secs [Fixnum] Offset (in seconds) of the time + # window to analyze against the midnight of the specified day + def initialize(monitoring_files, day, window_offest_secs) + @window_start_time = @window_end_time = @utc_offset = nil + @sleep_cycles = [] + @sleep_phase = [] + @resting_heart_rate = nil # 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 + extract_data_from_monitor_files(monitoring_files, day_as_time, + window_offest_secs) + + # We must have information about the local time zone the data was + # recorded in. Abort if not available. + return unless @utc_offset + + fill_monitoring_data + categorize_sleep_activity + + if categorize_sleep_heart_rate + # We have usable heart rate data for the sleep periods. Correlating + # wrist motion data with heart rate cycles will greatly improve the + # sleep phase and sleep cycle detection. + categorize_sleep_phase_by_hr_level + @sleep_cycles.each do |c| + # Adjust the cycle boundaries to align with REM phase. + c.adjust_cycle_boundaries(@sleep_phase) + # Detect sleep phases for each cycle. + c.detect_phases(@sleep_phase) + end + else + # We have no usable heart rate data. Just guess sleep phases based on + # wrist motion data. + categorize_sleep_phase_by_activity_level + @sleep_cycles.each { |c| c.detect_phases(@sleep_phase) } + end + dump_data + delete_wake_cycles + determine_resting_heart_rate calculate_totals end @@ -60,123 +108,375 @@ module PostRunner 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) + def extract_data_from_monitor_files(monitoring_files, day, + window_offest_secs) + # The data from the monitoring files is stored in Arrays that cover 36 + # hours at 1 minute resolution. We store the period noon of the previous + # day to midnight the next day for the given day. The algorithm + # currently cannot handle time zone or DST changes. The day is always 24 + # hours and the local time at noon the previous day is used for the + # whole 36 hour period. + @heart_rate = Array.new(TIME_WINDOW_MINUTES, nil) + # The following activity types are known: + # [ :undefined, :running, :cycling, :transition, + # :fitness_equipment, :swimming, :walking, :unknown7, + # :resting, :unknown9 ] + @activity_type = Array.new(TIME_WINDOW_MINUTES, nil) + # The activity values in the FIT files can range from 0 to 7. + @activity_intensity = Array.new(TIME_WINDOW_MINUTES, nil) + monitoring_files.each do |mf| utc_offset = extract_utc_offset(mf) + # Midnight (local time) of the requested day. + midnight_today = day - utc_offset # 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 + window_start_time = midnight_today + window_offest_secs # Noon (local time) of the current day - noon_today = day + 12 * 60 * 60 - utc_offset + window_end_time = window_start_time + TIME_WINDOW_MINUTES * 60 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 + # Ignore all entries outside our 36 hour window from noon the day + # before midnight of the next day. + next if m.timestamp < window_start_time || + m.timestamp >= window_end_time - if @noon_yesterday.nil? && @noon_today.nil? + 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. - @noon_yesterday = noon_yesterday - @noon_today = noon_today + @window_start_time = window_start_time + @window_end_time = window_end_time @utc_offset = utc_offset end + # The index (minutes after noon yesterday) to address all the value + # arrays. + index = (m.timestamp - @window_start_time) / 60 + + # The activity type and intensity are stored in the same FIT field. + # We'll break them into 2 separate values. if (cati = m.current_activity_type_intensity) - activity_type = cati & 0x1F + @activity_type[index] = cati & 0x1F + @activity_intensity[index] = (cati >> 5) & 0x7 + end - # 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 + # Store heart rate data if available. + if m.heart_rate + @heart_rate[index] = m.heart_rate end end end end - def fill_sleep_activity + def fill_monitoring_data + # The FIT files only contain a timestamped entry when new values have + # been measured. The timestamp marks the end of the period where the + # recorded values were current. + # + # We want to have an entry for every minute. So we have to replicate the + # found value for all previous minutes until we find another valid + # entry. current = nil - @sleep_activity = @sleep_activity.reverse.map do |v| - v.nil? ? current : current = v - end.reverse + [ @activity_type, @activity_intensity, @heart_rate ].each do |dataset| + current = nil + # We need to fill back-to-front, so we reverse the array during the + # fill. And reverse it back at the end. + dataset.reverse!.map! do |v| + v.nil? ? current : current = v + end.reverse! + end + end + # Dump all input and intermediate data for the sleep tracking into a CSV + # file if DEBUG mode is enabled. + def dump_data 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}" + File.open('monitoring-data.csv', 'w') do |f| + f.puts 'Date;Activity Type;Activity Level;Weighted Act. Level;' + + 'Heart Rate;Activity Class;Heart Rate Class;Sleep Phase' + 0.upto(TIME_WINDOW_MINUTES - 1) do |i| + at = @activity_type[i] + ai = @activity_intensity[i] + wsa = @weighted_sleep_activity[i] + hr = @heart_rate[i] + sac = @sleep_activity_classification[i] + shc = @sleep_heart_rate_classification[i] + sp = @sleep_phase[i] + f.puts "#{@window_start_time + i * 60};" + + "#{at.is_a?(Fixnum) ? at : ''};" + + "#{ai.is_a?(Fixnum) ? ai : ''};" + + "#{wsa};" + + "#{hr.is_a?(Fixnum) ? hr : ''};" + + "#{sac ? sac.to_s : ''};" + + "#{shc ? shc.to_s : ''};" + + "#{sp.to_s}" 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 + def categorize_sleep_activity + @weighted_sleep_activity = Array.new(TIME_WINDOW_MINUTES, 8) + @sleep_activity_classification = Array.new(TIME_WINDOW_MINUTES, nil) + + delta = 7 + 0.upto(TIME_WINDOW_MINUTES - 1) do |i| + intensity_sum = 0 + weight_sum = 0 + + (i - delta).upto(i + delta) do |j| + next if i < 0 || i >= TIME_WINDOW_MINUTES + + weight = delta - (i - j).abs + intensity_sum += weight * + (@activity_type[j] != 8 ? 8 : @activity_intensity[j]) + weight_sum += weight + end + + # Normalize the weighted intensity sum + @weighted_sleep_activity[i] = + intensity_sum.to_f / weight_sum + + @sleep_activity_classification[i] = + if @weighted_sleep_activity[i] > 2.2 + :wake + elsif @weighted_sleep_activity[i] > 0.5 + :low_activity + else + :no_activity + end + end + end + + # During the nightly sleep the heart rate is alternating between a high + # and a low frequency. The actual frequencies vary so that we need to look + # for the transitions to classify each sample as high or low. + def categorize_sleep_heart_rate + @sleep_heart_rate_classification = Array.new(TIME_WINDOW_MINUTES, nil) + + last_heart_rate = 0 + current_category = :high_hr + last_transition_index = 0 + last_transition_delta = 0 + transitions = 0 + + 0.upto(TIME_WINDOW_MINUTES - 1) do |i| + if @sleep_activity_classification[i] == :wake || + @heart_rate[i].nil? || @heart_rate[i] == 0 + last_heart_rate = 0 + current_category = :high_hr + last_transition_index = i + 1 + last_transition_delta = 0 + next end - @smoothed_sleep_activity[i] = sum / window_size + + if last_heart_rate + if current_category == :high_hr + if last_heart_rate > @heart_rate[i] + # High/low transition found + current_category = :low_hr + transitions += 1 + last_transition_delta = last_heart_rate - @heart_rate[i] + last_transition_index = i + elsif last_heart_rate < @heart_rate[i] && + last_transition_delta < @heart_rate[i] - last_heart_rate + # The previously found high segment was wrongly categorized as + # such. Convert it to low segment. + last_transition_index.upto(i - 1) do |j| + @sleep_heart_rate_classification[j] = :low_hr + end + # Now we are in a high segment. + current_category = :high_hr + last_transition_delta += @heart_rate[i] - last_heart_rate + last_transition_index = i + end + else + if last_heart_rate < @heart_rate[i] + # Low/High transition found. + current_category = :high_hr + transitions += 1 + last_transition_delta = @heart_rate[i] - last_heart_rate + last_transition_index = i + elsif last_heart_rate > @heart_rate[i] && + last_transition_delta < last_heart_rate - @heart_rate[i] + # The previously found low segment was wrongly categorized as + # such. Convert it to high segment. + last_transition_index.upto(i - 1) do |j| + @sleep_heart_rate_classification[j] = :high_hr + end + # Now we are in a low segment. + current_category = :low_hr + last_transition_delta += last_heart_rate - @heart_rate[i] + last_transition_index = i + end + end + @sleep_heart_rate_classification[i] = current_category + end + + last_heart_rate = @heart_rate[i] 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}" + # We consider the HR transition data good enough if we have found at + # least 3 transitions. + transitions > 3 + end + + def categorize_sleep_phase_by_hr_level + @sleep_phase = Array.new(TIME_WINDOW_MINUTES, :wake) + + rem_possible = false + current_hr_phase = nil + cycle = nil + + 0.upto(TIME_WINDOW_MINUTES - 1) do |i| + sac = @sleep_activity_classification[i] + hrc = @sleep_heart_rate_classification[i] + + if hrc != current_hr_phase + if current_hr_phase.nil? + if hrc == :high_hr + # Wake/High transition. + rem_possible = false + else + # Wake/Low transition. Should be very uncommon. + rem_possible = true + end + cycle = SleepCycle.new(@window_start_time, i) + elsif current_hr_phase == :high_hr + rem_possible = false + if hrc.nil? + # High/Wake transition. Wakeing up from light sleep. + if cycle + cycle.end_idx = i - 1 + @sleep_cycles << cycle + cycle = nil + end + else + # High/Low transition. Going into deep sleep + if cycle + # A high/low transition completes the cycle if we already have + # a low/high transition for this cycle. The actual end + # should be the end of the REM phase, but we have to correct + # this and the start of the new cycle later. + cycle.high_low_trans_idx = i + if cycle.low_high_trans_idx + cycle.end_idx = i - 1 + @sleep_cycles << cycle + cycle = SleepCycle.new(@window_start_time, i, cycle) + end + end + end + else + if hrc.nil? + # Low/Wake transition. Waking up from deep sleep. + rem_possible = false + if cycle + cycle.end_idx = i - 1 + @sleep_cycles << cycle + cycle = nil + end + else + # Low/High transition. REM phase possible + rem_possible = true + cycle.low_high_trans_idx = i if cycle + end end end + current_hr_phase = hrc + + next unless hrc && sac + + @sleep_phase[i] = + if hrc == :high_hr + if sac == :no_activity + :nrem1 + else + rem_possible ? :rem : :nrem1 + end + else + if sac == :no_activity + :nrem3 + else + :nrem2 + end + end end end - def analyze - current_phase = :awake - current_phase_start = @noon_yesterday - @sleep_intervals = [] - - @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 + def delete_wake_cycles + wake_cycles = [] + @sleep_cycles.each { |c| wake_cycles << c if c.is_wake_cycle? } + + wake_cycles.each { |c| c.unlink } + @sleep_cycles.delete_if { |c| wake_cycles.include?(c) } + end + + def categorize_sleep_phase_by_activity_level + @sleep_phase = [] + mappings = { :wake => :wake, :low_activity => :nrem1, + :no_activity => :nrem3 } + + current_cycle_start = nil + current_phase = @sleep_activity_classification[0] + current_phase_start = 0 + + 0.upto(TIME_WINDOW_MINUTES - 1) do |idx| + # Without HR data, we need to use other threshold values to determine + # the activity classification. Hence we do it again here. + @sleep_activity_classification[idx] = sac = + if @weighted_sleep_activity[idx] > 2.2 + :wake + elsif @weighted_sleep_activity[idx] > 0.01 + :low_activity + else + :no_activity + end + + @sleep_phase << mappings[sac] + + # Sleep cycles start at wake/non-wake transistions. + if current_cycle_start.nil? && sac != :wake + current_cycle_start = idx end - if current_phase != phase - t = @noon_yesterday + 60 * idx - @sleep_intervals << SleepInterval.new(current_phase_start, t, - current_phase) - current_phase = phase - current_phase_start = t + if current_phase != sac || idx >= TIME_WINDOW_MINUTES + # We have detected the end of a phase. + if (current_phase == :no_activity || sac == :wake) && + current_cycle_start + # The end of the :no_activity phase marks the end of a sleep cycle. + @sleep_cycles << (cycle = SleepCycle.new(@window_start_time, + current_cycle_start, + @sleep_cycles.last)) + cycle.end_idx = idx + current_cycle_start = nil + end + + current_phase = sac + current_phase_start = idx end end - @sleep_intervals << SleepInterval.new(current_phase_start, @noon_today, - current_phase) end + def determine_resting_heart_rate + # Find the smallest heart rate. TODO: While being awake. + @heart_rate.each_with_index do |heart_rate, idx| + next unless heart_rate + if @resting_heart_rate.nil? || + (@resting_heart_rate > heart_rate && heart_rate > 0) + @resting_heart_rate = heart_rate + end + end + end + + def trim_wake_periods_at_ends first_deep_sleep_idx = last_deep_sleep_idx = nil - @sleep_intervals.each_with_index do |p, idx| - if p.phase == :deep_sleep || - (p.phase == :light_sleep && ((p.to_time - p.from_time) > 15 * 60)) + @sleep_phase.each_with_index do |p, idx| + if p.phase == :nrem3 first_deep_sleep_idx = idx unless first_deep_sleep_idx last_deep_sleep_idx = idx end @@ -184,31 +484,27 @@ module PostRunner return unless first_deep_sleep_idx && last_deep_sleep_idx - if first_deep_sleep_idx > 0 && - @sleep_intervals[first_deep_sleep_idx - 1].phase == :light_sleep - first_deep_sleep_idx -= 1 + while first_deep_sleep_idx > 0 && + @sleep_phase[first_deep_sleep_idx - 1].phase != :wake do + first_deep_sleep_idx -= 1 end - if last_deep_sleep_idx < @sleep_intervals.length - 2 && - @sleep_intervals[last_deep_sleep_idx + 1].phase == :light_sleep + while last_deep_sleep_idx < @sleep_phase.length - 1 && + @sleep_phase[last_deep_sleep_idx + 1].phase != :wake do last_deep_sleep_idx += 1 end - @sleep_intervals = - @sleep_intervals[first_deep_sleep_idx..last_deep_sleep_idx] + @sleep_phase = + @sleep_phase[first_deep_sleep_idx..last_deep_sleep_idx] end def calculate_totals - @total_sleep = @light_sleep = @deep_sleep = 0 - @sleep_intervals.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 + @total_sleep = @light_sleep = @deep_sleep = @rem_sleep = 0 + + @sleep_cycles.each do |p| + @total_sleep += p.total_seconds.values.inject(0, :+) + @light_sleep += p.total_seconds[:nrem1] + p.total_seconds[:nrem2] + @deep_sleep += p.total_seconds[:nrem3] + @rem_sleep += p.total_seconds[:rem] end end diff --git a/lib/postrunner/FFS_Monitoring.rb b/lib/postrunner/FFS_Monitoring.rb index a5c52da..ce518c0 100644 --- a/lib/postrunner/FFS_Monitoring.rb +++ b/lib/postrunner/FFS_Monitoring.rb @@ -74,8 +74,6 @@ module PostRunner 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) diff --git a/lib/postrunner/FitFileStore.rb b/lib/postrunner/FitFileStore.rb index 07a1335..d7933e1 100644 --- a/lib/postrunner/FitFileStore.rb +++ b/lib/postrunner/FitFileStore.rb @@ -334,7 +334,7 @@ module PostRunner monitorings += device.monitorings(day_as_time - 36 * 60 * 60, day_as_time + 36 * 60 * 60) end - monitoring_files = monitorings.map do |m| + monitoring_files = monitorings.reverse.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 @@ -357,7 +357,7 @@ module PostRunner monitorings += device.monitorings(day_as_time - 36 * 60 * 60, day_as_time + 33 * 24 * 60 * 60) end - monitoring_files = monitorings.map do |m| + monitoring_files = monitorings.sort.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 diff --git a/lib/postrunner/FlexiTable.rb b/lib/postrunner/FlexiTable.rb index 8113cc1..39f3b9a 100644 --- a/lib/postrunner/FlexiTable.rb +++ b/lib/postrunner/FlexiTable.rb @@ -3,7 +3,7 @@ # # = FlexiTable.rb -- PostRunner - Manage the data from your Garmin sport devices. # -# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org> +# Copyright (c) 2014, 2015, 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 @@ -191,6 +191,11 @@ module PostRunner end def new_row + if @current_row && @head_rows[0] && + @current_row.length != @head_rows[0].length + Log.fatal "Row has #{@current_row.length} cells instead of " + + "#{@head_rows[0].length} cells in head row." + end @current_row = nil end diff --git a/lib/postrunner/Main.rb b/lib/postrunner/Main.rb index 6754582..5db7b1d 100644 --- a/lib/postrunner/Main.rb +++ b/lib/postrunner/Main.rb @@ -296,6 +296,7 @@ EOT case (cmd = args.shift) when 'check' if args.empty? + @db.check(true) @ffs.check Log.info "Datebase cleanup started. Please wait ..." @db.gc diff --git a/lib/postrunner/SleepCycle.rb b/lib/postrunner/SleepCycle.rb new file mode 100644 index 0000000..d579258 --- /dev/null +++ b/lib/postrunner/SleepCycle.rb @@ -0,0 +1,198 @@ +#!/usr/bin/env ruby -w +# encoding: UTF-8 +# +# = SleepCycle.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. +# + +module PostRunner + + # A SleepPhase is a segment of a sleep cycle. It captures the start and + # end time as well as the kind of phase. + class SleepPhase + + attr_reader :from_time, :to_time, :phase + + # Create a new sleep phase. + # @param from_time [Time] Start time of the phase + # @param to_time [Time] End time of the phase + # @param phase [Symbol] The kind of phase [ :rem, :nrem1, :nrem2, :nrem3 ] + def initialize(from_time, to_time, phase) + @from_time = from_time + @to_time = to_time + @phase = phase + end + + # Duration of the phase in seconds. + # @return [Fixnum] duration + def duration + @to_time - @from_time + end + + end + + # A sleep cycle consists of several sleep phases. This class is used to + # gather and store the relevant data of a sleep cycle. Data is analzyed and + # stored with a one minute granularity. Time values are stored as minutes + # past the zero_idx_time. + class SleepCycle + + attr_reader :total_seconds, :totals + attr_accessor :start_idx, :end_idx, + :high_low_trans_idx, :low_high_trans_idx, + :prev_cycle, :next_cycle + + # Create a new SleepCycle record. + # @param zero_idx_time [Time] This is the time of the 0-th minute. All + # time values are stored as minutes past this time. + # @param start_idx [Fixnum] Time when the sleep cycle starts. We may start + # with an appromated value that gets fine tuned later on. + # @param prev_cycle [SleepCycle] A reference to the preceding sleep cycle + # or nil if this is the first cycle of the analyzed period. + def initialize(zero_idx_time, start_idx, prev_cycle = nil) + @zero_idx_time = zero_idx_time + @start_idx = start_idx + # These values will be determined later. + @end_idx = nil + # Every sleep cycle has at most one high/low heart rate transition and + # one low/high transition. These variables store the time of these + # transitions or nil if the transition does not exist. Every cycle must + # have at least one of these transitions to be a valid cycle. + @high_low_trans_idx = @low_high_trans_idx = nil + @prev_cycle = prev_cycle + # Register this cycle as successor of the previous cycle. + prev_cycle.next_cycle = self if prev_cycle + @next_cycle = nil + # Array holding the sleep phases of this cycle + @phases = [] + # A hash with the total durations (in secods) of the various sleep + # phases. + @total_seconds = Hash.new(0) + end + + # The start time of the cycle as Time object + # @return [Time] + def from_time + idx_to_time(@start_idx) + end + + # The end time of the cycle as Time object. + # @return [Time] + def to_time + idx_to_time(@end_idx + 1) + end + + # Remove this cycle from the cycle chain. + def unlink + @prev_cycle.next_cycle = @next_cycle if @prev_cycle + @next_cycle.prev_cycle = @prev_cycle if @next_cycle + end + + # Initially, we use the high/low heart rate transition to mark the end + # of the cycle. But it's really the end of the REM phase that marks the + # end of a sleep cycle. If we find a REM phase, we use its end to adjust + # the sleep cycle boundaries. + # @param phases [Array] List of symbols that describe the sleep phase at + # at the minute corresponding to the Array index. + def adjust_cycle_boundaries(phases) + end_of_rem_phase_idx = nil + @start_idx.upto(@end_idx) do |i| + end_of_rem_phase_idx = i if phases[i] == :rem + end + if end_of_rem_phase_idx + # We have found a REM phase. Adjust the end_idx of this cycle + # accordingly. + @end_idx = end_of_rem_phase_idx + if @next_cycle + # If we have a successor phase, we also adjust the start. + @next_cycle.start_idx = end_of_rem_phase_idx + 1 + end + end + end + + # Gather a list of SleepPhase objects that describe the sequence of sleep + # phases in the provided Array. + # @param phases [Array] List of symbols that describe the sleep phase at + # at the minute corresponding to the Array index. + def detect_phases(phases) + @phases = [] + current_phase = phases[0] + current_phase_start = @start_idx + + @start_idx.upto(@end_idx) do |i| + if (current_phase && current_phase != phases[i]) || i == @end_idx + # We found a transition in the sequence. Create a SleepPhase object + # that describes the prepvious segment and add it to the @phases + # list. + @phases << (p = SleepPhase.new(idx_to_time(current_phase_start), + idx_to_time(i == @end_idx ? i + 1 : i), + current_phase)) + # Add the duration of the phase to the corresponding sum in the + # @total_seconds Hash. + @total_seconds[current_phase] += p.duration + + # Update the variables that track the start and kind of the + # currently read phase. + current_phase_start = i + current_phase = phases[i] + end + end + end + + # Check if this cycle is really a sleep cycle or not. A sleep cycle must + # have at least one deep sleep phase or must be part of a directly + # attached series of cycles that contain a deep sleep phase. + # @return [Boolean] True if not a sleep cycle, false otherwise. + def is_wake_cycle? + !has_deep_sleep_phase? && !has_leading_deep_sleep_phase? && + !has_trailing_deep_sleep_phase? + end + + + # Check if the cycle has a deep sleep phase. + # @return [Boolean] True of one of the phases is NREM3 phase. False + # otherwise. + def has_deep_sleep_phase? + # A real deep sleep phase must be at least 10 minutes long. + @phases.each do |p| + return true if p.phase == :nrem3 && p.duration > 10 * 60 + end + + false + end + + # Check if any of the previous cycles that are directly attached have a + # deep sleep cycle. + # @return [Boolean] True if it has a leading sleep cycle. + def has_leading_deep_sleep_phase? + return false if @prev_cycle.nil? || @start_idx != @prev_cycle.end_idx + 1 + + @prev_cycle.has_deep_sleep_phase? || + @prev_cycle.has_leading_deep_sleep_phase? + end + + # Check if any of the trailing cycles that are directly attached have a + # deep sleep cycle. + # @return [Boolean] True if it has a trailing sleep cycle. + def has_trailing_deep_sleep_phase? + return false if @next_cycle.nil? || @end_idx + 1 != @next_cycle.start_idx + + @next_cycle.has_deep_sleep_phase? || + @next_cycle.has_trailing_deep_sleep_phase? + end + + private + + def idx_to_time(idx) + return nil unless idx + @zero_idx_time + 60 * idx + end + + end + +end diff --git a/lib/postrunner/SleepStatistics.rb b/lib/postrunner/SleepStatistics.rb index 0825ab9..16de544 100644 --- a/lib/postrunner/SleepStatistics.rb +++ b/lib/postrunner/SleepStatistics.rb @@ -33,34 +33,14 @@ module PostRunner # Generate a report for a certain day. # @param day [String] Date of the day as YYYY-MM-DD string. def daily(day) - analyzer = DailySleepAnalyzer.new(@monitoring_files, day) + analyzer = DailySleepAnalyzer.new(@monitoring_files, day, -12 * 60 * 60) - if analyzer.sleep_intervals.empty? + if analyzer.sleep_cycles.empty? return 'No sleep data available for this day' end - ti = FlexiTable.new - ti.head - ti.row([ 'From', 'To', 'Sleep phase' ]) - ti.body - utc_offset = analyzer.utc_offset - analyzer.sleep_intervals.each do |i| - ti.cell(i[:from_time].localtime(utc_offset).strftime('%H:%M')) - ti.cell(i[:to_time].localtime(utc_offset).strftime('%H:%M')) - ti.cell(i[:phase]) - ti.new_row - end - - tt = FlexiTable.new - tt.head - tt.row([ 'Total Sleep', 'Deep Sleep', 'Light Sleep' ]) - tt.body - tt.cell(secsToHM(analyzer.total_sleep), { :halign => :right }) - tt.cell(secsToHM(analyzer.deep_sleep), { :halign => :right }) - tt.cell(secsToHM(analyzer.light_sleep), { :halign => :right }) - tt.new_row - - "Sleep Statistics for #{day}\n\n#{ti}\n#{tt}" + "Sleep Statistics for #{day}\n\n" + + daily_sleep_cycle_table(analyzer).to_s end def monthly(day) @@ -72,45 +52,135 @@ module PostRunner t = FlexiTable.new left = { :halign => :left } right = { :halign => :right } - t.set_column_attributes([ left, right, right, right ]) + t.set_column_attributes([ left, right, right, right, right, right ]) t.head - t.row([ 'Date', 'Total Sleep', 'Deep Sleep', 'Light Sleep' ]) + t.row([ 'Date', 'Total Sleep', 'REM Sleep', 'Deep Sleep', + 'Light Sleep', 'RHR' ]) t.body totals = Hash.new(0) counted_days = 0 + rhr_days = 0 1.upto(last_day_of_month).each do |dom| day_str = Time.new(year, month, dom).strftime('%Y-%m-%d') t.cell(day_str) - analyzer = DailySleepAnalyzer.new(@monitoring_files, day_str) + analyzer = DailySleepAnalyzer.new(@monitoring_files, day_str, + -12 * 60 * 60) - if analyzer.sleep_intervals.empty? - t.cell('-') - t.cell('-') - t.cell('-') + if (analyzer.sleep_cycles.empty?) + 4.times { t.cell('-') } else totals[:total_sleep] += analyzer.total_sleep + totals[:rem_sleep] += analyzer.rem_sleep totals[:deep_sleep] += analyzer.deep_sleep totals[:light_sleep] += analyzer.light_sleep counted_days += 1 t.cell(secsToHM(analyzer.total_sleep)) + t.cell(secsToHM(analyzer.rem_sleep)) t.cell(secsToHM(analyzer.deep_sleep)) t.cell(secsToHM(analyzer.light_sleep)) end + + if (rhr = analyzer.resting_heart_rate) && rhr > 0 + t.cell(rhr) + totals[:rhr] += rhr + rhr_days += 1 + else + t.cell('-') + end t.new_row end t.foot t.cell('Averages') - t.cell(secsToHM(totals[:total_sleep] / counted_days)) - t.cell(secsToHM(totals[:deep_sleep] / counted_days)) - t.cell(secsToHM(totals[:light_sleep] / counted_days)) + if counted_days > 0 + t.cell(secsToHM(totals[:total_sleep] / counted_days)) + t.cell(secsToHM(totals[:rem_sleep] / counted_days)) + t.cell(secsToHM(totals[:deep_sleep] / counted_days)) + t.cell(secsToHM(totals[:light_sleep] / counted_days)) + else + 3.times { t.cell('-') } + end + if rhr_days > 0 + t.cell('%.1f' % (totals[:rhr] / rhr_days)) + else + t.cell('-') + end t.new_row "Sleep Statistics for #{day_as_time.strftime('%B')} #{year}\n\n#{t}" end + private + + def cell_right_aligned(table, text) + table.cell(text, { :halign => :right }) + end + + def time_as_hm(t, utc_offset) + t.localtime(utc_offset).strftime('%H:%M') + end + + def daily_sleep_cycle_table(analyzer) + ti = FlexiTable.new + ti.head + ti.row([ 'Cycle', 'From', 'To', 'Duration', 'REM Sleep', + 'Light Sleep', 'Deep Sleep']) + ti.body + utc_offset = analyzer.utc_offset + format = { :halign => :right } + totals = Hash.new(0) + last_to_time = nil + analyzer.sleep_cycles.each_with_index do |c, idx| + if last_to_time && c.from_time > last_to_time + # We have a gap in the sleep cycles. + ti.cell('Wake') + cell_right_aligned(ti, time_as_hm(last_to_time, utc_offset)) + cell_right_aligned(ti, time_as_hm(c.from_time, utc_offset)) + cell_right_aligned(ti, "(#{secsToHM(c.from_time - last_to_time)})") + ti.cell('') + ti.cell('') + ti.cell('') + ti.new_row + end + + ti.cell((idx + 1).to_s, format) + ti.cell(c.from_time.localtime(utc_offset).strftime('%H:%M'), format) + ti.cell(c.to_time.localtime(utc_offset).strftime('%H:%M'), format) + + duration = c.to_time - c.from_time + totals[:duration] += duration + ti.cell(secsToHM(duration), format) + + totals[:rem] += c.total_seconds[:rem] + ti.cell(secsToHM(c.total_seconds[:rem]), format) + + light_sleep = c.total_seconds[:nrem1] + c.total_seconds[:nrem2] + totals[:light_sleep] += light_sleep + ti.cell(secsToHM(light_sleep), format) + + totals[:deep_sleep] += c.total_seconds[:nrem3] + ti.cell(secsToHM(c.total_seconds[:nrem3]), format) + + ti.new_row + last_to_time = c.to_time + end + ti.foot + ti.cell('Totals') + ti.cell(analyzer.sleep_cycles[0].from_time.localtime(utc_offset). + strftime('%H:%M'), format) + ti.cell(analyzer.sleep_cycles[-1].to_time.localtime(utc_offset). + strftime('%H:%M'), format) + ti.cell(secsToHM(totals[:duration]), format) + ti.cell(secsToHM(totals[:rem]), format) + ti.cell(secsToHM(totals[:light_sleep]), format) + ti.cell(secsToHM(totals[:deep_sleep]), format) + ti.new_row + + ti + end + end end |