postrunner 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ea10a2814c3c25363117c69d85a1f5e0d4c07c2c
4
- data.tar.gz: f0c72064a97183bfee97978e11a534bd9c2d5c5e
3
+ metadata.gz: c470dc3a2d1c211ae2c294e98392d9a2621b2ade
4
+ data.tar.gz: 201bf757b216cb48ab8bf4cf9ace6fae602b7730
5
5
  SHA512:
6
- metadata.gz: 644edbd1db0ad2fe562e74a2d09b61ad2664942b1b0aaf31647a96d948cb9c2eb5951fd79a07302d4d2099215f76189b20524071fdd5a1b5d46d340dad851c8a
7
- data.tar.gz: b2d3a31c489a3bc506c4d3d809abc35f4eb059edcf94fee1a8b630b52dbdc3e9e580b94825209123890ca670dc6999f28074a1e618c05342935d14a7abd4c167
6
+ metadata.gz: 2e9a5edc603c22081b74c538f5249943eabc266bb9eb54083c57e7664f948df27f9881a83f47d2db28862360c614e81cd2139b9775819ce46c7321b1d59a104e
7
+ data.tar.gz: 9bcd6103ac667230b1309b1bf31bcdda583ba651d016dee32510786faa8da52d13f4d06daf8c069f0ff4bdfb3d0563a1a324f865f55e80ef82ede97bbc291b20
data/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  PostRunner is an application to manage FIT files such as those
4
4
  produced by Garmin products like the Forerunner 620 (FR620) and Fenix
5
- 3. It allows you to import the files from the device and inspect them.
6
- In addition to the common features like plotting pace, heart rates,
7
- elevation and other captured values it also provides a heart rate
8
- variability (HRV) analysis. It can also update satellite orbit prediction
9
- (EPO) data on the device to speed-up fix times. It is an offline
10
- alternative to Garmin Connect.
5
+ 3 or Fenix 3HR. It allows you to import the files from the device and
6
+ analyze the data. In addition to the common features like plotting pace,
7
+ heart rates, elevation and other captured values it also provides a
8
+ heart rate variability (HRV) analysis. It can also update satellite
9
+ orbit prediction (EPO) data on the device to speed-up GPS fix times.
10
+ It is an offline alternative to Garmin Connect. The software has been
11
+ developed and tested on Linux but should work on other operating
12
+ systems as well.
11
13
 
12
14
  ## Installation
