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,45 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = Percentiles.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
+ # This class can be used to partition sets according to a given percentile.
16
+ class Percentiles
17
+
18
+ # Create a Percentiles object for the given data set.
19
+ # @param set [Array] It must be an Array of tuples (2 element Array). The
20
+ # first element is the actual value, the second does not matter for
21
+ # the computation. It is usually a reference to the context of the
22
+ # value.
23
+ def initialize(set)
24
+ @set = set.sort { |e1, e2| e1[0] <=> e2[0] }
25
+ end
26
+
27
+ # @return [Array] Return the tuples that are within the given percentile.
28
+ # @param x [Float] Percentage value
29
+ def tp_x(x)
30
+ split_idx = (x / 100.0 * @set.size).to_i
31
+ @set[0..split_idx]
32
+ end
33
+
34
+ # @return [Array] Return the tuples that are not within the given
35
+ # percentile.
36
+ # @param x [Float] Percentage value
37
+ def not_tp_x(x)
38
+ split_idx = (x / 100.0 * @set.size).to_i
39
+ @set[split_idx..-1]
40
+ end
41
+
42
+ end
43
+
44
+ end
45
+
@@ -1,28 +1,22 @@
1
1
  #!/usr/bin/env ruby -w
2
2
  # encoding: UTF-8
3
3
  #
4
- # = PersonalRecords.rb -- PostRunner - Manage the data from your Garmin sport devices.
4
+ # = FitFileStore.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
10
10
  # published by the Free Software Foundation.
11
11
  #
12
12
 
13
- require 'fileutils'
14
- require 'yaml'
15
-
16
- require 'fit4ruby'
17
- require 'postrunner/BackedUpFile'
18
13
  require 'postrunner/RecordListPageView'
19
- require 'postrunner/ActivityLink'
20
14
 
21
15
  module PostRunner
22
16
 
23
17
  # The PersonalRecords class stores the various records. Records are grouped
24
18
  # by specific year or all-time records.
25
- class PersonalRecords
19
+ class PersonalRecords < PEROBS::Object
26
20
 
27
21
  include Fit4Ruby::Converters
28
22
 
@@ -78,13 +72,11 @@ module PostRunner
78
72
  }
79
73
  }
80
74
 
81
- # The Record class stores a single speed or longest distance record. It
82
- # also stores a reference to the Activity that contains the record.
83
- class Record
75
+ po_attr :sport_records
84
76
 
85
- include Fit4Ruby::Converters
77
+ class ActivityResult
86
78
 
87
- attr_accessor :activity, :sport, :distance, :duration, :start_time
79
+ attr_reader :activity, :sport, :distance, :duration, :start_time
88
80
 
89
81
  def initialize(activity, sport, distance, duration, start_time)
90
82
  @activity = activity
@@ -94,32 +86,56 @@ module PostRunner
94
86
  @start_time = start_time
95
87
  end
96
88
 
89
+ end
90
+
91
+ # The Record class stores a single speed or longest distance record. It
92
+ # also stores a reference to the Activity that contains the record.
93
+ class Record < PEROBS::Object
94
+
95
+ include Fit4Ruby::Converters
96
+
97
+ po_attr :activity, :sport, :distance, :duration, :start_time
98
+
99
+ def initialize(p, result)
100
+ super(p)
101
+
102
+ self.activity = result.activity
103
+ self.sport = result.sport
104
+ self.distance = result.distance
105
+ self.duration = result.duration
106
+ self.start_time = result.start_time
107
+ end
108
+
97
109
  def to_table_row(t)
98
110
  t.row((@duration.nil? ?
99
111
  [ 'Longest Distance', '%.3f km' % (@distance / 1000.0), '-' ] :
100
112
  [ PersonalRecords::SpeedRecordDistances[@sport][@distance],
101
113
  secsToHMS(@duration),
102
114
  speedToPace(@distance / @duration) ]) +
103
- [ @activity.db.ref_by_fit_file(@activity.fit_file),
104
- ActivityLink.new(@activity, false),
105
- @start_time.strftime("%Y-%m-%d") ])
115
+ [ @store['file_store'].ref_by_activity(@activity),
116
+ ActivityLink.new(@activity, false),
117
+ @start_time.strftime("%Y-%m-%d") ])
106
118
  end
107
119
 
108
120
  end
109
121
 
110
- class RecordSet
122
+ class RecordSet < PEROBS::Object
111
123
 
112
124
  include Fit4Ruby::Converters
113
125
 
114
- attr_reader :year
126
+ po_attr :sport, :year, :distance_record, :speed_records
115
127
 
