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 +4 -4
- data/README.md +8 -6
- data/lib/postrunner/DailyMonitoringAnalyzer.rb +239 -0
- data/lib/postrunner/DailySleepAnalyzer.rb +418 -125
- data/lib/postrunner/FFS_Device.rb +3 -7
- data/lib/postrunner/FFS_Monitoring.rb +0 -2
- data/lib/postrunner/FitFileStore.rb +10 -9
- data/lib/postrunner/FlexiTable.rb +6 -1
- data/lib/postrunner/Main.rb +12 -12
- data/lib/postrunner/MonitoringStatistics.rb +372 -0
- data/lib/postrunner/SleepCycle.rb +198 -0
- data/lib/postrunner/version.rb +1 -1
- data/postrunner.gemspec +10 -2
- data/spec/PostRunner_spec.rb +8 -0
- metadata +17 -10
- data/lib/postrunner/SleepStatistics.rb +0 -117
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c470dc3a2d1c211ae2c294e98392d9a2621b2ade
|
4
|
+
data.tar.gz: 201bf757b216cb48ab8bf4cf9ace6fae602b7730
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
6
|
-
In addition to the common features like plotting pace,
|
7
|
-
elevation and other captured values it also provides a
|
8
|
-
variability (HRV) analysis. It can also update satellite
|
9
|
-
(EPO) data on the device to speed-up fix times.
|
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
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
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
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
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 (
|
54
|
-
(
|
55
|
-
return
|
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
|
-
|
59
|
-
# offset.
|
60
|
-
localtime - mi[0].timestamp
|
140
|
+
mi
|
61
141
|
end
|
62
142
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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
|
-
|
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
|
-
|
158
|
+
window_start_time = midnight_today + window_offest_secs
|
72
159
|
# Noon (local time) of the current day
|
73
|
-
|
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
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
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
|
-
|
94
|
-
|
95
|
-
|
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
|
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
|
-
@
|
110
|
-
|
111
|
-
|
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('
|
115
|
-
f.puts 'Date;
|
116
|
-
|
117
|
-
|
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
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
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
|
-
|
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
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
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
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
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
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
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
|
175
|
-
|
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
|
-
|
178
|
-
if
|
179
|
-
|
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
|
-
|
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
|
-
|
188
|
-
|
189
|
-
|
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
|
-
|
197
|
-
|
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
|
201
|
-
|
202
|
-
@
|
203
|
-
|
204
|
-
|
205
|
-
@
|
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
|