13
15
 
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = DailyMonitoringAnalzyer.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2016 by Chris Schlaeger <cs@taskjuggler.org>
7
+ #
8
+ # This program is free software; you can redistribute it and/or modify
9
+ # it under the terms of version 2 of the GNU General Public License as
10
+ # published by the Free Software Foundation.
11
+ #
12
+
13
+ require 'fit4ruby'
14
+
15
+ module PostRunner
16
+
17
+ class DailyMonitoringAnalyzer
18
+
19
+ attr_reader :window_start_time, :window_end_time
20
+
21
+ class MonitoringSample
22
+
23
+ attr_reader :timestamp, :activity_type, :cycles, :steps,
24
+ :floors_climbed, :floors_descended, :distance,
25
+ :active_calories, :weekly_moderate_activity_minutes,
26
+ :weekly_vigorous_activity_minutes
27
+
28
+ def initialize(m)
29
+ @timestamp = m.timestamp
30
+ types = [
31
+ 'generic', 'running', 'cycling', 'transition',
32
+ 'fitness_equipment', 'swimming', 'walking', 'unknown7',
33
+ 'resting', 'unknown9'
34
+ ]
35
+ if (cati = m.current_activity_type_intensity)
36
+ @activity_type = types[cati & 0x1F]
37
+ @activity_intensity = (cati >> 5) & 0x7
38
+ else
39
+ @activity_type = m.activity_type
40
+ end
41
+ @active_time = m.active_time
42
+ @active_calories = m.active_calories
43
+ @ascent = m.ascent
44
+ @descent = m.descent
45
+ @floors_climbed = m.floors_climbed
46
+ @floors_descended = m.floors_descended
47
+ @cycles = m.cycles
48
+ @distance = m.distance
49
+ @duration_min = m.duration_min
50
+ @heart_rate = m.heart_rate
51
+ @steps = m.steps
52
+ @weekly_moderate_activity_minutes = m.weekly_moderate_activity_minutes
53
+ @weekly_vigorous_activity_minutes = m.weekly_vigorous_activity_minutes
54
+ end
55
+
56
+ end
57
+
58
+ def initialize(monitoring_files, day)
59
+ # Day as Time object. Midnight UTC.
60
+ day_as_time = Time.parse(day + "-00:00:00+00:00").gmtime
61
+
62
+ @samples = []
63
+ extract_data_from_monitor_files(monitoring_files, day_as_time)
64
+
65
+ # We must have information about the local time zone the data was
66
+ # recorded in. Abort if not available.
67
+ return unless @utc_offset
68
+ end
69
+
70
+ def total_distance
71
+ distance = 0.0
72
+ @samples.each do |s|
73
+ if s.distance && s.distance > distance
74
+ distance = s.distance
75
+ end
76
+ end
77
+
78
+ distance
79
+ end
80
+
81
+ def total_floors
82
+ floors_climbed = floors_descended = 0.0
83
+
84
+ @samples.each do |s|
85
+ if s.floors_climbed && s.floors_climbed > floors_climbed
86
+ floors_climbed = s.floors_climbed
87
+ end
88
+ if s.floors_descended && s.floors_descended > floors_descended
89
+ floors_descended = s.floors_descended
90
+ end
91
+ end
92
+
93
+ { :floors_climbed => (floors_climbed / 3.048).floor,
94
+ :floors_descended => (floors_descended / 3.048).floor }
95
+ end
96
+
97
+ def steps_distance_calories
98
+ total_cycles = Hash.new(0.0)
99
+ total_distance = Hash.new(0.0)
100
+ total_calories = Hash.new(0.0)
101
+
102
+ @samples.each do |s|
103
+ at = s.activity_type
104
+ if s.cycles && s.cycles > total_cycles[at]
105
+ total_cycles[at] = s.cycles
106
+ end
107
+ if s.distance && s.distance > total_distance[at]
108
+ total_distance[at] = s.distance
109
+ end
110
+ if s.active_calories && s.active_calories > total_calories[at]
111
+ total_calories[at] = s.active_calories
112
+ end
113
+ end
114
+
115
+ distance = calories = 0.0
116
+ if @monitoring_info
117
+ if @monitoring_info.activity_type &&
118
+ @monitoring_info.cycles_to_distance &&
119
+ @monitoring_info.cycles_to_calories
120
+ walking_cycles_to_distance = running_cycles_to_distance = nil
121
+ walking_cycles_to_calories = running_cycles_to_calories = nil
122
+
123
+ @monitoring_info.activity_type.each_with_index do |at, idx|
124
+ if at == 'walking'
125
+ walking_cycles_to_distance =
126
+ @monitoring_info.cycles_to_distance[idx]
127
+ walking_cycles_to_calories =
128
+ @monitoring_info.cycles_to_calories[idx]
129
+ elsif at == 'running'
130
+ running_cycles_to_distance =
131
+ @monitoring_info.cycles_to_distance[idx]
132
+ running_cycles_to_calories =
133
+ @monitoring_info.cycles_to_calories[idx]
134
+ end
135
+ end
136
+ distance = total_distance.values.inject(0.0, :+)
137
+ calories = total_calories.values.inject(0.0, :+) +
138
+ @monitoring_info.resting_metabolic_rate
139
+ end
140
+ end
141
+
142
+ { :steps => ((total_cycles['walking'] + total_cycles['running']) * 2 +
143
+ total_cycles['generic']).to_i,
144
+ :distance => distance, :calories => calories }
145
+ end
146
+
147
+ def intensity_minutes
148
+ moderate_minutes = vigorous_minutes = 0.0
149
+ @samples.each do |s|
150
+ if s.weekly_moderate_activity_minutes &&
151
+ s.weekly_moderate_activity_minutes > moderate_minutes
152
+ moderate_minutes = s.weekly_moderate_activity_minutes
153
+ end
154
+ if s.weekly_vigorous_activity_minutes &&
155
+ s.weekly_vigorous_activity_minutes > vigorous_minutes
156
+ vigorous_minutes = s.weekly_vigorous_activity_minutes
157
+ end
158
+ end
159
+
160
+ { :moderate_minutes => moderate_minutes,
161
+ :vigorous_minutes => vigorous_minutes }
162
+ end
163
+
164
+ def steps_goal
165
+ if @monitoring_info && @monitoring_info.goal_cycles &&
166
+ @monitoring_info.goal_cycles[0]
167
+ @monitoring_info.goal_cycles[0]
168
+ else
169
+ 0
170
+ end
171
+ end
172
+
173
+ def samples
174
+ @samples.length
175
+ end
176
+
177
+ private
178
+
179
+ def get_monitoring_info(monitoring_file)
180
+ # The monitoring files have a monitoring_info section that contains a
181
+ # timestamp in UTC and a local_time field for the same time in the local
182
+ # time. If any of that isn't present, we use an offset of 0.
183
+ if (mis = monitoring_file.monitoring_infos).nil? || mis.empty? ||
184
+ (mi = mis[0]).nil? || mi.local_time.nil? || mi.timestamp.nil?
185
+ return nil
186
+ end
187
+
188
+ mi
189
+ end
190
+
191
+ # Load monitoring data from monitoring_b FIT files into Arrays.
192
+ # @param monitoring_files [Array of Monitoring_B] FIT files to read
193
+ # @param day [Time] Midnight UTC of the day to analyze
194
+ def extract_data_from_monitor_files(monitoring_files, day)
195
+ monitoring_files.each do |mf|
196
+ next unless (mi = get_monitoring_info(mf))
197
+
198
+ utc_offset = mi.local_time - mi.timestamp
199
+ # Midnight (local time) of the requested day.
200
+ window_start_time = day - utc_offset
201
+ # Midnight (local time) of the next day
202
+ window_end_time = window_start_time + 24 * 60 * 60
203
+
204
+ # Ignore all files with data prior to the potential time window.
205
+ next if mf.monitorings.empty? ||
206
+ mf.monitorings.last.timestamp < window_start_time
207
+
208
+ if @utc_offset.nil?
209
+ # The instance variables will only be set once we have found our
210
+ # first monitoring file that matches the requested day. We use the
211
+ # local time setting for this first file even if it changes in
212
+ # subsequent files.
213
+ @window_start_time = window_start_time
214
+ @window_end_time = window_end_time
215
+ @utc_offset = utc_offset
216
+ end
217
+
218
+ if @monitoring_info.nil? && @window_start_time <= mi.local_time &&
219
+ mi.local_time < @window_end_time
220
+ @monitoring_info = mi
221
+ end
222
+
223
+ mf.monitorings.each do |m|
224
+ # Ignore all entries outside our time window. It's important to note
225
+ # that records with a midnight timestamp contain totals from the day
226
+ # before.
227
+ next if m.timestamp <= @window_start_time ||
228
+ m.timestamp > @window_end_time
229
+
230
+ @samples << MonitoringSample.new(m)
231
+ end
232
+ end
233
+
234
+ end
235
+
236
+ end
237
+
238
+ end
239
+
@@ -12,206 +12,499 @@
12
12
 
