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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1ea85f9119389650b496a24e3adc7a960c5f0720
4
- data.tar.gz: 622774385cb1a22076bf7d7cfbf6b760c1d34ada
3
+ metadata.gz: 30fc3ab83ce783076286638ede5faf67d2b5adbf
4
+ data.tar.gz: 465e09b0a7dac4cfdbcb129886f50afae1802680
5
5
  SHA512:
6
- metadata.gz: e70a68cd0332762fc5255e4f9977a19c8be2bb1297f78dbb7b597c9727ebad0c1a769b0991ee568f43622ba6360c61763ae328ac75c9e7112fe242df974840b8
7
- data.tar.gz: 6f8cb38a8e094bec344f5ac5ab7dc1bbaba0d5ba6f0d2d39eec1ccdace18ad964c5ef8dcd1120968a7206043260e37879a22c5b7cc56fc8539dbef8da1a954e6
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([ 'Date:', session.timestamp ])
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(@fit_activity)
163
- if hrv.has_hrv_data?
164
- t.row([ 'HRV Score:', "%.1f" % hrv.lnrmssdx20_1sigma ])
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
@@ -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(@activity.fit_activity)
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 => 'HRV Score (30s Window)',
73
- :short_label => 'HRV Score',
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
- 0.upto(@hrv_analyzer.total_duration.to_i - 30) do |t|
274
- next unless (hrv_score = @hrv_analyzer.lnrmssdx20(t, 30)) > 0.0
275
- min_value = hrv_score if min_value.nil? || min_value > hrv_score
276
- data_set << [ t * 1000, hrv_score ]
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
- 1.upto(@hrv_analyzer.rr_intervals.length - 1) do |idx|
280
- curr_intvl = @hrv_analyzer.rr_intervals[idx]
281
- prev_intvl = @hrv_analyzer.rr_intervals[idx - 1]
282
- next unless curr_intvl && prev_intvl
283
-
284
- # Convert the R-R interval duration to ms.
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
- value = r.get_as(chart[:id], chart[:unit] || '')
292
-
293
- next unless value
294
-
295
- if chart[:id] == 'pace'
296
- # Slow speeds lead to very large pace values that make the graph
297
- # hard to read. We cap the pace at 20.0 min/km to keep it readable.
298
- if value > (@unit_system == :metric ? 20.0 : 36.0 )
299
- value = nil
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
- value = (value * 3600.0 * 1000).to_i
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
- data_set << [ ((r.timestamp.to_i - start_time) * 1000).to_i, value ]
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
- return (DeviceList::DeviceTypeNames[device.device_type] ||
79
- device.device_type) + " [#{device.device_index}]"
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
- po_attr :device, :fit_file_name, :norecord, :name, :note, :sport,
100
- :sub_sport, :timestamp, :total_distance, :total_timer_time,
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
- private
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
- po_attr :activities, :monitorings, :short_uid, :long_uid
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
- po_attr :device, :fit_file_name, :name, :period_start, :period_end
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
- po_attr :devices
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.sort
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.sort do |a1, a2|
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/LinearPredictor'
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 :rr_intervals, :timestamps
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 fit_file [Fit4Ruby::Activity] FIT file to analyze.
26
- def initialize(fit_file)
27
- @fit_file = fit_file
28
- collect_rr_intervals
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
- !@fit_file.hrv.empty? && total_duration > 30.0
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
- @rr_intervals[start_idx..end_idx].each do |i|
72
- if i && last_i
73
- sum += (last_i - i) ** 2.0
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 RMSSD value is not very easy to memorize. Alternatively, we can
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 lnrmssdx20(start_time = 0.0, duration = nil)
92
- -20.0 * Math.log(rmssd(start_time, duration))
116
+ def ln_rmssd(start_time = 0.0, duration = nil)
117
+ Math.log(rmssd(start_time, duration))
93
118
  end
94
119
 
95
- # This method is similar to lnrmssdx20 but it tries to search the data for
96
- # the best time period to compute the lnrmssdx20 value from.
97
- def lnrmssdx20_1sigma
98
- # Create a new Array that consists of rr_intervals and timestamps
99
- # tuples.
100
- set = []
101
- 0.upto(@rr_intervals.length - 1) do |i|
102
- set << [ @rr_intervals[i] ? @rr_intervals[i] : 0.0, @timestamps[i] ]
103
- end
104
-
105
- percentiles = Percentiles.new(set)
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 collect_rr_intervals
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
- # Each Fit4Ruby::HRV object has an Array called 'time' that contains up
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
- raw_rr_intervals.each do |dt|
157
- timer += dt
146
+ clean_rr_intervals = []
147
+ @errors = 0
148
+ rr_intervals.each_with_index do |rr, i|
158
149
  @timestamps << timer
159
150
 
160
- # Sometimes the hrv data is missing one or more beats. The next
161
- # detected beat is than listed with the time interval since the last
162
- # detected beat. We try to detect these skipped beats by looking for
163
- # time intervals that are 1.5 or more times larger than the predicted
164
- # value for this interval.
165
- if (next_dt = predictor.predict) && dt > 1.5 * next_dt
166
- @rr_intervals << nil
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
- @rr_intervals << dt
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, default = nil)
22
- @values = Array.new(n, default)
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 > @size
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
- po_attr :sport_records
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
- po_attr :activity, :sport, :distance, :duration, :start_time
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
- po_attr :sport, :year, :distance_record, :speed_records
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
- po_attr :sport, :all_time, :yearly
249
+ attr_persist :sport, :all_time, :yearly
250
250
 
251
251
  def initialize(p, sport)
252
252
  super(p)
@@ -11,5 +11,5 @@
11
11
  #
12
12
 
13
13
  module PostRunner
14
- VERSION = '0.8.1'
14
+ VERSION = '0.9.0'
15
15
  end
@@ -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', '~> 3.0.1'
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
+
@@ -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(Fixnum)
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(Fixnum)
101
- expect(list.index(File.basename(@file2))).to be_a(Fixnum)
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(Fixnum)
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(Fixnum)
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(Fixnum)
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(Fixnum)
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(Fixnum)
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(Fixnum)
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(Fixnum)
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(Fixnum)
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(Fixnum)
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.8.1
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-08-14 00:00:00.000000000 Z
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: 3.0.1
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: 3.0.1
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