postrunner 0.0.11 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = HRV_Analyzer.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015 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 'postrunner/LinearPredictor'
14
+
15
+ module PostRunner
16
+
17
+ # This class analyzes the heart rate variablity based on the R-R intervals
18
+ # in the given FIT file. It can compute RMSSD and a HRV score if the data
19
+ # quality is good enough.
20
+ class HRV_Analyzer
21
+
22
+ attr_reader :rr_intervals, :timestamps
23
+
24
+ # 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
29
+ end
30
+
31
+ # The method can be used to check if we have valid HRV data. The FIT file
32
+ # must have HRV data and the measurement duration must be at least 30
33
+ # seconds.
34
+ def has_hrv_data?
35
+ !@fit_file.hrv.empty? && total_duration > 30.0
36
+ end
37
+
38
+ # Return the total duration of all measured intervals in seconds.
39
+ def total_duration
40
+ @timestamps[-1]
41
+ end
42
+
43
+ # Compute the root mean square of successive differences.
44
+ # @param start_time [Float] Determines at what time mark (in seconds) the
45
+ # computation should start.
46
+ # @param duration [Float] The duration of the total inteval in seconds to
47
+ # be considered for the computation. This value should be larger
48
+ # then 30 seconds to produce meaningful values.
49
+ def rmssd(start_time = 0.0, duration = nil)
50
+ # Find the start index based on the requested interval start time.
51
+ start_idx = 0
52
+ @timestamps.each do |ts|
53
+ break if ts >= start_time
54
+ start_idx += 1
55
+ end
56
+ # Find the end index based on the requested interval duration.
57
+ if duration
58
+ end_time = start_time + duration
59
+ end_idx = start_idx
60
+ while end_idx < (@timestamps.length - 1) &&
61
+ @timestamps[end_idx] < end_time
62
+ end_idx += 1
63
+ end
64
+ else
65
+ end_idx = -1
66
+ end
67
+
68
+ last_i = nil
69
+ sum = 0.0
70
+ cnt = 0
71
+ @rr_intervals[start_idx..end_idx].each do |i|
72
+ if i && last_i
73
+ sum += (last_i - i) ** 2.0
74
+ cnt += 1
75
+ end
76
+ last_i = i
77
+ end
78
+
79
+ Math.sqrt(sum / cnt)
80
+ end
81
+
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.
86
+ # @param start_time [Float] Determines at what time mark (in seconds) the
87
+ # computation should start.
88
+ # @param duration [Float] The duration of the total inteval in seconds to
89
+ # be considered for the computation. This value should be larger
90
+ # 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))
93
+ end
94
+
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)
128
+ end
129
+
130
+ private
131
+
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 = []
137
+ # The timestamps Array stores the relative (to start of sequence) time
138
+ # for each interval in the rr_intervals Array.
139
+ @timestamps = []
140
+
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)
153
+
154
+ # The timer accumulates the interval durations.
155
+ timer = 0.0
156
+ raw_rr_intervals.each do |dt|
157
+ timer += dt
158
+ @timestamps << timer
159
+
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
167
+ else
168
+ @rr_intervals << dt
169
+ # Feed the value into the predictor.
170
+ predictor.insert(dt)
171
+ end
172
+ end
173
+ end
174
+
175
+ end
176
+
177
+ end
178
+
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = LinearPredictor.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015 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
+ module PostRunner
14
+
15
+ # For now we use a trivial adaptive linear predictor that just uses the
16
+ # average of past values to predict the next value.
17
+ class LinearPredictor
18
+
19
+ # Create a new LinearPredictor object.
20
+ # @param n [Fixnum] The number of coefficients the predictor should use.
21
+ def initialize(n, default = nil)
22
+ @values = Array.new(n, default)
23
+ @size = n
24
+ @next = nil
25
+ end
26
+
27
+ # Tell the predictor about the actual next value.
28
+ # @param value [Float] next value
29
+ def insert(value)
30
+ @values << value
31
+
32
+ if @values.length > @size
33
+ @values.shift
34
+ @next = @values.reduce(:+) / @size
35
+ end
36
+ end
37
+
38
+ # @return [Float] The predicted value of the next sample.
39
+ def predict
40
+ @next
41
+ end
42
+
43
+ end
44
+
45
+ end
46
+
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # = Main.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
5
  #
6
- # Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
6
+ # Copyright (c) 2014, 2015, 2016 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
@@ -16,7 +16,10 @@ require 'perobs'
16
16
 