13
13
  require 'fit4ruby'
14
14
 
15
+ require 'postrunner/SleepCycle'
16
+
15
17
  module PostRunner
16
18
 
17
19
  # This class extracts the sleep information from a set of monitoring files
18
20
  # and determines when and how long the user was awake or had a light or deep
19
- # sleep.
21
+ # sleep. Determining the sleep state of a person purely based on wrist
22
+ # movement data is not very accurate. It gets a lot more accurate when heart
23
+ # rate data is available as well. The heart rate describes a sinus-like
24
+ # curve that aligns with the sleep cycles. Each sinus cycle corresponds to a
25
+ # sleep cycle. Unfortunately, current Garmin devices only use a default
26
+ # sampling time of 15 minutes. Since a sleep cycle is broken down into
27
+ # various sleep phases that normally last 10 - 15 minutes, there is a fairly
28
+ # high margin of error to determine the exact timing of the sleep cycle.
29
+ #
30
+ # HR High -----+ +-------+ +------+
31
+ # HR Low +---+ +--------+ +---
32
+ # Mov High --+ +-------+ +-----+ +--
33
+ # Mov Low +---------+ +--+ +-----+
34
+ # Phase wk n1 n3 n2 rem n2 n3 n2 rem n2 n3 n2
35
+ # Cycle 1 2 3
36
+ #
37
+ # Legend: wk: wake n1: NREM1, n2: NREM2, n3: NREM3, rem: REM sleep
38
+ #
39
+ # Too frequent or too strong movements abort the cycle to wake.
20
40
  class DailySleepAnalyzer
21
41
 
22
- # Utility class to store the interval of a sleep/wake phase.
23
- class SleepInterval < Struct.new(:from_time, :to_time, :phase)
24
- end
42
+ attr_reader :sleep_cycles, :utc_offset,
43
+ :total_sleep, :rem_sleep, :deep_sleep, :light_sleep,
44
+ :resting_heart_rate, :window_start_time, :window_end_time
25
45
 
26
- attr_reader :sleep_intervals, :utc_offset,
27
- :total_sleep, :deep_sleep, :light_sleep
46
+ TIME_WINDOW_MINUTES = 24 * 60
28
47
 
29
48
  # Create a new DailySleepAnalyzer object to analyze the given monitoring
30
49
  # files.
31
50
  # @param monitoring_files [Array] A set of Monitoring_B objects
32
51
  # @param day [String] Day to analyze as YY-MM-DD string
