postrunner 0.0.11 → 0.1.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.
@@ -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