postrunner 0.8.1 → 0.9.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/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
|