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