33
- def initialize(monitoring_files, day)
34
- @noon_yesterday = @noon_today = @utc_offset = nil
35
- @sleep_intervals = []
52
+ # @param window_offest_secs [Fixnum] Offset (in seconds) of the time
53
+ # window to analyze against the midnight of the specified day
54
+ def initialize(monitoring_files, day, window_offest_secs)
55
+ @window_start_time = @window_end_time = @utc_offset = nil
56
+
57
+ # The following activity types are known:
58
+ # [ :undefined, :running, :cycling, :transition,
59
+ # :fitness_equipment, :swimming, :walking, :unknown7,
60
+ # :resting, :unknown9 ]
61
+ @activity_type = Array.new(TIME_WINDOW_MINUTES, nil)
62
+ # The activity values in the FIT files can range from 0 to 7.
63
+ @activity_intensity = Array.new(TIME_WINDOW_MINUTES, nil)
64
+ # Wrist motion data is not very well suited to determine wake or sleep
65
+ # states. A single movement can be a turning motion, a NREM1 jerk or
66
+ # even a movement while you dream. The fewer motions are detected, the
67
+ # more likely you are really asleep. To even out single spikes, we
68
+ # average the motions over a period of time. This Array stores the
69
+ # weighted activity.
70
+ @weighted_sleep_activity = Array.new(TIME_WINDOW_MINUTES, 8)
71
+ # We classify the sleep activity into :wake, :low_activity and
72
+ # :no_activity in this Array.
73
+ @sleep_activity_classification = Array.new(TIME_WINDOW_MINUTES, nil)
74
+
75
+ # The data from the monitoring files is stored in Arrays that cover 24
76
+ # hours at 1 minute resolution. The algorithm currently cannot handle
77
+ # time zone or DST changes. The day is always 24 hours and the local
78
+ # time at noon the previous day is used for the whole window.
79
+ @heart_rate = Array.new(TIME_WINDOW_MINUTES, nil)
80
+ # From the wrist motion data and if available from the heart rate data,
81
+ # we try to guess the sleep phase (:wake, :rem, :nrem1, :nrem2, :nrem3).
82
+ # This Array will hold a minute-by-minute list of the guessed sleep
83
+ # phase.
84
+ @sleep_phase = Array.new(TIME_WINDOW_MINUTES, :wake)
85
+ # The DailySleepAnalzyer extracts the sleep cycles from the monitoring
86
+ # data. Each night usually has 5 - 6 sleep cycles. If we have heart rate
87
+ # data, those cycles can be identified fairly well. If we have to rely
88
+ # on wrist motion data only, we usually find more cycles than there
89
+ # actually were. Each cycle is captured as SleepCycle object.
90
+ @sleep_cycles = []
91
+ # The resting heart rate.
92
+ @resting_heart_rate = nil
36
93
 
37
94
  # Day as Time object. Midnight UTC.
38
95
  day_as_time = Time.parse(day + "-00:00:00+00:00").gmtime
39
- extract_data_from_monitor_files(monitoring_files, day_as_time)
40
- fill_sleep_activity
41
- smoothen_sleep_activity
42
- analyze
43
- trim_wake_periods_at_ends
96
+ extract_data_from_monitor_files(monitoring_files, day_as_time,
97
+ window_offest_secs)
98
+
99
+ # We must have information about the local time zone the data was
100
+ # recorded in. Abort if not available.
101
+ return unless @utc_offset
102
+
103
+ fill_monitoring_data
104
+ categorize_sleep_activity
105
+
106
+ if categorize_sleep_heart_rate
107
+ # We have usable heart rate data for the sleep periods. Correlating
108
+ # wrist motion data with heart rate cycles will greatly improve the
109
+ # sleep phase and sleep cycle detection.
110
+ categorize_sleep_phase_by_hr_level
111
+ @sleep_cycles.each do |c|
112
+ # Adjust the cycle boundaries to align with REM phase.
113
+ c.adjust_cycle_boundaries(@sleep_phase)
114
+ # Detect sleep phases for each cycle.
115
+ c.detect_phases(@sleep_phase)
116
+ end
117
+ else
118
+ # We have no usable heart rate data. Just guess sleep phases based on
119
+ # wrist motion data.
120
+ categorize_sleep_phase_by_activity_level
121
+ @sleep_cycles.each { |c| c.detect_phases(@sleep_phase) }
122
+ end
123
+ dump_data
124
+ delete_wake_cycles
125
+ determine_resting_heart_rate
44
126
  calculate_totals
45
127
  end
46
128
 
47
129
  private
48
130
 
49
- def extract_utc_offset(monitoring_file)
131
+ def get_monitoring_info(monitoring_file)
50
132
  # The monitoring files have a monitoring_info section that contains a
51
133
  # timestamp in UTC and a local_time field for the same time in the local
52
134
  # time. If any of that isn't present, we use an offset of 0.
53
- if (mi = monitoring_file.monitoring_infos).nil? || mi.empty? ||
54
- (localtime = mi[0].local_time).nil?
55
- return 0
135
+ if (mis = monitoring_file.monitoring_infos).nil? || mis.empty? ||
136
+ (mi = mis[0]).nil? || mi.local_time.nil? || mi.timestamp.nil?
137
+ return nil
56
138
  end
57
139
 
58
- # Otherwise the delta in seconds between UTC and localtime is the
59
- # offset.
60
- localtime - mi[0].timestamp
140
+ mi
61
141
  end
62
142
 
