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 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