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 +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
@@ -0,0 +1,198 @@
|
|
1
|
+
#!/usr/bin/env ruby -w
|
2
|
+
# encoding: UTF-8
|
3
|
+
#
|
4
|
+
# = SleepCycle.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
|
+
module PostRunner
|
14
|
+
|
15
|
+
# A SleepPhase is a segment of a sleep cycle. It captures the start and
|
16
|
+
# end time as well as the kind of phase.
|
17
|
+
class SleepPhase
|
18
|
+
|
19
|
+
attr_reader :from_time, :to_time, :phase
|
20
|
+
|
21
|
+
# Create a new sleep phase.
|
22
|
+
# @param from_time [Time] Start time of the phase
|
23
|
+
# @param to_time [Time] End time of the phase
|
24
|
+
# @param phase [Symbol] The kind of phase [ :rem, :nrem1, :nrem2, :nrem3 ]
|
25
|
+
def initialize(from_time, to_time, phase)
|
26
|
+
@from_time = from_time
|
27
|
+
@to_time = to_time
|
28
|
+
@phase = phase
|
29
|
+
end
|
30
|
+
|
31
|
+
# Duration of the phase in seconds.
|
32
|
+
# @return [Fixnum] duration
|
33
|
+
def duration
|
34
|
+
@to_time - @from_time
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
# A sleep cycle consists of several sleep phases. This class is used to
|
40
|
+
# gather and store the relevant data of a sleep cycle. Data is analzyed and
|
41
|
+
# stored with a one minute granularity. Time values are stored as minutes
|
42
|
+
# past the zero_idx_time.
|
43
|
+
class SleepCycle
|
44
|
+
|
45
|
+
attr_reader :total_seconds, :totals
|
46
|
+
attr_accessor :start_idx, :end_idx,
|
47
|
+
:high_low_trans_idx, :low_high_trans_idx,
|
48
|
+
:prev_cycle, :next_cycle
|
49
|
+
|
50
|
+
# Create a new SleepCycle record.
|
51
|
+
# @param zero_idx_time [Time] This is the time of the 0-th minute. All
|
52
|
+
# time values are stored as minutes past this time.
|
53
|
+
# @param start_idx [Fixnum] Time when the sleep cycle starts. We may start
|
54
|
+
# with an appromated value that gets fine tuned later on.
|
55
|
+
# @param prev_cycle [SleepCycle] A reference to the preceding sleep cycle
|
56
|
+
# or nil if this is the first cycle of the analyzed period.
|
57
|
+
def initialize(zero_idx_time, start_idx, prev_cycle = nil)
|
58
|
+
@zero_idx_time = zero_idx_time
|
59
|
+
@start_idx = start_idx
|
60
|
+
# These values will be determined later.
|
61
|
+
@end_idx = nil
|
62
|
+
# Every sleep cycle has at most one high/low heart rate transition and
|
63
|
+
# one low/high transition. These variables store the time of these
|
64
|
+
# transitions or nil if the transition does not exist. Every cycle must
|
65
|
+
# have at least one of these transitions to be a valid cycle.
|
66
|
+
@high_low_trans_idx = @low_high_trans_idx = nil
|
67
|
+
@prev_cycle = prev_cycle
|
68
|
+
# Register this cycle as successor of the previous cycle.
|
69
|
+
prev_cycle.next_cycle = self if prev_cycle
|
70
|
+
@next_cycle = nil
|
71
|
+
# Array holding the sleep phases of this cycle
|
72
|
+
@phases = []
|
73
|
+
# A hash with the total durations (in secods) of the various sleep
|
74
|
+
# phases.
|
75
|
+
@total_seconds = Hash.new(0)
|
76
|
+
end
|
77
|
+
|
78
|
+
# The start time of the cycle as Time object
|
79
|
+
# @return [Time]
|
80
|
+
def from_time
|
81
|
+
idx_to_time(@start_idx)
|
82
|
+
end
|
83
|
+
|
84
|
+
# The end time of the cycle as Time object.
|
85
|
+
# @return [Time]
|
86
|
+
def to_time
|
87
|
+
idx_to_time(@end_idx + 1)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Remove this cycle from the cycle chain.
|
91
|
+
def unlink
|
92
|
+
@prev_cycle.next_cycle = @next_cycle if @prev_cycle
|
93
|
+
@next_cycle.prev_cycle = @prev_cycle if @next_cycle
|
94
|
+
end
|
95
|
+
|
96
|
+
# Initially, we use the high/low heart rate transition to mark the end
|
97
|
+
# of the cycle. But it's really the end of the REM phase that marks the
|
98
|
+
# end of a sleep cycle. If we find a REM phase, we use its end to adjust
|
99
|
+
# the sleep cycle boundaries.
|
100
|
+
# @param phases [Array] List of symbols that describe the sleep phase at
|
101
|
+
# at the minute corresponding to the Array index.
|
102
|
+
def adjust_cycle_boundaries(phases)
|
103
|
+
end_of_rem_phase_idx = nil
|
104
|
+
@start_idx.upto(@end_idx) do |i|
|
105
|
+
end_of_rem_phase_idx = i if phases[i] == :rem
|
106
|
+
end
|
107
|
+
if end_of_rem_phase_idx
|
108
|
+
# We have found a REM phase. Adjust the end_idx of this cycle
|
109
|
+
# accordingly.
|
110
|
+
@end_idx = end_of_rem_phase_idx
|
111
|
+
if @next_cycle
|
112
|
+
# If we have a successor phase, we also adjust the start.
|
113
|
+
@next_cycle.start_idx = end_of_rem_phase_idx + 1
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Gather a list of SleepPhase objects that describe the sequence of sleep
|
119
|
+
# phases in the provided Array.
|
120
|
+
# @param phases [Array] List of symbols that describe the sleep phase at
|
121
|
+
# at the minute corresponding to the Array index.
|
122
|
+
def detect_phases(phases)
|
123
|
+
@phases = []
|
124
|
+
current_phase = phases[0]
|
125
|
+
current_phase_start = @start_idx
|
126
|
+
|
127
|
+
@start_idx.upto(@end_idx) do |i|
|
128
|
+
if (current_phase && current_phase != phases[i]) || i == @end_idx
|
129
|
+
# We found a transition in the sequence. Create a SleepPhase object
|
130
|
+
# that describes the prepvious segment and add it to the @phases
|
131
|
+
# list.
|
132
|
+
@phases << (p = SleepPhase.new(idx_to_time(current_phase_start),
|
133
|
+
idx_to_time(i == @end_idx ? i + 1 : i),
|
134
|
+
current_phase))
|
135
|
+
# Add the duration of the phase to the corresponding sum in the
|
136
|
+
# @total_seconds Hash.
|
137
|
+
@total_seconds[current_phase] += p.duration
|
138
|
+
|
139
|
+
# Update the variables that track the start and kind of the
|
140
|
+
# currently read phase.
|
141
|
+
current_phase_start = i
|
142
|
+
current_phase = phases[i]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Check if this cycle is really a sleep cycle or not. A sleep cycle must
|
148
|
+
# have at least one deep sleep phase or must be part of a directly
|
149
|
+
# attached series of cycles that contain a deep sleep phase.
|
150
|
+
# @return [Boolean] True if not a sleep cycle, false otherwise.
|
151
|
+
def is_wake_cycle?
|
152
|
+
!has_deep_sleep_phase? && !has_leading_deep_sleep_phase? &&
|
153
|
+
!has_trailing_deep_sleep_phase?
|
154
|
+
end
|
155
|
+
|
156
|
+
|
157
|
+
# Check if the cycle has a deep sleep phase.
|
158
|
+
# @return [Boolean] True of one of the phases is NREM3 phase. False
|
159
|
+
# otherwise.
|
160
|
+
def has_deep_sleep_phase?
|
161
|
+
# A real deep sleep phase must be at least 10 minutes long.
|
162
|
+
@phases.each do |p|
|
163
|
+
return true if p.phase == :nrem3 && p.duration > 10 * 60
|
164
|
+
end
|
165
|
+
|
166
|
+
false
|
167
|
+
end
|
168
|
+
|
169
|
+
# Check if any of the previous cycles that are directly attached have a
|
170
|
+
# deep sleep cycle.
|
171
|
+
# @return [Boolean] True if it has a leading sleep cycle.
|
172
|
+
def has_leading_deep_sleep_phase?
|
173
|
+
return false if @prev_cycle.nil? || @start_idx != @prev_cycle.end_idx + 1
|
174
|
+
|
175
|
+
@prev_cycle.has_deep_sleep_phase? ||
|
176
|
+
@prev_cycle.has_leading_deep_sleep_phase?
|
177
|
+
end
|
178
|
+
|
179
|
+
# Check if any of the trailing cycles that are directly attached have a
|
180
|
+
# deep sleep cycle.
|
181
|
+
# @return [Boolean] True if it has a trailing sleep cycle.
|
182
|
+
def has_trailing_deep_sleep_phase?
|
183
|
+
return false if @next_cycle.nil? || @end_idx + 1 != @next_cycle.start_idx
|
184
|
+
|
185
|
+
@next_cycle.has_deep_sleep_phase? ||
|
186
|
+
@next_cycle.has_trailing_deep_sleep_phase?
|
187
|
+
end
|
188
|
+
|
189
|
+
private
|
190
|
+
|
191
|
+
def idx_to_time(idx)
|
192
|
+
return nil unless idx
|
193
|
+
@zero_idx_time + 60 * idx
|
194
|
+
end
|
195
|
+
|
196
|
+
end
|
197
|
+
|
198
|
+
end
|
data/lib/postrunner/version.rb
CHANGED
data/postrunner.gemspec
CHANGED
@@ -9,7 +9,15 @@ GEM_SPEC = Gem::Specification.new do |spec|
|
|
9
9
|
spec.authors = ["Chris Schlaeger"]
|
10
10
|
spec.email = ["cs@taskjuggler.org"]
|
11
11
|
spec.summary = %q{Application to manage and analyze Garmin FIT files.}
|
12
|
-
spec.description = %q{
|
12
|
+
spec.description = %q{PostRunner is an application to manage FIT files
|
13
|
+
such as those produced by Garmin products like the Forerunner 620 (FR620) and
|
14
|
+
Fenix 3 or Fenix 3HR. It allows you to import the files from the device and
|
15
|
+
analyze the data. In addition to the common features like plotting pace, heart
|
16
|
+
rates, elevation and other captured values it also provides a heart rate
|
17
|
+
variability (HRV) analysis. It can also update satellite orbit prediction
|
18
|
+
(EPO) data on the device to speed-up GPS fix times. It is an offline alternative
|
19
|
+
to Garmin Connect. The software has been developed and tested on Linux but
|
20
|
+
should work on other operating systems as well.}
|
13
21
|
spec.homepage = 'https://github.com/scrapper/postrunner'
|
14
22
|
spec.license = "GNU GPL version 2"
|
15
23
|
|
@@ -19,7 +27,7 @@ GEM_SPEC = Gem::Specification.new do |spec|
|
|
19
27
|
spec.require_paths = ["lib"]
|
20
28
|
spec.required_ruby_version = '>=2.0'
|
21
29
|
|
22
|
-
spec.add_dependency 'fit4ruby', '~> 1.
|
30
|
+
spec.add_dependency 'fit4ruby', '~> 1.1.0'
|
23
31
|
spec.add_dependency 'perobs', '~> 2.2'
|
24
32
|
spec.add_dependency 'nokogiri', '~> 1.6'
|
25
33
|
|
data/spec/PostRunner_spec.rb
CHANGED
@@ -209,5 +209,13 @@ describe PostRunner::Main do
|
|
209
209
|
expect(list.index(File.basename(@file3))).to be_nil
|
210
210
|
end
|
211
211
|
|
212
|
+
it 'should support the daily command' do
|
213
|
+
postrunner([ 'daily' ])
|
214
|
+
end
|
215
|
+
|
216
|
+
it 'should supoprt the monthly command' do
|
217
|
+
postrunner([ 'monthly' ])
|
218
|
+
end
|
219
|
+
|
212
220
|
end
|
213
221
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: postrunner
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Chris Schlaeger
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-
|
11
|
+
date: 2016-05-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: fit4ruby
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: 1.
|
19
|
+
version: 1.1.0
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: 1.
|
26
|
+
version: 1.1.0
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: perobs
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -108,11 +108,16 @@ dependencies:
|
|
108
108
|
- - "~>"
|
109
109
|
- !ruby/object:Gem::Version
|
110
110
|
version: 0.8.7
|
111
|
-
description:
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
to
|
111
|
+
description: |-
|
112
|
+
PostRunner is an application to manage FIT files
|
113
|
+
such as those produced by Garmin products like the Forerunner 620 (FR620) and
|
114
|
+
Fenix 3 or Fenix 3HR. It allows you to import the files from the device and
|
115
|
+
analyze the data. In addition to the common features like plotting pace, heart
|
116
|
+
rates, elevation and other captured values it also provides a heart rate
|
117
|
+
variability (HRV) analysis. It can also update satellite orbit prediction
|
118
|
+
(EPO) data on the device to speed-up GPS fix times. It is an offline alternative
|
119
|
+
to Garmin Connect. The software has been developed and tested on Linux but
|
120
|
+
should work on other operating systems as well.
|
116
121
|
email:
|
117
122
|
- cs@taskjuggler.org
|
118
123
|
executables:
|
@@ -135,6 +140,7 @@ files:
|
|
135
140
|
- lib/postrunner/ActivityView.rb
|
136
141
|
- lib/postrunner/BackedUpFile.rb
|
137
142
|
- lib/postrunner/ChartView.rb
|
143
|
+
- lib/postrunner/DailyMonitoringAnalyzer.rb
|
138
144
|
- lib/postrunner/DailySleepAnalyzer.rb
|
139
145
|
- lib/postrunner/DataSources.rb
|
140
146
|
- lib/postrunner/DeviceList.rb
|
@@ -152,6 +158,7 @@ files:
|
|
152
158
|
- lib/postrunner/Log.rb
|
153
159
|
- lib/postrunner/Main.rb
|
154
160
|
- lib/postrunner/MonitoringDB.rb
|
161
|
+
- lib/postrunner/MonitoringStatistics.rb
|
155
162
|
- lib/postrunner/NavButtonRow.rb
|
156
163
|
- lib/postrunner/PagingButtons.rb
|
157
164
|
- lib/postrunner/Percentiles.rb
|
@@ -160,7 +167,7 @@ files:
|
|
160
167
|
- lib/postrunner/RecordListPageView.rb
|
161
168
|
- lib/postrunner/RuntimeConfig.rb
|
162
169
|
- lib/postrunner/Schema.rb
|
163
|
-
- lib/postrunner/
|
170
|
+
- lib/postrunner/SleepCycle.rb
|
164
171
|
- lib/postrunner/TrackView.rb
|
165
172
|
- lib/postrunner/UserProfileView.rb
|
166
173
|
- lib/postrunner/View.rb
|
@@ -1,117 +0,0 @@
|
|
1
|
-
#!/usr/bin/env ruby -w
|
2
|
-
# encoding: UTF-8
|
3
|
-
#
|
4
|
-
# = SleepStatistics.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
|
-
require 'postrunner/DailySleepAnalyzer'
|
16
|
-
require 'postrunner/FlexiTable'
|
17
|
-
|
18
|
-
module PostRunner
|
19
|
-
|
20
|
-
# This class can be used to generate reports for sleep data. It uses the
|
21
|
-
# DailySleepAnalyzer class to compute the data and generates the report for
|
22
|
-
# a certain time period.
|
23
|
-
class SleepStatistics
|
24
|
-
|
25
|
-
include Fit4Ruby::Converters
|
26
|
-
|
27
|
-
# Create a new SleepStatistics object.
|
28
|
-
# @param monitoring_files [Array of Fit4Ruby::Monitoring_B] FIT files
|
29
|
-
def initialize(monitoring_files)
|
30
|
-
@monitoring_files = monitoring_files
|
31
|
-
end
|
32
|
-
|
33
|
-
# Generate a report for a certain day.
|
34
|
-
# @param day [String] Date of the day as YYYY-MM-DD string.
|
35
|
-
def daily(day)
|
36
|
-
analyzer = DailySleepAnalyzer.new(@monitoring_files, day)
|
37
|
-
|
38
|
-
if analyzer.sleep_intervals.empty?
|
39
|
-
return 'No sleep data available for this day'
|
40
|
-
end
|
41
|
-
|
42
|
-
ti = FlexiTable.new
|
43
|
-
ti.head
|
44
|
-
ti.row([ 'From', 'To', 'Sleep phase' ])
|
45
|
-
ti.body
|
46
|
-
utc_offset = analyzer.utc_offset
|
47
|
-
analyzer.sleep_intervals.each do |i|
|
48
|
-
ti.cell(i[:from_time].localtime(utc_offset).strftime('%H:%M'))
|
49
|
-
ti.cell(i[:to_time].localtime(utc_offset).strftime('%H:%M'))
|
50
|
-
ti.cell(i[:phase])
|
51
|
-
ti.new_row
|
52
|
-
end
|
53
|
-
|
54
|
-
tt = FlexiTable.new
|
55
|
-
tt.head
|
56
|
-
tt.row([ 'Total Sleep', 'Deep Sleep', 'Light Sleep' ])
|
57
|
-
tt.body
|
58
|
-
tt.cell(secsToHM(analyzer.total_sleep), { :halign => :right })
|
59
|
-
tt.cell(secsToHM(analyzer.deep_sleep), { :halign => :right })
|
60
|
-
tt.cell(secsToHM(analyzer.light_sleep), { :halign => :right })
|
61
|
-
tt.new_row
|
62
|
-
|
63
|
-
"Sleep Statistics for #{day}\n\n#{ti}\n#{tt}"
|
64
|
-
end
|
65
|
-
|
66
|
-
def monthly(day)
|
67
|
-
day_as_time = Time.parse(day)
|
68
|
-
year = day_as_time.year
|
69
|
-
month = day_as_time.month
|
70
|
-
last_day_of_month = Date.new(year, month, -1).day
|
71
|
-
|
72
|
-
t = FlexiTable.new
|
73
|
-
left = { :halign => :left }
|
74
|
-
right = { :halign => :right }
|
75
|
-
t.set_column_attributes([ left, right, right, right ])
|
76
|
-
t.head
|
77
|
-
t.row([ 'Date', 'Total Sleep', 'Deep Sleep', 'Light Sleep' ])
|
78
|
-
t.body
|
79
|
-
totals = Hash.new(0)
|
80
|
-
counted_days = 0
|
81
|
-
|
82
|
-
1.upto(last_day_of_month).each do |dom|
|
83
|
-
day_str = Time.new(year, month, dom).strftime('%Y-%m-%d')
|
84
|
-
t.cell(day_str)
|
85
|
-
|
86
|
-
analyzer = DailySleepAnalyzer.new(@monitoring_files, day_str)
|
87
|
-
|
88
|
-
if analyzer.sleep_intervals.empty?
|
89
|
-
t.cell('-')
|
90
|
-
t.cell('-')
|
91
|
-
t.cell('-')
|
92
|
-
else
|
93
|
-
totals[:total_sleep] += analyzer.total_sleep
|
94
|
-
totals[:deep_sleep] += analyzer.deep_sleep
|
95
|
-
totals[:light_sleep] += analyzer.light_sleep
|
96
|
-
counted_days += 1
|
97
|
-
|
98
|
-
t.cell(secsToHM(analyzer.total_sleep))
|
99
|
-
t.cell(secsToHM(analyzer.deep_sleep))
|
100
|
-
t.cell(secsToHM(analyzer.light_sleep))
|
101
|
-
end
|
102
|
-
t.new_row
|
103
|
-
end
|
104
|
-
t.foot
|
105
|
-
t.cell('Averages')
|
106
|
-
t.cell(secsToHM(totals[:total_sleep] / counted_days))
|
107
|
-
t.cell(secsToHM(totals[:deep_sleep] / counted_days))
|
108
|
-
t.cell(secsToHM(totals[:light_sleep] / counted_days))
|
109
|
-
t.new_row
|
110
|
-
|
111
|
-
"Sleep Statistics for #{day_as_time.strftime('%B')} #{year}\n\n#{t}"
|
112
|
-
end
|
113
|
-
|
114
|
-
end
|
115
|
-
|
116
|
-
end
|
117
|
-
|