63
- def extract_data_from_monitor_files(monitoring_files, day)
64
- # We use an Array with entries for every minute from noon yesterday to
65
- # noon today.
66
- @sleep_activity = Array.new(24 * 60, nil)
143
+ # Load monitoring data from monitoring_b FIT files into Arrays.
144
+ # @param monitoring_files [Array of Monitoring_B] FIT files to read
145
+ # @param day [Time] Midnight UTC of the day to analyze
146
+ # @param window_offest_secs [Fixnum] Difference between midnight and the
147
+ # start of the time window to analyze.
148
+ def extract_data_from_monitor_files(monitoring_files, day,
149
+ window_offest_secs)
67
150
  monitoring_files.each do |mf|
68
- utc_offset = extract_utc_offset(mf)
151
+ next unless (mi = get_monitoring_info(mf))
152
+
153
+ utc_offset = mi.local_time - mi.timestamp
154
+ # Midnight (local time) of the requested day.
155
+ midnight_today = day - utc_offset
69
156
  # Noon (local time) the day before the requested day. The time object
70
157
  # is UTC for the noon time in the local time zone.
71
- noon_yesterday = day - 12 * 60 * 60 - utc_offset
158
+ window_start_time = midnight_today + window_offest_secs
72
159
  # Noon (local time) of the current day
73
- noon_today = day + 12 * 60 * 60 - utc_offset
160
+ window_end_time = window_start_time + TIME_WINDOW_MINUTES * 60
161
+
162
+ # Ignore all files with data prior to the potential time window.
163
+ next if mf.monitorings.empty? ||
164
+ mf.monitorings.last.timestamp < window_start_time
165
+
166
+ if @utc_offset.nil?
167
+ # The instance variables will only be set once we have found our
168
+ # first monitoring file that matches the requested day. We use the
169
+ # local time setting for this first file even if it changes in
170
+ # subsequent files.
171
+ @window_start_time = window_start_time
172
+ @window_end_time = window_end_time
173
+ @utc_offset = utc_offset
174
+ end
74
175
 
75
176
  mf.monitorings.each do |m|
76
- # Ignore all entries outside our 24 hour window from noon the day
77
- # before to noon the current day.
78
- next if m.timestamp < noon_yesterday || m.timestamp >= noon_today
79
-
80
- if @noon_yesterday.nil? && @noon_today.nil?
81
- # The instance variables will only be set once we have found our
82
- # first monitoring file that matches the requested day. We use the
83
- # local time setting for this first file even if it changes in
84
- # subsequent files.
85
- @noon_yesterday = noon_yesterday
86
- @noon_today = noon_today
87
- @utc_offset = utc_offset
88
- end
177
+ # Ignore all entries outside our time window.
178
+ next if m.timestamp < @window_start_time ||
179
+ m.timestamp >= @window_end_time
180
+
181
+ # The index (minutes after noon yesterday) to address all the value
182
+ # arrays.
183
+ index = (m.timestamp - @window_start_time) / 60
89
184
 
185
+ # The activity type and intensity are stored in the same FIT field.
186
+ # We'll break them into 2 separate values.
90
187
  if (cati = m.current_activity_type_intensity)
91
- activity_type = cati & 0x1F
188
+ @activity_type[index] = cati & 0x1F
189
+ @activity_intensity[index] = (cati >> 5) & 0x7
190
+ end
92
191
 
93
- # Compute the index in the @sleep_activity Array.
94
- index = (m.timestamp - @noon_yesterday) / 60
95
- if activity_type == 8
96
- intensity = (cati >> 5) & 0x7
97
- @sleep_activity[index] = intensity
98
- else
99
- @sleep_activity[index] = false
100
- end
192
+ # Store heart rate data if available.
193
+ if m.heart_rate
194
+ @heart_rate[index] = m.heart_rate
101
195
  end
102
196
  end
103
197
  end
104
198
 
105
199
  end
106
200
 
107
- def fill_sleep_activity
201
+ def fill_monitoring_data
202
+ # The FIT files only contain a timestamped entry when new values have
203
+ # been measured. The timestamp marks the end of the period where the
204
+ # recorded values were current.
205
+ #
206
+ # We want to have an entry for every minute. So we have to replicate the
207
+ # found value for all previous minutes until we find another valid
208
+ # entry.
108
209
  current = nil
109
- @sleep_activity = @sleep_activity.reverse.map do |v|
110
- v.nil? ? current : current = v
111
- end.reverse
210
+ [ @activity_type, @activity_intensity, @heart_rate ].each do |dataset|
211
+ current = nil
212
+ # We need to fill back-to-front, so we reverse the array during the
213
+ # fill. And reverse it back at the end.
214
+ dataset.reverse!.map! do |v|
215
+ v.nil? ? current : current = v
216
+ end.reverse!
217
+ end
218
+ end
112
219
 
220
+ # Dump all input and intermediate data for the sleep tracking into a CSV
221
+ # file if DEBUG mode is enabled.
222
+ def dump_data
113
223
  if $DEBUG
