postrunner 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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