postrunner 0.8.1 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/postrunner/ActivitySummary.rb +11 -4
- data/lib/postrunner/ChartView.rb +43 -33
- data/lib/postrunner/DataSources.rb +7 -3
- data/lib/postrunner/FFS_Activity.rb +5 -4
- data/lib/postrunner/FFS_Device.rb +8 -1
- data/lib/postrunner/FFS_Monitoring.rb +1 -1
- data/lib/postrunner/FitFileStore.rb +33 -6
- data/lib/postrunner/HRV_Analyzer.rb +102 -82
- data/lib/postrunner/LinearPredictor.rb +6 -4
- data/lib/postrunner/PersonalRecords.rb +4 -4
- data/lib/postrunner/version.rb +1 -1
- data/postrunner.gemspec +1 -1
- data/spec/HRV_Analyzer_spec.rb +94 -0
- data/spec/PostRunner_spec.rb +12 -12
- metadata +6 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 30fc3ab83ce783076286638ede5faf67d2b5adbf
|
4
|
+
data.tar.gz: 465e09b0a7dac4cfdbcb129886f50afae1802680
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: aa31ebe5811736bbcba8324d54a0dbb0bd4b19435544471236aac7563ad623002e5c4c2f7cec6e0095da7dfabbb6bdd25f10637cf88905a30d7dc35be6a2f459
|
7
|
+
data.tar.gz: 801afc538448f0615c3d2095559f6fb945dd14cabeb3d55d3c1faa8c323d49e38522edca58ca6b44572cb79c332ae78bb8c4dbf1c22e224e4d3bf32996e8079e
|
@@ -77,7 +77,7 @@ module PostRunner
|
|
77
77
|
t.body
|
78
78
|
t.row([ 'Type:', @type ])
|
79
79
|
t.row([ 'Sub Type:', @sub_type ])
|
80
|
-
t.row([ '
|
80
|
+
t.row([ 'Start Time:', session.start_time])
|
81
81
|
t.row([ 'Distance:',
|
82
82
|
local_value(session, 'total_distance', '%.2f %s',
|
83
83
|
{ :metric => 'km', :statute => 'mi'}) ])
|
@@ -159,9 +159,16 @@ module PostRunner
|
|
159
159
|
t.row([ 'Suggested Recovery Time:',
|
160
160
|
rec_time ? secsToDHMS(rec_time * 60) : '-' ])
|
161
161
|
|
162
|
-
hrv = HRV_Analyzer.new(@
|
163
|
-
|
164
|
-
|
162
|
+
hrv = HRV_Analyzer.new(@activity)
|
163
|
+
# If we have HRV data for more than 120s we compute the PostRunner HRV
|
164
|
+
# Score for the 2nd and 3rd minute. The first minute is ignored as it
|
165
|
+
# often contains erratic data due to body movements and HRM adjustments.
|
166
|
+
# Clinical tests usually recommend a 5 minute measure time, but that's
|
167
|
+
# probably too long for daily tests.
|
168
|
+
if hrv.has_hrv_data? && hrv.duration > 180
|
169
|
+
if (hrv_score = hrv.hrv_score(60, 120)) > 0.0 && hrv_score < 100.0
|
170
|
+
t.row([ 'PostRunner HRV Score:', "%.1f" % hrv_score ])
|
171
|
+
end
|
165
172
|
end
|
166
173
|
|
167
174
|
t
|
data/lib/postrunner/ChartView.rb
CHANGED
@@ -21,7 +21,7 @@ module PostRunner
|
|
21
21
|
@sport = activity.sport
|
22
22
|
@unit_system = unit_system
|
23
23
|
@empty_charts = {}
|
24
|
-
@hrv_analyzer = HRV_Analyzer.new(
|
24
|
+
@hrv_analyzer = HRV_Analyzer.new(activity)
|
25
25
|
|
26
26
|
@charts = [
|
27
27
|
{
|
@@ -63,14 +63,12 @@ module PostRunner
|
|
63
63
|
:unit => 'ms',
|
64
64
|
:graph => :line_graph,
|
65
65
|
:colors => '#900000',
|
66
|
-
:show => @hrv_analyzer.has_hrv_data
|
67
|
-
:min_y => -30,
|
68
|
-
:max_y => 30
|
66
|
+
:show => @hrv_analyzer.has_hrv_data?
|
69
67
|
},
|
70
68
|
{
|
71
69
|
:id => 'hrv_score',
|
72
|
-
:label => '
|
73
|
-
:short_label => '
|
70
|
+
:label => 'rMSSD (30s Window)',
|
71
|
+
:short_label => 'rMSSD',
|
74
72
|
:graph => :line_graph,
|
75
73
|
:colors => '#900000',
|
76
74
|
:show => false
|
@@ -270,41 +268,53 @@ EOT
|
|
270
268
|
start_time = @activity.fit_activity.sessions[0].start_time.to_i
|
271
269
|
min_value = nil
|
272
270
|
if chart[:id] == 'hrv_score'
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
271
|
+
window_time = 120
|
272
|
+
0.upto(@hrv_analyzer.total_duration.to_i - window_time) do |t|
|
273
|
+
if (hrv_score = @hrv_analyzer.rmssd(t, window_time)) >= 0.0
|
274
|
+
min_value = hrv_score if min_value.nil? || min_value > hrv_score
|
275
|
+
data_set << [ (t * 1000).to_i, hrv_score ]
|
276
|
+
else
|
277
|
+
data_set << [ (t * 1000).to_i, nil ]
|
278
|
+
end
|
277
279
|
end
|
278
280
|
elsif chart[:id] == 'hrv'
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
dt = (curr_intvl - prev_intvl) * 1000.0
|
286
|
-
min_value = dt if min_value.nil? || min_value > dt
|
287
|
-
data_set << [ @hrv_analyzer.timestamps[idx] * 1000, dt ]
|
281
|
+
@hrv_analyzer.hrv.each_with_index do |dt, i|
|
282
|
+
if dt
|
283
|
+
data_set << [ (@hrv_analyzer.timestamps[i] * 1000).to_i, dt * 1000 ]
|
284
|
+
else
|
285
|
+
data_set << [ (@hrv_analyzer.timestamps[i] * 1000).to_i, nil ]
|
286
|
+
end
|
288
287
|
end
|
288
|
+
min_value = 0
|
289
289
|
else
|
290
|
+
last_value = nil
|
291
|
+
last_timestamp = nil
|
290
292
|
@activity.fit_activity.records.each do |r|
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
293
|
+
if last_timestamp && (r.timestamp - last_timestamp) > 5.0
|
294
|
+
# We have a gap in the values that is longer than 5 seconds. We'll
|
295
|
+
# finish the line and start a new one later.
|
296
|
+
data_set << [ (r.timestamp - start_time + 1).to_i * 1000, nil ]
|
297
|
+
end
|
298
|
+
if (value = r.get_as(chart[:id], chart[:unit] || ''))
|
299
|
+
if chart[:id] == 'pace'
|
300
|
+
# Slow speeds lead to very large pace values that make the graph
|
301
|
+
# hard to read. We cap the pace at 20.0 min/km to keep it
|
302
|
+
# readable.
|
303
|
+
if value > (@unit_system == :metric ? 20.0 : 36.0 )
|
304
|
+
value = nil
|
305
|
+
else
|
306
|
+
value = (value * 3600.0 * 1000).to_i
|
307
|
+
end
|
308
|
+
min_value = 0.0
|
300
309
|
else
|
301
|
-
|
310
|
+
min_value = value if (min_value.nil? || min_value > value)
|
302
311
|
end
|
303
|
-
min_value = 0.0
|
304
|
-
else
|
305
|
-
min_value = value if (min_value.nil? || min_value > value)
|
306
312
|
end
|
307
|
-
|
313
|
+
unless last_value.nil? && value.nil?
|
314
|
+
data_set << [ (r.timestamp - start_time).to_i * 1000, value ]
|
315
|
+
end
|
316
|
+
last_value = value
|
317
|
+
last_timestamp = r.timestamp
|
308
318
|
end
|
309
319
|
end
|
310
320
|
|
@@ -59,8 +59,8 @@ module PostRunner
|
|
59
59
|
t.cell(secsToHMS(source.timestamp - start_time))
|
60
60
|
t.cell(@activity.distance(source.timestamp, @unit_system))
|
61
61
|
t.cell(source.mode)
|
62
|
-
t.cell(device_name(source.distance))
|
63
62
|
t.cell(device_name(source.speed))
|
63
|
+
t.cell(device_name(source.distance))
|
64
64
|
t.cell(device_name(source.cadence))
|
65
65
|
t.cell(device_name(source.elevation))
|
66
66
|
t.cell(device_name(source.heart_rate))
|
@@ -75,8 +75,12 @@ module PostRunner
|
|
75
75
|
def device_name(index)
|
76
76
|
@fit_activity.device_infos.each do |device|
|
77
77
|
if device.device_index == index
|
78
|
-
|
79
|
-
|
78
|
+
if device.device_type
|
79
|
+
return (DeviceList::DeviceTypeNames[device.device_type] ||
|
80
|
+
device.device_type) + " [#{device.device_index}]"
|
81
|
+
else
|
82
|
+
return "Unknown [#{device.device_index}]"
|
83
|
+
end
|
80
84
|
end
|
81
85
|
end
|
82
86
|
|
@@ -96,9 +96,8 @@ module PostRunner
|
|
96
96
|
'all' => 'All'
|
97
97
|
}
|
98
98
|
|
99
|
-
|
100
|
-
|
101
|
-
:avg_speed
|
99
|
+
attr_persist :device, :fit_file_name, :norecord, :name, :note, :sport,
|
100
|
+
:sub_sport, :timestamp, :total_distance, :total_timer_time, :avg_speed
|
102
101
|
attr_reader :fit_activity
|
103
102
|
|
104
103
|
# Create a new FFS_Activity object.
|
@@ -288,7 +287,9 @@ module PostRunner
|
|
288
287
|
end
|
289
288
|
end
|
290
289
|
|
291
|
-
|
290
|
+
def purge_fit_file
|
291
|
+
@fit_activity = nil
|
292
|
+
end
|
292
293
|
|
293
294
|
end
|
294
295
|
|
@@ -23,7 +23,7 @@ module PostRunner
|
|
23
23
|
# dashes. All objects are transparently stored in the PEROBS::Store.
|
24
24
|
class FFS_Device < PEROBS::Object
|
25
25
|
|
26
|
-
|
26
|
+
attr_persist :activities, :monitorings, :short_uid, :long_uid
|
27
27
|
|
28
28
|
# Create a new FFS_Device object.
|
29
29
|
# @param p [PEROBS::Handle] p
|
@@ -93,6 +93,13 @@ module PostRunner
|
|
93
93
|
entities << entity
|
94
94
|
entities.sort!
|
95
95
|
|
96
|
+
md5sums = @store['fit_file_md5sums']
|
97
|
+
md5sums << FitFileStore.calc_md5_sum(fit_file_name)
|
98
|
+
# We only store the 512 most recently added FIT files. This should be
|
99
|
+
# more than a device can store. This will allow us to skip the already
|
100
|
+
# imported FIT files quickly instead of having to parse them each time.
|
101
|
+
md5sums.shift if md5sums.length > 512
|
102
|
+
|
96
103
|
# Scan the activity for any potential new personal records and register
|
97
104
|
# them.
|
98
105
|
if entity.is_a?(FFS_Activity)
|
@@ -21,7 +21,7 @@ module PostRunner
|
|
21
21
|
|
22
22
|
include DirUtils
|
23
23
|
|
24
|
-
|
24
|
+
attr_persist :device, :fit_file_name, :name, :period_start, :period_end
|
25
25
|
|
26
26
|
# Create a new FFS_Monitoring object.
|
27
27
|
# @param p [PEROBS::Handle] PEROBS handle
|
@@ -10,6 +10,7 @@
|
|
10
10
|
# published by the Free Software Foundation.
|
11
11
|
#
|
12
12
|
|
13
|
+
require 'digest'
|
13
14
|
require 'fit4ruby'
|
14
15
|
require 'perobs'
|
15
16
|
|
@@ -29,7 +30,7 @@ module PostRunner
|
|
29
30
|
|
30
31
|
include DirUtils
|
31
32
|
|
32
|
-
|
33
|
+
attr_persist :devices
|
33
34
|
|
34
35
|
attr_reader :store, :views
|
35
36
|
|
@@ -54,6 +55,9 @@ module PostRunner
|
|
54
55
|
# safely to another directory.
|
55
56
|
@store['config']['devices_dir'] = @devices_dir
|
56
57
|
create_directory(@devices_dir, 'devices')
|
58
|
+
unless @store['fit_file_md5sums']
|
59
|
+
@store['fit_file_md5sums'] = @store.new(PEROBS::Array)
|
60
|
+
end
|
57
61
|
|
58
62
|
# Define which View objects the HTML output will consist of. This
|
59
63
|
# doesn't really belong in this class but for now it's the best place
|
@@ -76,6 +80,12 @@ module PostRunner
|
|
76
80
|
# @return [FFS_Activity or FFS_Monitoring] Corresponding entry in the
|
77
81
|
# FitFileStore or nil if file could not be added.
|
78
82
|
def add_fit_file(fit_file_name, fit_entity = nil, overwrite = false)
|
83
|
+
md5sum = FitFileStore.calc_md5_sum(fit_file_name)
|
84
|
+
if @store['fit_file_md5sums'].include?(md5sum)
|
85
|
+
# The FIT file is already stored in the DB.
|
86
|
+
return nil unless overwrite
|
87
|
+
end
|
88
|
+
|
79
89
|
# If we the file hasn't been read yet, read it in as a
|
80
90
|
# Fit4Ruby::Activity or Fit4Ruby::Monitoring entity.
|
81
91
|
unless fit_entity
|
@@ -176,7 +186,7 @@ module PostRunner
|
|
176
186
|
def change_unit_system
|
177
187
|
# If we have changed the unit system we need to re-generate all HTML
|
178
188
|
# reports.
|
179
|
-
activities.each do |activity|
|
189
|
+
activities.reverse.each do |activity|
|
180
190
|
activity.generate_html_report
|
181
191
|
end
|
182
192
|
@store['records'].generate_html_reports
|
@@ -209,7 +219,17 @@ module PostRunner
|
|
209
219
|
@store['devices'].each do |id, device|
|
210
220
|
list += device.activities
|
211
221
|
end
|
212
|
-
list
|
222
|
+
# Sort the activites by timestamps (newest to oldest). As the list is
|
223
|
+
# composed from multiple devices, there is a small chance of identical
|
224
|
+
# timestamps. To guarantee a stable list, we use the long UID of the
|
225
|
+
# device in cases of identical timestamps.
|
226
|
+
list.sort! do |a1, a2|
|
227
|
+
a1.timestamp == a2.timestamp ?
|
228
|
+
a1.device.long_uid <=> a2.device.long_uid :
|
229
|
+
a2.timestamp <=> a1.timestamp
|
230
|
+
end
|
231
|
+
|
232
|
+
list
|
213
233
|
end
|
214
234
|
|
215
235
|
# Read in all Monitoring_B FIT files that overlap with the given interval.
|
@@ -305,11 +325,10 @@ module PostRunner
|
|
305
325
|
def check
|
306
326
|
records = @store['records']
|
307
327
|
records.delete_all_records
|
308
|
-
activities.
|
309
|
-
a1.timestamp <=> a2.timestamp
|
310
|
-
end.each do |a|
|
328
|
+
activities.reverse.each do |a|
|
311
329
|
a.check
|
312
330
|
records.scan_activity_for_records(a)
|
331
|
+
a.purge_fit_file
|
313
332
|
end
|
314
333
|
records.generate_html_reports
|
315
334
|
generate_html_index_pages
|
@@ -411,6 +430,14 @@ module PostRunner
|
|
411
430
|
puts MonitoringStatistics.new(monitoring_files).monthly(day)
|
412
431
|
end
|
413
432
|
|
433
|
+
def FitFileStore::calc_md5_sum(file_name)
|
434
|
+
begin
|
435
|
+
Digest::MD5.hexdigest File.read(file_name)
|
436
|
+
rescue IOError
|
437
|
+
return 0
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
414
441
|
private
|
415
442
|
|
416
443
|
def read_fit_file(fit_file_name)
|
@@ -3,14 +3,14 @@
|
|
3
3
|
#
|
4
4
|
# = HRV_Analyzer.rb -- PostRunner - Manage the data from your Garmin sport devices.
|
5
5
|
#
|
6
|
-
# Copyright (c) 2015 by Chris Schlaeger <cs@taskjuggler.org>
|
6
|
+
# Copyright (c) 2015, 2016, 2017 by Chris Schlaeger <cs@taskjuggler.org>
|
7
7
|
#
|
8
8
|
# This program is free software; you can redistribute it and/or modify
|
9
9
|
# it under the terms of version 2 of the GNU General Public License as
|
10
10
|
# published by the Free Software Foundation.
|
11
11
|
#
|
12
12
|
|
13
|
-
require 'postrunner/
|
13
|
+
require 'postrunner/FFS_Activity'
|
14
14
|
|
15
15
|
module PostRunner
|
16
16
|
|
@@ -19,20 +19,48 @@ module PostRunner
|
|
19
19
|
# quality is good enough.
|
20
20
|
class HRV_Analyzer
|
21
21
|
|
22
|
-
attr_reader :
|
22
|
+
attr_reader :hrv, :timestamps, :duration, :errors
|
23
|
+
|
24
|
+
# According to Nunan et. al. 2010
|
25
|
+
# (http://www.qeeg.co.uk/HRV/NUNAN-2010-A%20Quantitative%20Systematic%20Review%20of%20Normal%20Values%20for.pdf)
|
26
|
+
# rMSSD (ms) are expected to be in the rage of 19 to 75 in healthy, adult
|
27
|
+
# humans. Typical ln(rMSSD) (ms) values for healthy, adult humans are
|
28
|
+
# between 2.94 and 4.32. We use a slighly broader interval. We'll add a
|
29
|
+
# bit of padding for our limits here.
|
30
|
+
LN_RMSSD_MIN = 2.9
|
31
|
+
LN_RMSSD_MAX = 4.4
|
23
32
|
|
24
33
|
# Create a new HRV_Analyzer object.
|
25
|
-
# @param
|
26
|
-
def initialize(
|
27
|
-
|
28
|
-
|
34
|
+
# @param arg [Activity, Array<Float>] R-R (or NN) time delta in seconds.
|
35
|
+
def initialize(arg)
|
36
|
+
if arg.is_a?(Array)
|
37
|
+
rr_intervals = arg
|
38
|
+
else
|
39
|
+
activity = arg
|
40
|
+
# Gather the RR interval list from the activity. Note that HRV data
|
41
|
+
# still gets recorded after the activity has been stoped until the
|
42
|
+
# activity gets saved.
|
43
|
+
# Each Fit4Ruby::HRV object has an Array called 'time' that contains up
|
44
|
+
# to 5 R-R interval durations. If less than 5 values are present the
|
45
|
+
# remaining are filled with nil entries.
|
46
|
+
rr_intervals = activity.fit_activity.hrv.map do |hrv|
|
47
|
+
hrv.time.compact
|
48
|
+
end.flatten
|
49
|
+
end
|
50
|
+
#$stderr.puts rr_intervals.inspect
|
51
|
+
|
52
|
+
cleanup_rr_intervals(rr_intervals)
|
29
53
|
end
|
30
54
|
|
31
55
|
# The method can be used to check if we have valid HRV data. The FIT file
|
32
56
|
# must have HRV data and the measurement duration must be at least 30
|
33
57
|
# seconds.
|
34
58
|
def has_hrv_data?
|
35
|
-
!@
|
59
|
+
@hrv && !@hrv.empty? && total_duration > 30.0
|
60
|
+
end
|
61
|
+
|
62
|
+
def data_quality
|
63
|
+
(@hrv.size - @errors).to_f / @hrv.size * 100.0
|
36
64
|
end
|
37
65
|
|
38
66
|
# Return the total duration of all measured intervals in seconds.
|
@@ -65,111 +93,103 @@ module PostRunner
|
|
65
93
|
end_idx = -1
|
66
94
|
end
|
67
95
|
|
68
|
-
last_i = nil
|
69
96
|
sum = 0.0
|
70
97
|
cnt = 0
|
71
|
-
@
|
72
|
-
if i
|
73
|
-
|
98
|
+
@hrv[start_idx..end_idx].each do |i|
|
99
|
+
if i
|
100
|
+
# Input values are in seconds, but rmssd is usually computed from
|
101
|
+
# milisecond values.
|
102
|
+
sum += (i * 1000) ** 2.0
|
74
103
|
cnt += 1
|
75
104
|
end
|
76
|
-
last_i = i
|
77
105
|
end
|
78
106
|
|
79
107
|
Math.sqrt(sum / cnt)
|
80
108
|
end
|
81
109
|
|
82
|
-
# The
|
83
|
-
# multiply the natural logarithm of RMSSD by -20. This usually results in
|
84
|
-
# values between 1.0 (for untrained) and 100.0 (for higly trained)
|
85
|
-
# athletes. Values larger than 100.0 are rare but possible.
|
110
|
+
# The natural logarithm of rMSSD.
|
86
111
|
# @param start_time [Float] Determines at what time mark (in seconds) the
|
87
112
|
# computation should start.
|
88
113
|
# @param duration [Float] The duration of the total inteval in seconds to
|
89
114
|
# be considered for the computation. This value should be larger
|
90
115
|
# then 30 seconds to produce meaningful values.
|
91
|
-
def
|
92
|
-
|
116
|
+
def ln_rmssd(start_time = 0.0, duration = nil)
|
117
|
+
Math.log(rmssd(start_time, duration))
|
93
118
|
end
|
94
119
|
|
95
|
-
#
|
96
|
-
#
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
# Compile a list of all tuples with rr_intervals that are outside of the
|
107
|
-
# PT84 (aka +1sigma range. Sort the list by time.
|
108
|
-
not_1sigma = percentiles.not_tp_x(84.13).sort { |e1, e2| e1[1] <=> e2[1] }
|
109
|
-
|
110
|
-
# Then find the largest time gap in that list. So all the values in that
|
111
|
-
# gap are within TP84.
|
112
|
-
gap_start = gap_end = 0
|
113
|
-
last = nil
|
114
|
-
not_1sigma.each do |e|
|
115
|
-
if last
|
116
|
-
if (e[1] - last) > (gap_end - gap_start)
|
117
|
-
gap_start = last
|
118
|
-
gap_end = e[1]
|
119
|
-
end
|
120
|
-
end
|
121
|
-
last = e[1]
|
122
|
-
end
|
123
|
-
# That gap should be at least 30 seconds long. Otherwise we'll just use
|
124
|
-
# all the values.
|
125
|
-
return lnrmssdx20 if gap_end - gap_start < 30
|
126
|
-
|
127
|
-
lnrmssdx20(gap_start, gap_end - gap_start)
|
120
|
+
# The ln_rmssd values are hard to interpret. Since we know the expected
|
121
|
+
# range we'll transform it into a value in the range 0 - 100. If the HRV
|
122
|
+
# is measured early in the morning while standing upright and with a
|
123
|
+
# regular 3s in/3s out breathing pattern the HRV Score is a performance
|
124
|
+
# indicator. The higher it is, the better the performance condition.
|
125
|
+
def hrv_score(start_time = 0.0, duration = nil)
|
126
|
+
ssd = ln_rmssd(start_time, duration)
|
127
|
+
ssd = LN_RMSSD_MIN if ssd < LN_RMSSD_MIN
|
128
|
+
ssd = LN_RMSSD_MAX if ssd > LN_RMSSD_MAX
|
129
|
+
|
130
|
+
(ssd - LN_RMSSD_MIN) * (100.0 / (LN_RMSSD_MAX - LN_RMSSD_MIN))
|
128
131
|
end
|
129
132
|
|
130
133
|
private
|
131
134
|
|
132
|
-
def
|
133
|
-
# The rr_intervals Array stores the beat-to-beat time intervals (R-R).
|
134
|
-
# If one or move beats have been skipped during measurement, a nil value
|
135
|
-
# is inserted.
|
136
|
-
@rr_intervals = []
|
135
|
+
def cleanup_rr_intervals(rr_intervals)
|
137
136
|
# The timestamps Array stores the relative (to start of sequence) time
|
138
137
|
# for each interval in the rr_intervals Array.
|
139
138
|
@timestamps = []
|
140
139
|
|
141
|
-
|
142
|
-
# to 5 R-R interval durations. If less than 5 are present, they are
|
143
|
-
# filled with nil.
|
144
|
-
raw_rr_intervals = []
|
145
|
-
@fit_file.hrv.each do |hrv|
|
146
|
-
raw_rr_intervals += hrv.time.compact
|
147
|
-
end
|
148
|
-
return if raw_rr_intervals.empty?
|
149
|
-
|
150
|
-
window = 20
|
151
|
-
intro_mean = raw_rr_intervals[0..4 * window].reduce(:+) / (4 * window)
|
152
|
-
predictor = LinearPredictor.new(window, intro_mean)
|
140
|
+
return if rr_intervals.empty?
|
153
141
|
|
154
|
-
# The timer accumulates the interval durations
|
142
|
+
# The timer accumulates the interval durations and keeps track of the
|
143
|
+
# timestamp of the current value with respect to the beging of the
|
144
|
+
# series.
|
155
145
|
timer = 0.0
|
156
|
-
|
157
|
-
|
146
|
+
clean_rr_intervals = []
|
147
|
+
@errors = 0
|
148
|
+
rr_intervals.each_with_index do |rr, i|
|
158
149
|
@timestamps << timer
|
159
150
|
|
160
|
-
#
|
161
|
-
#
|
162
|
-
#
|
163
|
-
#
|
164
|
-
# value
|
165
|
-
|
166
|
-
|
151
|
+
# The biggest source of errors are missed beats resulting in intervals
|
152
|
+
# that are twice or more as large as the regular intervals. We look at
|
153
|
+
# a window of values surrounding the current interval to determine
|
154
|
+
# what's normal. We assume that at least half the values are normal.
|
155
|
+
# When we sort the values by size, the middle value must be a good
|
156
|
+
# proxy for a normal value.
|
157
|
+
# Any values that are 1.8 times larger than the normal proxy value
|
158
|
+
# will be discarded and replaced by nil.
|
159
|
+
if rr > 1.8 * median_value(rr_intervals, i, 21)
|
160
|
+
clean_rr_intervals << nil
|
161
|
+
@errors += 1
|
167
162
|
else
|
168
|
-
|
169
|
-
# Feed the value into the predictor.
|
170
|
-
predictor.insert(dt)
|
163
|
+
clean_rr_intervals << rr
|
171
164
|
end
|
165
|
+
|
166
|
+
timer += rr
|
172
167
|
end
|
168
|
+
|
169
|
+
# This array holds the cleanedup heart rate variability values.
|
170
|
+
@hrv = []
|
171
|
+
0.upto(clean_rr_intervals.length - 2) do |i|
|
172
|
+
rr1 = clean_rr_intervals[i]
|
173
|
+
rr2 = clean_rr_intervals[i + 1]
|
174
|
+
if rr1.nil? || rr2.nil?
|
175
|
+
@hrv << nil
|
176
|
+
else
|
177
|
+
@hrv << (rr1 - rr2).abs
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Save the overall duration of the HRV samples.
|
182
|
+
@duration = timer
|
183
|
+
end
|
184
|
+
|
185
|
+
def median_value(ary, index, half_window_size)
|
186
|
+
low_i = index - half_window_size
|
187
|
+
low_i = 0 if low_i < 0
|
188
|
+
high_i = index + half_window_size
|
189
|
+
high_i = ary.length - 1 if high_i > ary.length - 1
|
190
|
+
values = ary[low_i..high_i].delete_if{ |v| v.nil? }.sort
|
191
|
+
|
192
|
+
median = values[values.length / 2]
|
173
193
|
end
|
174
194
|
|
175
195
|
end
|
@@ -18,8 +18,8 @@ module PostRunner
|
|
18
18
|
|
19
19
|
# Create a new LinearPredictor object.
|
20
20
|
# @param n [Fixnum] The number of coefficients the predictor should use.
|
21
|
-
def initialize(n
|
22
|
-
@values =
|
21
|
+
def initialize(n)
|
22
|
+
@values = []
|
23
23
|
@size = n
|
24
24
|
@next = nil
|
25
25
|
end
|
@@ -29,10 +29,12 @@ module PostRunner
|
|
29
29
|
def insert(value)
|
30
30
|
@values << value
|
31
31
|
|
32
|
-
if @values.length
|
32
|
+
if @values.length >= @size
|
33
33
|
@values.shift
|
34
|
-
@next = @values.reduce(:+) / @size
|
35
34
|
end
|
35
|
+
|
36
|
+
@next = @values.reduce(:+) / @values.size
|
37
|
+
$stderr.puts "insert(#{value}) next: #{@next}"
|
36
38
|
end
|
37
39
|
|
38
40
|
# @return [Float] The predicted value of the next sample.
|
@@ -72,7 +72,7 @@ module PostRunner
|
|
72
72
|
}
|
73
73
|
}
|
74
74
|
|
75
|
-
|
75
|
+
attr_persist :sport_records
|
76
76
|
|
77
77
|
class ActivityResult
|
78
78
|
|
@@ -94,7 +94,7 @@ module PostRunner
|
|
94
94
|
|
95
95
|
include Fit4Ruby::Converters
|
96
96
|
|
97
|
-
|
97
|
+
attr_persist :activity, :sport, :distance, :duration, :start_time
|
98
98
|
|
99
99
|
def initialize(p, result)
|
100
100
|
super(p)
|
@@ -123,7 +123,7 @@ module PostRunner
|
|
123
123
|
|
124
124
|
include Fit4Ruby::Converters
|
125
125
|
|
126
|
-
|
126
|
+
attr_persist :sport, :year, :distance_record, :speed_records
|
127
127
|
|
128
128
|
def initialize(p, sport, year)
|
129
129
|
super(p)
|
@@ -246,7 +246,7 @@ module PostRunner
|
|
246
246
|
|
247
247
|
class SportRecords < PEROBS::Object
|
248
248
|
|
249
|
-
|
249
|
+
attr_persist :sport, :all_time, :yearly
|
250
250
|
|
251
251
|
def initialize(p, sport)
|
252
252
|
super(p)
|
data/lib/postrunner/version.rb
CHANGED
data/postrunner.gemspec
CHANGED
@@ -29,7 +29,7 @@ well.}
|
|
29
29
|
spec.required_ruby_version = '>=2.0'
|
30
30
|
|
31
31
|
spec.add_dependency 'fit4ruby', '~> 1.6.1'
|
32
|
-
spec.add_dependency 'perobs', '~>
|
32
|
+
spec.add_dependency 'perobs', '~> 4.0.0'
|
33
33
|
spec.add_dependency 'nokogiri', '~> 1.6'
|
34
34
|
|
35
35
|
spec.add_development_dependency 'bundler', '~> 1.6'
|
@@ -0,0 +1,94 @@
|
|
1
|
+
#!/usr/bin/env ruby -w
|
2
|
+
# encoding: UTF-8
|
3
|
+
#
|
4
|
+
# = PostRunner_spec.rb -- PostRunner - Manage the data from your Garmin sport devices.
|
5
|
+
#
|
6
|
+
# Copyright (c) 2014 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 'spec_helper'
|
14
|
+
|
15
|
+
describe PostRunner::HRV_Analyzer do
|
16
|
+
|
17
|
+
it 'should cleanup the input data' do
|
18
|
+
rri = [ 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 1.1, 0.6,
|
19
|
+
0.6, 0.8, 0.6, 0.61, 0.59, 0.6, 0.6, 0.6,
|
20
|
+
0.6, 0.6, 0.2, 0.6, 0.6, 0.6, 0.6, 0.5,
|
21
|
+
0.6, 0.6, 0.6, 1.3, 0.6, 0.6, 0.6, 0.6 ]
|
22
|
+
hrv = PostRunner::HRV_Analyzer.new(rri)
|
23
|
+
expect(hrv.errors).to eql(2)
|
24
|
+
ts = [ 0.0, 0.6, 1.2, 1.8, 2.4, 3.0, 3.6, 4.7,
|
25
|
+
5.3, 5.9, 6.7, 7.3, 7.9, 8.5, 9.1, 9.7,
|
26
|
+
10.3, 10.9, 11.5, 11.7, 12.3, 12.9, 13.5, 14.1,
|
27
|
+
14.6, 15.2, 15.8, 16.4, 17.7, 18.3, 18.9, 19.5 ]
|
28
|
+
hrv.timestamps.each_with_index do |v, i|
|
29
|
+
expect(v).to be_within(0.01).of(ts[i])
|
30
|
+
end
|
31
|
+
expect(hrv.has_hrv_data?).to be false
|
32
|
+
expect(hrv.rmssd).to be_within(0.00001).of(124.81096817899)
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'should compute an HRV Score' do
|
36
|
+
rri = [
|
37
|
+
0.837, 0.831, 0.843, 0.867, 0.788, 0.984, 0.872, 0.891, 0.878, 0.864,
|
38
|
+
0.844, 0.818, 0.798, 0.791, 0.808, 0.866, 0.927, 0.951, 0.958, 0.943,
|
39
|
+
0.613, 1.2, 0.884, 0.884, 0.878, 0.873, 0.867, 0.875, 0.872, 0.871,
|
40
|
+
0.892, 0.943, 1.185, 0.788, 1.255, 0.636, 0.901, 0.896, 0.9, 0.915,
|
41
|
+
0.698, 1.148, 0.894, 0.872, 0.85, 0.86, 0.893, 0.941, 0.692, 1.233,
|
42
|
+
0.981, 0.926, 0.93, 0.928, 0.928, 0.93, 1.158, 0.68, 0.877, 0.915,
|
43
|
+
0.926, 0.933, 0.933, 0.924, 0.681, 1.133, 0.901, 0.892, 0.887, 0.877,
|
44
|
+
0.732, 0.968, 0.826, 0.824, 0.865, 0.905, 0.915, 0.935, 0.932, 0.924,
|
45
|
+
0.915, 0.945, 0.96, 0.963, 0.939, 0.92, 0.892, 0.669, 1.037, 0.806,
|
46
|
+
0.818, 0.847, 0.879, 0.922, 0.938, 0.952, 0.969, 1.018, 1.03, 1.004,
|
47
|
+
0.98, 0.948, 0.919, 0.894, 0.896, 0.905, 0.913, 0.925, 0.905, 0.879,
|
48
|
+
0.855, 0.857, 0.866, 0.878, 0.881, 0.884, 0.873, 0.857, 0.851, 0.864,
|
49
|
+
0.883, 0.895, 0.898, 0.898, 0.876, 0.853, 0.841, 0.85, 0.857, 0.852,
|
50
|
+
0.861, 0.867, 0.869, 0.858, 0.844, 0.856, 0.869, 0.879, 0.886, 0.89,
|
51
|
+
0.876, 0.857, 0.843, 0.839, 0.838, 0.843, 0.845, 0.856, 0.856, 0.85,
|
52
|
+
0.838, 0.842, 0.844, 0.842, 0.834, 0.832, 0.818, 0.81, 0.801, 0.78,
|
53
|
+
0.797, 0.816, 0.838, 0.85, 0.845, 0.841, 0.84, 0.837, 0.859, 0.874,
|
54
|
+
0.89, 0.896, 0.893, 0.879, 0.863, 0.855, 0.87, 0.875, 0.861, 0.854,
|
55
|
+
0.843, 0.836, 0.822, 0.813, 0.806, 0.81, 0.824, 0.834, 0.847, 0.867,
|
56
|
+
0.877, 0.883, 0.877, 0.856, 0.872, 0.88, 0.87, 0.861, 0.855, 0.852,
|
57
|
+
0.84, 0.832, 0.82, 0.827, 0.838, 0.854, 0.881, 0.893, 0.857
|
58
|
+
]
|
59
|
+
|
60
|
+
hrv = PostRunner::HRV_Analyzer.new(rri)
|
61
|
+
expect(hrv.data_quality).to be_within(0.00001).of(100)
|
62
|
+
expect(hrv.ln_rmssd(0.0, 90)).to be_within(0.00001).of(5.188390931)
|
63
|
+
expect(hrv.ln_rmssd(90, 90)).to be_within(0.00001).of(2.549616730)
|
64
|
+
expect(hrv.rmssd(0.0, 90)).to be_within(0.00001).of(179.1800079)
|
65
|
+
expect(hrv.hrv_score(0.0, 60)).to be_within(0.00001).of(100.0)
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'should find the right interval for a HRV score computation' do
|
69
|
+
rri = [
|
70
|
+
0.752, 0.759, 0.755, 0.741, 0.733, 0.738, 0.751, 0.767, 0.774, 0.777,
|
71
|
+
0.771, 0.787, 0.795, 0.805, 0.797, 0.78, 0.77, 0.764, 0.766, 0.771,
|
72
|
+
0.764, 0.763, 0.776, 0.777, 0.785, 0.78, 0.768, 0.757, 0.745, 0.737,
|
73
|
+
0.724, 0.709, 0.695, 0.699, 0.703, 0.719, 0.726, 0.73, 0.733, 0.739,
|
74
|
+
0.744, 0.744, 0.738, 0.725, 0.713, 0.706, 0.705, 0.7, 0.694, 0.697,
|
75
|
+
0.706, 0.716, 0.728, 0.73, 0.731, 0.742, 0.748, 0.742, 0.733, 0.731,
|
76
|
+
0.729, 0.727, 0.712, 0.712, 0.715, 0.712, 0.706, 0.707, 0.729, 0.762,
|
77
|
+
0.773, 0.768, 0.78, 0.78, 0.771, 0.75, 0.736, 0.719, 0.704, 0.69, 0.683,
|
78
|
+
0.688, 0.703, 0.732, 0.742, 0.751, 0.758, 0.783, 0.786, 0.764, 0.752,
|
79
|
+
0.733, 0.722, 0.711, 0.694, 0.687, 0.69, 0.707, 0.722, 0.732, 0.761,
|
80
|
+
0.783, 0.805, 0.795, 0.779, 0.76, 0.744, 0.726, 0.707, 0.692, 0.688,
|
81
|
+
0.694, 0.695, 0.708, 0.729, 0.761, 0.776, 0.787, 0.799, 0.795, 0.773,
|
82
|
+
0.755, 0.738, 0.721, 0.71, 0.701, 0.692, 0.698, 0.712, 0.73, 0.736,
|
83
|
+
0.732, 0.722, 0.72, 0.712, 0.709, 0.695, 0.687, 0.688, 0.684, 0.687,
|
84
|
+
0.685, 0.685, 0.684, 0.689, 0.705, 0.716, 0.712, 0.71, 0.732, 0.75,
|
85
|
+
0.755, 0.757, 0.758, 0.759, 0.753, 0.748, 0.748, 0.724, 0.715, 0.721,
|
86
|
+
0.727, 0.743, 0.741, 0.743, 0.757, 0.765, 0.774, 0.781, 0.77, 0.745,
|
87
|
+
0.729, 0.707
|
88
|
+
]
|
89
|
+
hrv = PostRunner::HRV_Analyzer.new(rri)
|
90
|
+
expect(hrv.hrv_score).to be_within(0.00001).of(0.0)
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
94
|
+
|
data/spec/PostRunner_spec.rb
CHANGED
@@ -90,15 +90,15 @@ describe PostRunner::Main do
|
|
90
90
|
|
91
91
|
it 'should list the imported file' do
|
92
92
|
v = postrunner(%w( list ))
|
93
|
-
expect(v[:stdout].index(File.basename(@file1))).to be_a(
|
93
|
+
expect(v[:stdout].index(File.basename(@file1))).to be_a(Integer)
|
94
94
|
end
|
95
95
|
|
96
96
|
it 'should import the 2nd FIT file' do
|
97
97
|
postrunner([ 'import', @file2 ])
|
98
98
|
v = postrunner(%w( list ))
|
99
99
|
list = v[:stdout]
|
100
|
-
expect(list.index(File.basename(@file1))).to be_a(
|
101
|
-
expect(list.index(File.basename(@file2))).to be_a(
|
100
|
+
expect(list.index(File.basename(@file1))).to be_a(Integer)
|
101
|
+
expect(list.index(File.basename(@file2))).to be_a(Integer)
|
102
102
|
end
|
103
103
|
|
104
104
|
it 'should delete the first file' do
|
@@ -106,7 +106,7 @@ describe PostRunner::Main do
|
|
106
106
|
v = postrunner(%w( list ))
|
107
107
|
list = v[:stdout]
|
108
108
|
expect(list.index(File.basename(@file1))).to be_nil
|
109
|
-
expect(list.index(File.basename(@file2))).to be_a(
|
109
|
+
expect(list.index(File.basename(@file2))).to be_a(Integer)
|
110
110
|
end
|
111
111
|
|
112
112
|
it 'should not import the deleted file again' do
|
@@ -114,7 +114,7 @@ describe PostRunner::Main do
|
|
114
114
|
v = postrunner(%w( list ))
|
115
115
|
list = v[:stdout]
|
116
116
|
expect(list.index(File.basename(@file1))).to be_nil
|
117
|
-
expect(list.index(File.basename(@file2))).to be_a(
|
117
|
+
expect(list.index(File.basename(@file2))).to be_a(Integer)
|
118
118
|
end
|
119
119
|
|
120
120
|
it 'should rename FILE2.FIT activity' do
|
@@ -122,7 +122,7 @@ describe PostRunner::Main do
|
|
122
122
|
v = postrunner(%w( list ))
|
123
123
|
list = v[:stdout]
|
124
124
|
expect(list.index(File.basename(@file2))).to be_nil
|
125
|
-
expect(list.index('foobar')).to be_a(
|
125
|
+
expect(list.index('foobar')).to be_a(Integer)
|
126
126
|
end
|
127
127
|
|
128
128
|
it 'should fail when setting bad attribute' do
|
@@ -135,7 +135,7 @@ describe PostRunner::Main do
|
|
135
135
|
v = postrunner(%w( list ))
|
136
136
|
list = v[:stdout]
|
137
137
|
expect(list.index(@file2)).to be_nil
|
138
|
-
expect(list.index('foobar')).to be_a(
|
138
|
+
expect(list.index('foobar')).to be_a(Integer)
|
139
139
|
end
|
140
140
|
|
141
141
|
it 'should set activity type for 2nd activity' do
|
@@ -143,7 +143,7 @@ describe PostRunner::Main do
|
|
143
143
|
v = postrunner(%w( summary :1 ))
|
144
144
|
list = v[:stdout]
|
145
145
|
expect(list.index('Running')).to be_nil
|
146
|
-
expect(list.index('Cycling')).to be_a(
|
146
|
+
expect(list.index('Cycling')).to be_a(Integer)
|
147
147
|
end
|
148
148
|
|
149
149
|
it 'should list the events of an activity' do
|
@@ -164,7 +164,7 @@ describe PostRunner::Main do
|
|
164
164
|
v = postrunner(%w( summary :1 ))
|
165
165
|
list = v[:stdout]
|
166
166
|
expect(list.index('Generic')).to be_nil
|
167
|
-
expect(list.index('Road')).to be_a(
|
167
|
+
expect(list.index('Road')).to be_a(Integer)
|
168
168
|
end
|
169
169
|
|
170
170
|
it 'should fail when setting bad activity subtype' do
|
@@ -193,13 +193,13 @@ describe PostRunner::Main do
|
|
193
193
|
postrunner([ 'import', '--force', @file1 ])
|
194
194
|
v = postrunner([ 'records' ])
|
195
195
|
list = v[:stdout]
|
196
|
-
expect(list.index(File.basename(@file1))).to be_a(
|
196
|
+
expect(list.index(File.basename(@file1))).to be_a(Integer)
|
197
197
|
|
198
198
|
# Add fast running activity
|
199
199
|
postrunner([ 'import', @file3 ])
|
200
200
|
v =postrunner([ 'records' ])
|
201
201
|
list = v[:stdout]
|
202
|
-
expect(list.index(File.basename(@file3))).to be_a(
|
202
|
+
expect(list.index(File.basename(@file3))).to be_a(Integer)
|
203
203
|
expect(list.index(File.basename(@file1))).to be_nil
|
204
204
|
end
|
205
205
|
|
@@ -207,7 +207,7 @@ describe PostRunner::Main do
|
|
207
207
|
postrunner(%w( set norecord true :1 ))
|
208
208
|
v = postrunner([ 'records' ])
|
209
209
|
list = v[:stdout]
|
210
|
-
expect(list.index(File.basename(@file1))).to be_a(
|
210
|
+
expect(list.index(File.basename(@file1))).to be_a(Integer)
|
211
211
|
expect(list.index(File.basename(@file3))).to be_nil
|
212
212
|
end
|
213
213
|
|
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.9.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: 2017-
|
11
|
+
date: 2017-11-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: fit4ruby
|
@@ -30,14 +30,14 @@ dependencies:
|
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
33
|
+
version: 4.0.0
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
40
|
+
version: 4.0.0
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: nokogiri
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -314,6 +314,7 @@ files:
|
|
314
314
|
- spec/ActivitySummary_spec.rb
|
315
315
|
- spec/FitFileStore_spec.rb
|
316
316
|
- spec/FlexiTable_spec.rb
|
317
|
+
- spec/HRV_Analyzer_spec.rb
|
317
318
|
- spec/PersonalRecords_spec.rb
|
318
319
|
- spec/PostRunner_spec.rb
|
319
320
|
- spec/View_spec.rb
|
@@ -349,6 +350,7 @@ test_files:
|
|
349
350
|
- spec/ActivitySummary_spec.rb
|
350
351
|
- spec/FitFileStore_spec.rb
|
351
352
|
- spec/FlexiTable_spec.rb
|
353
|
+
- spec/HRV_Analyzer_spec.rb
|
352
354
|
- spec/PersonalRecords_spec.rb
|
353
355
|
- spec/PostRunner_spec.rb
|
354
356
|
- spec/View_spec.rb
|