114
- File.open('sleep-data.csv', 'w') do |f|
115
- f.puts 'Date;Value'
116
- @sleep_activity.each_with_index do |v, i|
117
- f.puts "#{@noon_yesterday + i * 60};#{v.is_a?(Fixnum) ? v : 8}"
224
+ File.open('monitoring-data.csv', 'w') do |f|
225
+ f.puts 'Date;Activity Type;Activity Level;Weighted Act. Level;' +
226
+ 'Heart Rate;Activity Class;Heart Rate Class;Sleep Phase'
227
+ 0.upto(TIME_WINDOW_MINUTES - 1) do |i|
228
+ at = @activity_type[i]
229
+ ai = @activity_intensity[i]
230
+ wsa = @weighted_sleep_activity[i]
231
+ hr = @heart_rate[i]
232
+ sac = @sleep_activity_classification[i]
233
+ shc = @sleep_heart_rate_classification[i]
234
+ sp = @sleep_phase[i]
235
+ f.puts "#{@window_start_time + i * 60};" +
236
+ "#{at.is_a?(Fixnum) ? at : ''};" +
237
+ "#{ai.is_a?(Fixnum) ? ai : ''};" +
238
+ "#{wsa};" +
239
+ "#{hr.is_a?(Fixnum) ? hr : ''};" +
240
+ "#{sac ? sac.to_s : ''};" +
241
+ "#{shc ? shc.to_s : ''};" +
242
+ "#{sp.to_s}"
118
243
  end
119
244
  end
120
245
  end
121
246
  end
122
247
 
123
- def smoothen_sleep_activity
124
- window_size = 30
125
-
126
- @smoothed_sleep_activity = Array.new(24 * 60, nil)
127
- 0.upto(24 * 60 - 1).each do |i|
128
- window_start_idx = i - window_size
129
- window_end_idx = i
130
- sum = 0.0
131
- (i - window_size + 1).upto(i).each do |j|
132
- sum += j < 0 ? 8.0 :
133
- @sleep_activity[j].is_a?(Fixnum) ? @sleep_activity[j] : 8
248
+ def categorize_sleep_activity
249
+ delta = 7
250
+ 0.upto(TIME_WINDOW_MINUTES - 1) do |i|
251
+ intensity_sum = 0
252
+ weight_sum = 0
253
+
254
+ (i - delta).upto(i + delta) do |j|
255
+ next if i < 0 || i >= TIME_WINDOW_MINUTES
256
+
257
+ weight = delta - (i - j).abs
258
+ intensity_sum += weight *
259
+ (@activity_type[j] != 8 ? 8 : @activity_intensity[j])
260
+ weight_sum += weight
134
261
  end
135
- @smoothed_sleep_activity[i] = sum / window_size
262
+
263
+ # Normalize the weighted intensity sum
264
+ @weighted_sleep_activity[i] =
265
+ intensity_sum.to_f / weight_sum
266
+
267
+ @sleep_activity_classification[i] =
268
+ if @weighted_sleep_activity[i] > 2.2
269
+ :wake
270
+ elsif @weighted_sleep_activity[i] > 0.5
271
+ :low_activity
272
+ else
273
+ :no_activity
274
+ end
136
275
  end
276
+ end
137
277
 
138
- if $DEBUG
139
- File.open('smoothed-sleep-data.csv', 'w') do |f|
140
- f.puts 'Date;Value'
141
- @smoothed_sleep_activity.each_with_index do |v, i|
142
- f.puts "#{@noon_yesterday + i * 60};#{v}"
278
+ # During the nightly sleep the heart rate is alternating between a high
279
+ # and a low frequency. The actual frequencies vary so that we need to look
280
+ # for the transitions to classify each sample as high or low.
281
+ def categorize_sleep_heart_rate
282
+ @sleep_heart_rate_classification = Array.new(TIME_WINDOW_MINUTES, nil)
283
+
284
+ last_heart_rate = 0
285
+ current_category = :high_hr
286
+ last_transition_index = 0
287
+ last_transition_delta = 0
288
+ transitions = 0
289
+
290
+ 0.upto(TIME_WINDOW_MINUTES - 1) do |i|
291
+ if @sleep_activity_classification[i] == :wake ||
292
+ @heart_rate[i].nil? || @heart_rate[i] == 0
293
+ last_heart_rate = 0
294
+ current_category = :high_hr
295
+ last_transition_index = i + 1
296
+ last_transition_delta = 0
297
+ next
298
+ end
299
+
300
+ if last_heart_rate
301
+ if current_category == :high_hr
302
+ if last_heart_rate > @heart_rate[i]
303
+ # High/low transition found
304
+ current_category = :low_hr
305
+ transitions += 1
306
+ last_transition_delta = last_heart_rate - @heart_rate[i]
307
+ last_transition_index = i
308
+ elsif last_heart_rate < @heart_rate[i] &&
309
+ last_transition_delta < @heart_rate[i] - last_heart_rate
310
+ # The previously found high segment was wrongly categorized as
311
+ # such. Convert it to low segment.
312
+ last_transition_index.upto(i - 1) do |j|
313
+ @sleep_heart_rate_classification[j] = :low_hr
314
+ end
315
+ # Now we are in a high segment.
316
+ current_category = :high_hr
317
+ last_transition_delta += @heart_rate[i] - last_heart_rate
318
+ last_transition_index = i
319
+ end
320
+ else
321
+ if last_heart_rate < @heart_rate[i]
322
+ # Low/High transition found.
323
+ current_category = :high_hr
324
+ transitions += 1
325
+ last_transition_delta = @heart_rate[i] - last_heart_rate
326
+ last_transition_index = i
327
+ elsif last_heart_rate > @heart_rate[i] &&
328
+ last_transition_delta < last_heart_rate - @heart_rate[i]
329
+ # The previously found low segment was wrongly categorized as
330
+ # such. Convert it to high segment.
331
+ last_transition_index.upto(i - 1) do |j|
332
+ @sleep_heart_rate_classification[j] = :high_hr
333
+ end
334
+ # Now we are in a low segment.
335
+ current_category = :low_hr
336
+ last_transition_delta += last_heart_rate - @heart_rate[i]
337
+ last_transition_index = i
338
+ end
143
339
  end