17
17
  require 'postrunner/version'
18
18
  require 'postrunner/Log'
19
+ require 'postrunner/DirUtils'
19
20
  require 'postrunner/RuntimeConfig'
21
+ require 'postrunner/FitFileStore'
22
+ require 'postrunner/PersonalRecords'
20
23
  require 'postrunner/ActivitiesDB'
21
24
  require 'postrunner/MonitoringDB'
22
25
  require 'postrunner/EPO_Downloader'
@@ -25,20 +28,40 @@ module PostRunner
25
28
 
26
29
  class Main
27
30
 
31
+ include DirUtils
32
+
28
33
  def initialize(args)
29
34
  @filter = nil
30
35
  @name = nil
36
+ @force = false
31
37
  @attribute = nil
32
38
  @value = nil
33
- @activities = nil
34
- @monitoring = nil
35
39
  @db_dir = File.join(ENV['HOME'], '.postrunner')
36
40
 
37
41
  return if (args = parse_options(args)).nil?
38
42
 
39
- @cfg = RuntimeConfig.new(@db_dir)
43
+ create_directory(@db_dir, 'PostRunner data')
40
44
  @db = PEROBS::Store.new(File.join(@db_dir, 'database'))
45
+ # Create a hash to store configuration data in the store unless it
46
+ # exists already.
47
+ cfg = (@db['config'] ||= @db.new(PEROBS::Hash))
48
+ cfg['unit_system'] ||= :metric
49
+ cfg['version'] ||= VERSION
50
+ # We always override the data_dir as the user might have moved the data
51
+ # directory. The only reason we store it in the DB is to have it
52
+ # available throught the application.
53
+ cfg['data_dir'] = @db_dir
54
+ # Always update html_dir setting so that the DB directory can be moved
55
+ # around by the user.
56
+ cfg['html_dir'] = File.join(@db_dir, 'html')
57
+
58
+ setup_directories
59
+ if (errors = @db.check) != 0
60
+ Log.fatal "Postrunner database is corrupted: #{errors} errors found"
61
+ end
41
62
  execute_command(args)
63
+
64
+ @db.sync
42
65
  end
43
66
 
44
67
  private
@@ -81,6 +104,11 @@ EOT
81
104
  @filter = Fit4Ruby::FitFilter.new unless @filter
82
105
  @filter.ignore_undef = true
83
106
  end
107
+ opts.on('--force',
108
+ 'Import files even if they have been deleted from the ' +
109
+ 'database before.') do
110
+ @force = true
111
+ end
84
112
 
85
113
  opts.separator ""
86
114
  opts.separator "Options for the 'import' command:"
@@ -104,7 +132,7 @@ EOT
104
132
  return nil
105
133
  end
106
134
  opts.on('--version', 'Show version number') do
107
- $stderr.puts VERSION
135
+ puts VERSION
108
136
  return nil
109
137
  end
110
138
 
@@ -119,6 +147,9 @@ check [ <fit file> | <ref> ... ]
119
147
  dump <fit file> | <ref>
120
148
  Dump the content of the FIT file.
121
149
 
150
+ events [ <ref> ]
151
+ List all the events of the specified activies.
152
+
122
153
  import [ <fit file> | <directory> ]
123
154
  Import the provided FIT file(s) into the postrunner database. If no
124
155
  file or directory is provided, the directory that was used for the
@@ -187,16 +218,50 @@ EOT
187
218
  end
188
219
  end
189
220
 
221
+ def setup_directories
222
+ create_directory(@db['config']['html_dir'], 'HTML output')
223
+
224
+ %w( icons jquery flot openlayers postrunner ).each do |dir|
225
+ # This file should be in lib/postrunner. The 'misc' directory should be
226
+ # found in '../../misc'.
227
+ misc_dir = File.realpath(File.join(File.dirname(__FILE__),
228
+ '..', '..', 'misc'))
229
+ unless Dir.exists?(misc_dir)
230
+ Log.fatal "Cannot find 'misc' directory under '#{misc_dir}': #{$!}"
231
+ end
232
+ src_dir = File.join(misc_dir, dir)
233
+ unless Dir.exists?(src_dir)
234
+ Log.fatal "Cannot find '#{src_dir}': #{$!}"
235
+ end
236
+ dst_dir = @db['config']['html_dir']
237
+
238
+ begin
239
+ FileUtils.cp_r(src_dir, dst_dir)
240
+ rescue IOError
241
+ Log.fatal "Cannot copy auxilliary data directory '#{dst_dir}': #{$!}"
242
+ end
243
+ end
244
+ end
245
+
190
246
  def execute_command(args)
