diff options
Diffstat (limited to 'lib/postrunner/HRV_Analyzer.rb')
-rw-r--r-- | lib/postrunner/HRV_Analyzer.rb | 150 |
1 files changed, 111 insertions, 39 deletions
diff --git a/lib/postrunner/HRV_Analyzer.rb b/lib/postrunner/HRV_Analyzer.rb index 60d6134..8375a9b 100644 --- a/lib/postrunner/HRV_Analyzer.rb +++ b/lib/postrunner/HRV_Analyzer.rb @@ -10,6 +10,8 @@ # published by the Free Software Foundation. # +require 'postrunner/LinearPredictor' + module PostRunner # This class analyzes the heart rate variablity based on the R-R intervals @@ -17,85 +19,155 @@ module PostRunner # quality is good enough. class HRV_Analyzer + attr_reader :rr_intervals, :timestamps + + # Create a new HRV_Analyzer object. + # @param fit_file [Fit4Ruby::Activity] FIT file to analyze. def initialize(fit_file) @fit_file = fit_file collect_rr_intervals end # The method can be used to check if we have valid HRV data. The FIT file - # must have HRV data, it must have correctly measured 90% of the beats and - # the measurement duration must be at least 30 seconds. + # must have HRV data and the measurement duration must be at least 30 + # seconds. def has_hrv_data? - !@fit_file.hrv.empty? && - @missed_beats < (0.1 * @rr_intervals.length) && - total_duration > 30.0 + !@fit_file.hrv.empty? && total_duration > 30.0 end # Return the total duration of all measured intervals in seconds. def total_duration - @rr_intervals.inject(:+) + @timestamps[-1] end - # root mean square of successive differences - def rmssd + # Compute the root mean square of successive differences. + # @param start_time [Float] Determines at what time mark (in seconds) the + # computation should start. + # @param duration [Float] The duration of the total inteval in seconds to + # be considered for the computation. This value should be larger + # then 30 seconds to produce meaningful values. + def rmssd(start_time = 0.0, duration = nil) + # Find the start index based on the requested interval start time. + start_idx = 0 + @timestamps.each do |ts| + break if ts >= start_time + start_idx += 1 + end + # Find the end index based on the requested interval duration. + if duration + end_time = start_time + duration + end_idx = start_idx + while end_idx < (@timestamps.length - 1) && + @timestamps[end_idx] < end_time + end_idx += 1 + end + else + end_idx = -1 + end + last_i = nil sum = 0.0 - @rr_intervals.each do |i| - if last_i + cnt = 0 + @rr_intervals[start_idx..end_idx].each do |i| + if i && last_i sum += (last_i - i) ** 2.0 + cnt += 1 end last_i = i end - Math.sqrt(sum / (@rr_intervals.length - 1)) + + Math.sqrt(sum / cnt) end # The RMSSD value is not very easy to memorize. Alternatively, we can # multiply the natural logarithm of RMSSD by -20. This usually results in - # values between 40 (for untrained) and 100 (for higly trained) athletes. - def lnrmssdx20 - (-20.0 * Math.log(rmssd)).round.to_i + # values between 1.0 (for untrained) and 100.0 (for higly trained) + # athletes. Values larger than 100.0 are rare but possible. + # @param start_time [Float] Determines at what time mark (in seconds) the + # computation should start. + # @param duration [Float] The duration of the total inteval in seconds to + # be considered for the computation. This value should be larger + # then 30 seconds to produce meaningful values. + def lnrmssdx20(start_time = 0.0, duration = nil) + -20.0 * Math.log(rmssd(start_time, duration)) + end + + # This method is similar to lnrmssdx20 but it tries to search the data for + # the best time period to compute the lnrmssdx20 value from. + def lnrmssdx20_1sigma + # Create a new Array that consists of rr_intervals and timestamps + # tuples. + set = [] + 0.upto(@rr_intervals.length - 1) do |i| + set << [ @rr_intervals[i] ? @rr_intervals[i] : 0.0, @timestamps[i] ] + end + + percentiles = Percentiles.new(set) + # Compile a list of all tuples with rr_intervals that are outside of the + # PT84 (aka +1sigma range. Sort the list by time. + not_1sigma = percentiles.not_tp_x(84.13).sort { |e1, e2| e1[1] <=> e2[1] } + + # Then find the largest time gap in that list. So all the values in that + # gap are within TP84. + gap_start = gap_end = 0 + last = nil + not_1sigma.each do |e| + if last + if (e[1] - last) > (gap_end - gap_start) + gap_start = last + gap_end = e[1] + end + end + last = e[1] + end + # That gap should be at least 30 seconds long. Otherwise we'll just use + # all the values. + return lnrmssdx20 if gap_end - gap_start < 30 + + lnrmssdx20(gap_start, gap_end - gap_start) end private def collect_rr_intervals + # Each Fit4Ruby::HRV object has an Array called 'time' that contains up + # to 5 R-R interval durations. If less than 5 are present, they are + # filled with nil. raw_rr_intervals = [] @fit_file.hrv.each do |hrv| raw_rr_intervals += hrv.time.compact end - prev_dts = [] - avg_dt = nil - @missed_beats = 0 + window = 20 + intro_mean = raw_rr_intervals[0..4 * window].reduce(:+) / (4 * window) + predictor = LinearPredictor.new(window, intro_mean) + + # The rr_intervals Array stores the beat-to-beat time intervals (R-R). + # If one or move beats have been skipped during measurement, a nil value + # is inserted. @rr_intervals = [] + # The timestamps Array stores the relative (to start of sequence) time + # for each interval in the rr_intervals Array. + @timestamps = [] + + # The timer accumulates the interval durations. + timer = 0.0 raw_rr_intervals.each do |dt| + timer += dt + @timestamps << timer + # Sometimes the hrv data is missing one or more beats. The next - # detected beat is then listed with a time interval since the last + # detected beat is than listed with the time interval since the last # detected beat. We try to detect these skipped beats by looking for - # time intervals that are 1.8 or more times larger than the average of - # the last 5 good intervals. - if avg_dt && dt > 1.8 * avg_dt - # If we have found skipped beats we calcluate how many beats were - # skipped. - skip = (dt / avg_dt).round.to_i - # We count the total number of skipped beats. We don't use the HRV - # data if too many beats were skipped. - @missed_beats += skip - # Insert skip times the average skipped beat intervals. - skip.times do - new_dt = dt / skip - @rr_intervals << new_dt - prev_dts << new_dt - prev_dts.shift if prev_dts.length > 5 - end + # time intervals that are 1.5 or more times larger than the predicted + # value for this interval. + if (next_dt = predictor.predict) && dt > 1.5 * next_dt + @rr_intervals << nil else @rr_intervals << dt - # We keep a list of the previous 5 good intervals and compute the - # average value of them. - prev_dts << dt - prev_dts.shift if prev_dts.length > 5 + # Feed the value into the predictor. + predictor.insert(dt) end - avg_dt = prev_dts.inject(:+) / prev_dts.length end end |