340
+ @sleep_heart_rate_classification[i] = current_category
144
341
  end
342
+
343
+ last_heart_rate = @heart_rate[i]
145
344
  end
345
+
346
+ # We consider the HR transition data good enough if we have found at
347
+ # least 3 transitions.
348
+ transitions > 3
146
349
  end
147
350
 
148
- def analyze
149
- current_phase = :awake
150
- current_phase_start = @noon_yesterday
151
- @sleep_intervals = []
152
-
153
- @smoothed_sleep_activity.each_with_index do |v, idx|
154
- if v < 0.25
155
- phase = :deep_sleep
156
- elsif v < 1.5
157
- phase = :light_sleep
158
- else
159
- phase = :awake
351
+ # Use the wrist motion data and heart rate data to guess the sleep phases
352
+ # and sleep cycles.
353
+ def categorize_sleep_phase_by_hr_level
354
+ rem_possible = false
355
+ current_hr_phase = nil
356
+ cycle = nil
357
+
358
+ 0.upto(TIME_WINDOW_MINUTES - 1) do |i|
359
+ sac = @sleep_activity_classification[i]
360
+ hrc = @sleep_heart_rate_classification[i]
361
+
362
+ if hrc != current_hr_phase
363
+ if current_hr_phase.nil?
364
+ if hrc == :high_hr
365
+ # Wake/High transition.
366
+ rem_possible = false
367
+ else
368
+ # Wake/Low transition. Should be very uncommon.
369
+ rem_possible = true
370
+ end
371
+ cycle = SleepCycle.new(@window_start_time, i)
372
+ elsif current_hr_phase == :high_hr
373
+ rem_possible = false
374
+ if hrc.nil?
375
+ # High/Wake transition. Wakeing up from light sleep.
376
+ if cycle
377
+ cycle.end_idx = i - 1
378
+ @sleep_cycles << cycle
379
+ cycle = nil
380
+ end
381
+ else
382
+ # High/Low transition. Going into deep sleep
383
+ if cycle
384
+ # A high/low transition completes the cycle if we already have
385
+ # a low/high transition for this cycle. The actual end
386
+ # should be the end of the REM phase, but we have to correct
387
+ # this and the start of the new cycle later.
388
+ cycle.high_low_trans_idx = i
389
+ if cycle.low_high_trans_idx
390
+ cycle.end_idx = i - 1
391
+ @sleep_cycles << cycle
392
+ cycle = SleepCycle.new(@window_start_time, i, cycle)
393
+ end
394
+ end
395
+ end
396
+ else
397
+ if hrc.nil?
398
+ # Low/Wake transition. Waking up from deep sleep.
399
+ rem_possible = false
400
+ if cycle
401
+ cycle.end_idx = i - 1
402
+ @sleep_cycles << cycle
403
+ cycle = nil
404
+ end
405
+ else
406
+ # Low/High transition. REM phase possible
407
+ rem_possible = true
408
+ cycle.low_high_trans_idx = i if cycle
409
+ end
410
+ end
160
411
  end
412
+ current_hr_phase = hrc
161
413
 
162
- if current_phase != phase
163
- t = @noon_yesterday + 60 * idx
164
- @sleep_intervals << SleepInterval.new(current_phase_start, t,
165
- current_phase)
166
- current_phase = phase
167
- current_phase_start = t
168
- end
414
+ next unless hrc && sac
415
+
416
+ @sleep_phase[i] =
417
+ if hrc == :high_hr
418
+ if sac == :no_activity
419
+ :nrem1
420
+ else
421
+ rem_possible ? :rem : :nrem1
422
+ end
423
+ else
424
+ if sac == :no_activity
425
+ :nrem3
426
+ else
427
+ :nrem2
428
+ end
429
+ end
169
430
  end