191
- @activities = ActivitiesDB.new(@db_dir, @cfg)
192
- @monitoring = MonitoringDB.new(@db, @cfg)
247
+ # Create or load the FitFileStore data.
248
+ unless (@ffs = @db['file_store'])
249
+ @ffs = @db['file_store'] = @db.new(FitFileStore)
250
+ end
251
+ # Create or load the PersonalRecords data.
252
+ unless (@records = @db['records'])
253
+ @records = @db['records'] = @db.new(PersonalRecords)
254
+ end
193
255
  handle_version_update
256
+ import_legacy_archive
194
257
 
195
258
  case (cmd = args.shift)
196
259
  when 'check'
197
260
  if args.empty?
198
- @activities.check
199
- @activities.generate_all_html_reports
261
+ @ffs.check
262
+ Log.info "Datebase cleanup started. Please wait ..."
263
+ @db.gc
264
+ Log.info "Database cleanup finished"
200
265
  else
201
266
  process_files_or_activities(args, :check)
202
267
  end
@@ -205,23 +270,25 @@ EOT
205
270
  when 'dump'
206
271
  @filter = Fit4Ruby::FitFilter.new unless @filter
207
272
  process_files_or_activities(args, :dump)
273
+ when 'events'
274
+ process_files_or_activities(args, :events)
208
275
  when 'import'
209
276
  if args.empty?
210
277
  # If we have no file or directory for the import command, we get the
211
278
  # most recently used directory from the runtime config.
212
- process_files([ @cfg.get_option(:import_dir) ], :import)
279
+ process_files([ @db['config']['import_dir'] ], :import)
213
280
  else
214
281
  process_files(args, :import)
215
282
  if args.length == 1 && Dir.exists?(args[0])
216
283
  # If only one directory was specified as argument we store the
217
284
  # directory for future use.
218
- @cfg.set_option(:import_dir, args[0])
285
+ @db['config']['import_dir'] = args[0]
219
286
  end
220
287
  end
221
288
  when 'list'
222
- @activities.list
289
+ @ffs.list_activities
223
290
  when 'records'
224
- @activities.show_records
291
+ puts @records.to_s
225
292
  when 'rename'
226
293
  unless (@name = args.shift)
227
294
  Log.fatal 'You must provide a new name for the activity'
@@ -237,7 +304,7 @@ EOT
237
304
  process_activities(args, :set)
238
305
  when 'show'
239
306
  if args.empty?
240
- @activities.show_list_in_browser
307
+ @ffs.show_list_in_browser
241
308
  else
242
309
  process_activities(args, :show)
243
310
  end
@@ -277,7 +344,7 @@ EOT
277
344
 
278
345
  activity_refs.each do |a_ref|
279
346
  if a_ref[0] == ':'
280
- activities = @activities.find(a_ref[1..-1])
347
+ activities = @ffs.find(a_ref[1..-1])
281
348
  if activities.empty?
282
349
  Log.warn "No matching activities found for '#{a_ref}'"
283
350
  return
@@ -287,6 +354,8 @@ EOT
287
354
  Log.fatal "Activity references must start with ':': #{a_ref}"
288
355
  end
289
356
  end
357
+
358
+ nil
290
359
  end
291
360
 
292
361
  def process_files(files_or_dirs, command)
@@ -334,9 +403,9 @@ EOT
334
403
  end
335
404
 
336
405
  if fit_entity.is_a?(Fit4Ruby::Activity)
337
- return @activities.add(fit_file_name, fit_entity)
338
- elsif fit_entity.is_a?(Fit4Ruby::Monitoring_B)
339
- return @monitoring.add(fit_file_name, fit_entity)
406
+ return @ffs.add_fit_file(fit_file_name, fit_entity, @force)
407
+ #elsif fit_entity.is_a?(Fit4Ruby::Monitoring_B)
408
+ # return @monitoring.add(fit_file_name, fit_entity)
340
409
  else
341
410
  Log.error "#{fit_file_name} is not a recognized FIT file"
342
411
  return false
@@ -348,13 +417,15 @@ EOT
348
417
  when :check
349
418
  activity.check
350
419
  when :delete
351
- @activities.delete(activity)
420
+ @ffs.delete_activity(activity)
352
421
  when :dump
353
422
  activity.dump(@filter)