116
- def initialize(sport, year)
117
- @sport = sport
118
- @year = year
119
- @distance_record = nil
120
- @speed_records = {}
121
- PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
122
- @speed_records[dist] = nil
128
+ def initialize(p, sport, year)
129
+ super(p)
130
+
131
+ self.sport = sport
132
+ self.year = year
133
+ self.distance_record = nil
134
+ self.speed_records = @store.new(PEROBS::Hash)
135
+ if sport
136
+ PersonalRecords::SpeedRecordDistances[sport].each_key do |dist|
137
+ @speed_records[dist.to_s] = nil
138
+ end
123
139
  end
124
140
  end
125
141
 
@@ -131,9 +147,9 @@ module PostRunner
131
147
  Log.fatal "Unknown record distance #{result.distance}"
132
148
  end
133
149
 
134
- old_record = @speed_records[result.distance]
150
+ old_record = @speed_records[result.distance.to_s]
135
151
  if old_record.nil? || old_record.duration > result.duration
136
- @speed_records[result.distance] = result
152
+ @speed_records[result.distance.to_s] = @store.new(Record, result)
137
153
  Log.info "New #{@year ? @year.to_s : 'all-time'} " +
138
154
  "#{result.sport} speed record for " +
139
155
  "#{PersonalRecords::SpeedRecordDistances[@sport][
@@ -145,7 +161,8 @@ module PostRunner
145
161
  # We have a potential distance record.
146
162
  if @distance_record.nil? ||
147
163
  @distance_record.distance < result.distance
148
- @distance_record = result
164
+ self.distance_record = @store.new(Record, result)
165
+ raise RuntimeError if @distance_record.is_a?(String)
149
166
  Log.info "New #{@year ? @year.to_s : 'all-time'} " +
150
167
  "#{result.sport} distance record: #{result.distance} m"
151
168
  return true
@@ -156,14 +173,20 @@ module PostRunner
156
173
  end
157
174
 
158
175
  def delete_activity(activity)
176
+ record_deleted = false
159
177
  if @distance_record && @distance_record.activity == activity
160
- @distance_record = nil
178
+ self.distance_record = nil
179
+ record_deleted = true
161
180
  end
162
181
  PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
182
+ dist = dist.to_s
163
183
  if @speed_records[dist] && @speed_records[dist].activity == activity
164
184
  @speed_records[dist] = nil
185
+ record_deleted = true
165
186
  end
166
187
  end
188
+
189
+ record_deleted
167
190
  end
168
191
 
169
192
  # Return true if no Record is stored in this RecordSet object.
@@ -210,33 +233,33 @@ module PostRunner
210
233
  ])
211
234
  t.body
212
235
 
213
- records = @speed_records.values.delete_if { |r| r.nil? }
236
+ records = @speed_records.values.delete_if { |r| r.nil? }.
237
+ sort { |r1, r2| r1.distance <=> r2.distance }
214
238
  records << @distance_record if @distance_record
215
239
 
216
- records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
217
- r.to_table_row(t)
218
- end
240
+ records.each { |r| r.to_table_row(t) }
219
241
 
220
242
  t
221
243
  end
222
244
 
223
-
224
245
  end
225
246
 
226
- class SportRecords
247
+ class SportRecords < PEROBS::Object
227
248
 
228
- attr_reader :sport, :all_time, :yearly
249
+ po_attr :sport, :all_time, :yearly
229
250
 
230
- def initialize(sport)
231
- @sport = sport
232
- @all_time = RecordSet.new(@sport, nil)
233
- @yearly = {}
251
+ def initialize(p, sport)
252
+ super(p)
253
+
254
+ self.sport = sport
255
+ self.all_time = @store.new(RecordSet, sport, nil)
256
+ self.yearly = @store.new(PEROBS::Hash)
234
257
  end
235
258
 
236
259
  def register_result(result)
237
- year = result.start_time.year
260
+ year = result.start_time.year.to_s
238
261
  unless @yearly[year]
239
- @yearly[year] = RecordSet.new(@sport, year)
262
+ @yearly[year] = @store.new(RecordSet, @sport, year)
240
263
  end
241
264
 
242
265
  new_at = @all_time.register_result(result)
@@ -246,9 +269,12 @@ module PostRunner
246
269
  end
247
270
 
248
271
  def delete_activity(activity)
272
+ record_deleted = false
249
273
  ([ @all_time ] + @yearly.values).each do |r|
250
- r.delete_activity(activity)
274
+ record_deleted = true if r.delete_activity(activity)
251
275
  end
276
+
277
+ record_deleted
252
278
  end
253
279
 
254
280
  # Return true if no record is stored in this SportRecords object.