170
- @sleep_intervals << SleepInterval.new(current_phase_start, @noon_today,
171
- current_phase)
172
431
  end
173
432
 
174
- def trim_wake_periods_at_ends
175
- first_deep_sleep_idx = last_deep_sleep_idx = nil
433
+ def categorize_sleep_phase_by_activity_level
434
+ @sleep_phase = []
435
+ mappings = { :wake => :wake, :low_activity => :nrem1,
436
+ :no_activity => :nrem3 }
437
+
438
+ current_cycle_start = nil
439
+ current_phase = @sleep_activity_classification[0]
440
+ current_phase_start = 0
441
+
442
+ 0.upto(TIME_WINDOW_MINUTES - 1) do |idx|
443
+ # Without HR data, we need to use other threshold values to determine
444
+ # the activity classification. Hence we do it again here.
445
+ @sleep_activity_classification[idx] = sac =
446
+ if @weighted_sleep_activity[idx] > 2.2
447
+ :wake
448
+ elsif @weighted_sleep_activity[idx] > 0.01
449
+ :low_activity
450
+ else
451
+ :no_activity
452
+ end
453
+
454
+ @sleep_phase << mappings[sac]
176
455
 
177
- @sleep_intervals.each_with_index do |p, idx|
178
- if p.phase == :deep_sleep ||
179
- (p.phase == :light_sleep && ((p.to_time - p.from_time) > 15 * 60))
180
- first_deep_sleep_idx = idx unless first_deep_sleep_idx
181
- last_deep_sleep_idx = idx
456
+ # Sleep cycles start at wake/non-wake transistions.
457
+ if current_cycle_start.nil? && sac != :wake
458
+ current_cycle_start = idx
182
459
  end
183
- end
184
460
 
185
- return unless first_deep_sleep_idx && last_deep_sleep_idx
461
+ if current_phase != sac || idx >= TIME_WINDOW_MINUTES
462
+ # We have detected the end of a phase.
463
+ if (current_phase == :no_activity || sac == :wake) &&
464
+ current_cycle_start
465
+ # The end of the :no_activity phase marks the end of a sleep cycle.
466
+ @sleep_cycles << (cycle = SleepCycle.new(@window_start_time,
467
+ current_cycle_start,
468
+ @sleep_cycles.last))
469
+ cycle.end_idx = idx
470
+ current_cycle_start = nil
471
+ end
186
472
 
187
- if first_deep_sleep_idx > 0 &&
188
- @sleep_intervals[first_deep_sleep_idx - 1].phase == :light_sleep
189
- first_deep_sleep_idx -= 1
190
- end
191
- if last_deep_sleep_idx < @sleep_intervals.length - 2 &&
192
- @sleep_intervals[last_deep_sleep_idx + 1].phase == :light_sleep
193
- last_deep_sleep_idx += 1
473
+ current_phase = sac
474
+ current_phase_start = idx
475
+ end
194
476
  end
477
+ end
195
478
 
196
- @sleep_intervals =
197
- @sleep_intervals[first_deep_sleep_idx..last_deep_sleep_idx]
479
+ def delete_wake_cycles
480
+ wake_cycles = []
481
+ @sleep_cycles.each { |c| wake_cycles << c if c.is_wake_cycle? }
482
+
483
+ wake_cycles.each { |c| c.unlink }
484
+ @sleep_cycles.delete_if { |c| wake_cycles.include?(c) }
198
485
  end
199
486
 
200
- def calculate_totals
201
- @total_sleep = @light_sleep = @deep_sleep = 0
202
- @sleep_intervals.each do |p|
203
- if p.phase != :awake
204
- seconds = p.to_time - p.from_time
205
- @total_sleep += seconds
206
- if p.phase == :light_sleep
207
- @light_sleep += seconds
208
- else
209
- @deep_sleep += seconds
210
- end
487
+ def determine_resting_heart_rate
488
+ # Find the smallest heart rate. TODO: While being awake.
489
+ @heart_rate.each_with_index do |heart_rate, idx|
490
+ next unless heart_rate && heart_rate > 0
491
+ if @resting_heart_rate.nil? || @resting_heart_rate > heart_rate
492
+ @resting_heart_rate = heart_rate
211
493
  end
212
494
  end
213
495
  end
214
496
 
497
+ def calculate_totals
498
+ @total_sleep = @light_sleep = @deep_sleep = @rem_sleep = 0
499
+
500
+ @sleep_cycles.each do |p|
501
+ @total_sleep += p.total_seconds.values.inject(0, :+)
502
+ @light_sleep += p.total_seconds[:nrem1] + p.total_seconds[:nrem2]
503
+ @deep_sleep += p.total_seconds[:nrem3]
504
+ @rem_sleep += p.total_seconds[:rem]
505
+ end
506
+ end
507
+
215
508
  # Return the begining of the current day in local time as Time object.
216
509
  def begining_of_today(time = Time.now)
217
510
  sec, min, hour, day, month, year = time.to_a