423
+ when :events
424
+ activity.events
354
425
  when :rename
355
- @activities.rename(activity, @name)
426
+ @ffs.rename_activity(activity, @name)
356
427
  when :set
357
- @activities.set(activity, @attribute, @value)
428
+ @ffs.set_activity_attribute(activity, @attribute, @value)
358
429
  when :show
359
430
  activity.show
360
431
  when :sources
@@ -375,9 +446,9 @@ EOT
375
446
  Log.fatal("You must specify 'metric' or 'statute' as unit system.")
376
447
  end
377
448
 
378
- if @cfg[:unit_system].to_s != args[0]
379
- @cfg.set_option(:unit_system, args[0].to_sym)
380
- @activities.generate_all_html_reports
449
+ if @db['config']['unit_system'].to_s != args[0]
450
+ @db['config']['unit_system'] = args[0].to_sym
451
+ @ffs.change_unit_system
381
452
  end
382
453
  end
383
454
 
@@ -386,16 +457,16 @@ EOT
386
457
  Log.fatal('You must specify a directory')
387
458
  end
388
459
 
389
- if @cfg[:html_dir] != args[0]
390
- @cfg.set_option(:html_dir, args[0])
391
- @activities.create_directories
392
- @activities.generate_all_html_reports
460
+ if @db['config']['html_dir'] != args[0]
461
+ @db['config']['html_dir'] = args[0]
462
+ @ffs.create_directories
463
+ @ffs.generate_all_html_reports
393
464
  end
394
465
  end
395
466
 
396
467
  def update_gps_data
397
468
  epo_dir = File.join(@db_dir, 'epo')
398
- @cfg.create_directory(epo_dir, 'GPS Data Cache')
469
+ create_directory(epo_dir, 'GPS Data Cache')
399
470
  epo_file = File.join(epo_dir, 'EPO.BIN')
400
471
 
401
472
  if !File.exists?(epo_file) ||
@@ -403,7 +474,7 @@ EOT
403
474
  # The EPO file only changes every 6 hours. No need to download it more
404
475
  # frequently if it already exists.
405
476
  if EPO_Downloader.new.download(epo_file)
406
- unless (remotesw_dir = @cfg[:import_dir])
477
+ unless (remotesw_dir = @db['config']['import_dir'])
407
478
  Log.error "No device directory set. Please import an activity " +
408
479
  "from your device first."
409
480
  return
@@ -426,14 +497,45 @@ EOT
426
497
  end
427
498
 
428
499
  def handle_version_update
429
- if @cfg.get_option(:version) != VERSION
500
+ if @db['config']['version'] != VERSION
430
501
  Log.warn "PostRunner version upgrade detected."
431
- @activities.handle_version_update
432
- @cfg.set_option(:version, VERSION)
502
+ @ffs.handle_version_update
503
+ @db['config']['version'] = VERSION
433
504
  Log.info "Version upgrade completed."
434
505
  end
435
506
  end
436
507
 
508
+ # Earlier versions of PostRunner used a YAML file to store the activity
509
+ # data. This method transfers the data from the old storage to the new
510
+ # FitFileStore based database.
511
+ def import_legacy_archive
512
+ old_fit_dir = File.join(@db_dir, 'old_fit_dir')
513
+ create_directory(old_fit_dir, 'Old Fit')
514
+
515
+ cfg = RuntimeConfig.new(@db_dir)
516
+ activities = ActivitiesDB.new(@db_dir, cfg).activities
517
+ # Ensure that activities are sorted from earliest to last to properly
518
+ # recognize the personal records during import.
519
+ activities.sort! { |a1, a2| a1.timestamp <=> a2.timestamp }
520
+ activities.each do |activity|
521
+ file_name = File.join(@db_dir, 'fit', activity.fit_file)
522
+ next unless File.exists?(file_name)
523
+
524
+ Log.info "Converting #{activity.fit_file} to new DB format"
525
+ @db.transaction do
526
+ unless (new_activity = @ffs.add_fit_file(file_name))
527
+ Log.warn "Cannot convert #{file_name} to new database format"
528
+ next
529
+ end
530
+ new_activity.sport = activity.sport
531
+ new_activity.sub_sport = activity.sub_sport
532
+ new_activity.name = activity.name
533
+ new_activity.norecord = activity.norecord
534
+ FileUtils.move(file_name, File.join(old_fit_dir, activity.fit_file))
535
+ end
536
+ end
537
+ end
538
+
437
539
  end
438
540
 
439
541
  end