@@ -285,8 +311,9 @@ module PostRunner
285
311
  doc.div {
286
312
  doc.h3('All-time records')
287
313
  @all_time.to_html(doc)
288
- @yearly.values.sort{ |r1, r2| r2.year <=> r1.year }.each do |record|
289
- puts record.year
314
+ @yearly.values.sort do |r1, r2|
315
+ r2.year.to_i <=> r1.year.to_i
316
+ end.each do |record|
290
317
  unless record.empty?
291
318
  doc.h3("Records of #{record.year}")
292
319
  record.to_html(doc)
@@ -297,47 +324,161 @@ module PostRunner
297
324
 
298
325
  end
299
326
 
300
- def initialize(activities)
301
- @activities = activities
302
- @db_dir = activities.db_dir
303
- @records_file = File.join(@db_dir, 'records.yml')
327
+ def initialize(p)
328
+ super(p)
329
+
330
+ self.sport_records = @store.new(PEROBS::Hash)
304
331
  delete_all_records
332
+ end
333
+
334
+ def scan_activity_for_records(activity, report_update_requested = false)
335
+ # If we have the @norecord flag set, we ignore this Activity for the
336
+ # record collection.
337
+ return if activity.norecord
338
+
339
+ activity.load_fit_file
340
+
341
+ distance_record = 0.0
342
+ distance_record_sport = nil
343
+ # Array with popular distances (in meters) in ascending order.
344
+ record_distances = nil
345
+ # Speed records for popular distances (seconds hashed by distance in
346
+ # meters)
347
+ speed_records = {}
348
+
349
+ segment_start_time = activity.fit_activity.sessions[0].start_time
350
+ segment_start_distance = 0.0
351
+
352
+ sport = nil
353
+ last_timestamp = nil
354
+ last_distance = nil
355
+
356
+ activity.fit_activity.records.each do |record|
357
+ if record.distance.nil?
358
+ # All records must have a valid distance mark or the activity does
359
+ # not qualify for a personal record.
360
+ Log.warn "Found a record without a valid distance"
361
+ return
362
+ end
363
+ if record.timestamp.nil?
364
+ Log.warn "Found a record without a valid timestamp"
365
+ return
366
+ end
367
+
368
+ unless sport
369
+ # If the Activity has sport set to 'multisport' or 'all' we pick up
370
+ # the sport from the FIT records. Otherwise, we just use whatever
371
+ # sport the Activity provides.
372
+ if activity.sport == 'multisport' || activity.sport == 'all'
373
+ sport = record.activity_type
374
+ else
375
+ sport = activity.sport
376
+ end
377
+ return unless SpeedRecordDistances.include?(sport)
378
+
379
+ record_distances = SpeedRecordDistances[sport].
380
+ keys.sort
381
+ end
382
+
383
+ segment_start_distance = record.distance unless segment_start_distance
384
+ segment_start_time = record.timestamp unless segment_start_time
385
+
386
+ # Total distance covered in this segment so far
387
+ segment_distance = record.distance - segment_start_distance
388
+ # Check if we have reached the next popular distance.
389
+ if record_distances.first &&
390
+ segment_distance >= record_distances.first
391
+ segment_duration = record.timestamp - segment_start_time
392
+ # The distance may be somewhat larger than a popular distance. We
393
+ # normalize the time to the norm distance.
394
+ norm_duration = segment_duration / segment_distance *
395
+ record_distances.first
396
+ # Save the time for this distance.
397
+ speed_records[record_distances.first] = {
398
+ :time => norm_duration, :sport => sport
399
+ }
400
+ # Switch to the next popular distance.
401
+ record_distances.shift
402
+ end
305
403
 
306
- load_records
404
+ # We've reached the end of a segment if the sport type changes, we
405
+ # detect a pause of more than 30 seconds or when we've reached the
406
+ # last record.
407
+ if (record.activity_type && sport && record.activity_type != sport) ||
408
+ (last_timestamp && (record.timestamp - last_timestamp) > 30) ||
409
+ record.equal?(activity.fit_activity.records.last)
410
+
411
+ # Check for a total distance record
412
+ if segment_distance > distance_record
413
+ distance_record = segment_distance
414
+ distance_record_sport = sport
415
+ end
416
+
417
+ # Prepare for the next segment in this Activity
418
+ segment_start_distance = nil
419
+ segment_start_time = nil
420
+ sport = nil
421
+ end
422
+
423
+ last_timestamp = record.timestamp
424
+ last_distance = record.distance
425
+ end
426
+
427
+ # Store the found records
428
+ start_time = activity.fit_activity.sessions[0].timestamp
429
+ update_reports = false
430
+ if distance_record_sport
431
+ if register_result(activity, distance_record_sport, distance_record,
432
+ nil, start_time)
433
+ update_reports = true
434
+ end
435
+ end
436
+ speed_records.each do |dist, info|
437
+ if register_result(activity, info[:sport], dist, info[:time],
438
+ start_time)
439
+ update_reports = true
440
+ end
441
+ end
442
+
443
+ generate_html_reports if update_reports && report_update_requested
307
444
  end
308
445
 
309
446
  def register_result(activity, sport, distance, duration, start_time)
310
447
  unless @sport_records.include?(sport)
311
448
  Log.info "Ignoring records for activity type '#{sport}' in " +
312
- "#{activity.fit_file}"
449
+ "#{activity.fit_file_name}"
313
450
  return false
314
451
  end
315
452
 
316
- result = Record.new(activity, sport, distance, duration, start_time)
453
+ result = ActivityResult.new(activity, sport, distance, duration,
454
+ start_time)
317
455
  @sport_records[sport].register_result(result)
318
456
  end
319
457
 
320
458
  def delete_all_records
321
- @sport_records = {}
459
+ @sport_records.clear
322
460
  SpeedRecordDistances.keys.each do |sport|
323
- @sport_records[sport] = SportRecords.new(sport)
461
+ @sport_records[sport] = @store.new(SportRecords, sport)
324
462
  end
325
463
  end
326
464
 
327
465
  def delete_activity(activity)
328
- @sport_records.each_value { |r| r.delete_activity(activity) }
329
- end
466
+ record_deleted = false
467
+ @sport_records.each_value do |r|
468
+ record_deleted = true if r.delete_activity(activity)
469
+ end
330
470
 
331
- def sync
332
- save_records
471
+ record_deleted
472
+ end
333
473
 
474
+ def generate_html_reports
334
475
  non_empty_records = @sport_records.select { |s, r| !r.empty? }
335
476
  max = non_empty_records.length
336
477
  i = 0
337
478
  non_empty_records.each do |sport, record|
338
- output_file = File.join(@activities.cfg[:html_dir],
479
+ output_file = File.join(@store['config']['html_dir'],
339
480
  "records-#{i}.html")
340
- RecordListPageView.new(@activities, record, max, i).
481
+ RecordListPageView.new(@store['file_store'], record, max, i).
341
482
  write(output_file)
342
483
  i += 1
343
484
  end
@@ -362,8 +503,7 @@ module PostRunner
362
503
  def activity_records(activity)
363
504
  records = []
364
505
  each do |record|
365
- # puts record.activity
366
- if record.activity.equal?(activity) && !records.include?(record)
506
+ if record.activity.equal?(activity)
367
507
  records << record
368
508
  end
369
509
  end
@@ -371,57 +511,6 @@ module PostRunner
371
511
  records
372
512
  end
373
513
 
374
- private
375
-
376
- def load_records
377
- begin
378
- if File.exists?(@records_file)
379
- @sport_records = YAML.load_file(@records_file)
380
- else
381
- Log.info "No records file found at '#{@records_file}'"
382
- end
383
- rescue IOError
384
- Log.fatal "Cannot load records file '#{@records_file}': #{$!}"
385
- end
386
-
387
- unless @sport_records.is_a?(Hash)
388
- Log.fatal "The personal records file '#{@records_file}' is corrupted"
389
- end
390
- fit_file_names_to_activity_refs
391
- end
392
-
393
- def save_records
394
- activity_refs_to_fit_file_names
395
- begin
396
- BackedUpFile.open(@records_file, 'w') do |f|
397
- f.write(@sport_records.to_yaml)
398
- end
399
- rescue IOError
400
- Log.fatal "Cannot write records file '#{@records_file}': #{$!}"
401
- end
402
- fit_file_names_to_activity_refs
403
- end
404
-
405
- # Convert FIT file names in all Record objects into Activity references.
406
- def fit_file_names_to_activity_refs
407
- each do |record|
408
- # Record objects can be referenced multiple times.
409
- if record.activity.is_a?(String)
410
- record.activity = @activities.activity_by_fit_file(record.activity)
411
- end
412
- end
413
- end
414
-
415
- # Convert Activity references in all Record objects into FIT file names.
416
- def activity_refs_to_fit_file_names
417
- each do |record|
418
- # Record objects can be referenced multiple times.
419
- unless record.activity.is_a?(String)
420
- record.activity = record.activity.fit_file
421
- end
422
- end
423
- end
424
-
425
514
  end
426
515
 
427
516
  end