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.
- checksums.yaml +4 -4
- data/README.md +5 -2
- data/Rakefile +18 -2
- data/lib/postrunner/ActivitiesDB.rb +1 -22
- data/lib/postrunner/Activity.rb +21 -0
- data/lib/postrunner/ActivityLink.rb +1 -1
- data/lib/postrunner/ActivityListView.rb +10 -9
- data/lib/postrunner/ActivitySummary.rb +42 -15
- data/lib/postrunner/ActivityView.rb +10 -8
- data/lib/postrunner/ChartView.rb +238 -80
- data/lib/postrunner/DataSources.rb +1 -14
- data/lib/postrunner/DeviceList.rb +18 -10
- data/lib/postrunner/DirUtils.rb +33 -0
- data/lib/postrunner/EventList.rb +149 -0
- data/lib/postrunner/FFS_Activity.rb +297 -0
- data/lib/postrunner/FFS_Device.rb +129 -0
- data/lib/postrunner/FitFileStore.rb +372 -0
- data/lib/postrunner/HRV_Analyzer.rb +178 -0
- data/lib/postrunner/LinearPredictor.rb +46 -0
- data/lib/postrunner/Main.rb +135 -33
- data/lib/postrunner/Percentiles.rb +45 -0
- data/lib/postrunner/PersonalRecords.rb +203 -114
- data/lib/postrunner/RecordListPageView.rb +6 -6
- data/lib/postrunner/UserProfileView.rb +4 -0
- data/lib/postrunner/version.rb +1 -1
- data/misc/postrunner/trackview.js +99 -0
- data/postrunner.gemspec +5 -5
- data/spec/ActivitySummary_spec.rb +15 -4
- data/spec/FitFileStore_spec.rb +133 -0
- data/spec/FlexiTable_spec.rb +1 -1
- data/spec/PersonalRecords_spec.rb +206 -0
- data/spec/PostRunner_spec.rb +64 -60
- data/spec/View_spec.rb +1 -1
- data/spec/spec_helper.rb +76 -2
- metadata +42 -28
@@ -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
|
-
# =
|
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
|
-
|
82
|
-
# also stores a reference to the Activity that contains the record.
|
83
|
-
class Record
|
75
|
+
po_attr :sport_records
|
84
76
|
|
85
|
-
|
77
|
+
class ActivityResult
|
86
78
|
|
87
|
-
|
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
|
-
|
104
|
-
|
105
|
-
|
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
|
-
|
126
|
+
po_attr :sport, :year, :distance_record, :speed_records
|
115
127
|
|
116
|
-
def initialize(sport, year)
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
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
|
-
|
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
|
-
|
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.
|
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
|
-
|
249
|
+
po_attr :sport, :all_time, :yearly
|
229
250
|
|
230
|
-
def initialize(sport)
|
231
|
-
|
232
|
-
|
233
|
-
|
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] =
|
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
|
289
|
-
|
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(
|
301
|
-
|
302
|
-
|
303
|
-
|
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
|
-
|
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.
|
449
|
+
"#{activity.fit_file_name}"
|
313
450
|
return false
|
314
451
|
end
|
315
452
|
|
316
|
-
result =
|
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] =
|
461
|
+
@sport_records[sport] = @store.new(SportRecords, sport)
|
324
462
|
end
|
325
463
|
end
|
326
464
|
|
327
465
|
def delete_activity(activity)
|
328
|
-
|
329
|
-
|
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
|
-
|
332
|
-
|
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(@
|
479
|
+
output_file = File.join(@store['config']['html_dir'],
|
339
480
|
"records-#{i}.html")
|
340
|
-
RecordListPageView.new(@
|
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
|
-
|
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
|