postrunner 0.3.0 → 0.4.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.
@@ -129,14 +129,10 @@ module PostRunner
129
129
  # @param to_time [Time] end time of the interval (not included)
130
130
  # @return [Array] list of overlapping FFS_Monitoring objects.
131
131
  def monitorings(from_time, to_time)
132
- list = []
133
- @monitorings.each do |m|
134
- if (from_time <= m.period_start && m.period_start < to_time) ||
135
- (from_time <= m.period_end && m.period_end < to_time)
136
- list << m
137
- end
132
+ @monitorings.select do |m|
133
+ (from_time <= m.period_start && m.period_start < to_time) ||
134
+ (from_time <= m.period_end && m.period_end < to_time)
138
135
  end
139
- list
140
136
  end
141
137
 
142
138
  end
@@ -74,8 +74,6 @@ module PostRunner
74
74
  period_end = monitoring.timestamp if monitoring.timestamp
75
75
  end
76
76
  self.period_end = period_end
77
-
78
- puts "#{@period_start} - #{@period_end}"
79
77
  end
80
78
 
81
79
  def decode_activity_type(activity_type)
@@ -18,7 +18,7 @@ require 'postrunner/DirUtils'
18
18
  require 'postrunner/FFS_Device'
19
19
  require 'postrunner/ActivityListView'
20
20
  require 'postrunner/ViewButtons'
21
- require 'postrunner/SleepStatistics'
21
+ require 'postrunner/MonitoringStatistics'
22
22
 
23
23
  module PostRunner
24
24
 
@@ -329,16 +329,17 @@ module PostRunner
329
329
  # there are usually multiple files per GMT day.
330
330
  day_as_time = Time.parse(day).gmtime
331
331
  @store['devices'].each do |id, device|
332
- # We are looking for all files that potentially overlap with our
333
- # localtime day.
334
- monitorings += device.monitorings(day_as_time - 36 * 60 * 60,
332
+ # To get weekly intensity minutes we need 7 days of data prior to the
333
+ # current date and 1 day after to include the following night. We add
334
+ # at least 12 extra hours to accomodate time zone changes.
335
+ monitorings += device.monitorings(day_as_time - 8 * 24 * 60 * 60,
335
336
  day_as_time + 36 * 60 * 60)
336
337
  end
337
- monitoring_files = monitorings.map do |m|
338
+ monitoring_files = monitorings.reverse.map do |m|
338
339
  read_fit_file(File.join(fit_file_dir(m.fit_file_name, m.device.long_uid,
339
340
  'monitor'), m.fit_file_name))
340
341
  end
341
- puts SleepStatistics.new(monitoring_files).daily(day)
342
+ puts MonitoringStatistics.new(monitoring_files).daily(day)
342
343
  end
343
344
 
344
345
  def monthly_report(day)
@@ -354,14 +355,14 @@ module PostRunner
354
355
  @store['devices'].each do |id, device|
355
356
  # We are looking for all files that potentially overlap with our
356
357
  # localtime day.
357
- monitorings += device.monitorings(day_as_time - 36 * 60 * 60,
358
+ monitorings += device.monitorings(day_as_time - 8 * 24 * 60 * 60,
358
359
  day_as_time + 33 * 24 * 60 * 60)
359
360
  end
360
- monitoring_files = monitorings.map do |m|
361
+ monitoring_files = monitorings.sort.map do |m|
361
362
  read_fit_file(File.join(fit_file_dir(m.fit_file_name, m.device.long_uid,
362
363
  'monitor'), m.fit_file_name))
363
364
  end
364
- puts SleepStatistics.new(monitoring_files).monthly(day)
365
+ puts MonitoringStatistics.new(monitoring_files).monthly(day)
365
366
  end
366
367
 
367
368
  private
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # = FlexiTable.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
@@ -191,6 +191,11 @@ module PostRunner
191
191
  end
192
192
 
193
193
  def new_row
194
+ if @current_row && @head_rows[0] &&
195
+ @current_row.length != @head_rows[0].length
196
+ Log.fatal "Row has #{@current_row.length} cells instead of " +
197
+ "#{@head_rows[0].length} cells in head row."
198
+ end
194
199
  @current_row = nil
195
200
  end
196
201
 
@@ -69,9 +69,6 @@ module PostRunner
69
69
  cfg['html_dir'] = File.join(@db_dir, 'html')
70
70
 
71
71
  setup_directories
72
- if $DEBUG && (errors = @db.check) != 0
73
- Log.abort "Postrunner database is corrupted: #{errors} errors found"
74
- end
75
72
  return execute_command(args)
76
73
 
77
74
  rescue Exception => e
@@ -173,6 +170,13 @@ check [ <fit file> | <ref> ... ]
173
170
  Check the provided FIT file(s) for structural errors. If no file or
174
171
  reference is provided, the complete archive is checked.
175
172
 
173
+ daily [ <YYYY-MM-DD> ]
174
+ Print the monitoring statistics for the requested day and the
175
+ following night. If no date is given, yesterday's date will be used.
176
+
177
+ delete <ref>
178
+ Delete the activity from the archive.
179
+
176
180
  dump <fit file> | <ref>
177
181
  Dump the content of the FIT file.
178
182
 
@@ -184,18 +188,13 @@ import [ <fit file> | <directory> ]
184
188
  file or directory is provided, the directory that was used for the
185
189
  previous import is being used.
186
190
 
187
- daily [ <date> ]
188
- Print a report summarizing the current day or the specified day.
189
-
190
- delete <ref>
191
- Delete the activity from the archive.
192
-
193
191
  list
194
192
  List all FIT files stored in the data base.
195
193
 
196
- monthly [ <date> ]
194
+ monthly [ <YYYY-MM-DD> ]
195
+
197
196
  Print a table with various statistics for each day of the specified
198
- month.
197
+ month. If no date is given, yesterday's month will be used.
199
198
 
200
199
  records
201
200
  List all personal records.
@@ -296,6 +295,7 @@ EOT
296
295
  case (cmd = args.shift)
297
296
  when 'check'
298
297
  if args.empty?
298
+ @db.check(true)
299
299
  @ffs.check
300
300
  Log.info "Datebase cleanup started. Please wait ..."
301
301
  @db.gc
@@ -556,7 +556,7 @@ EOT
556
556
 
557
557
  def day_in_localtime(args, format)
558
558
  begin
559
- (args.empty? ? Time.now : Time.parse(args[0])).
559
+ (args.empty? ? Time.now - 24 * 60 * 60 : Time.parse(args[0])).
560
560
  localtime.strftime(format)
561
561
  rescue ArgumentError
562
562
  Log.abort("#{args[0]} is not a valid date. Use YYYY-MM-DD format.")
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = MonitoringStatistics.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2016 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 'fit4ruby'
14
+
15
+ require 'postrunner/DailySleepAnalyzer'
16
+ require 'postrunner/DailyMonitoringAnalyzer'
17
+ require 'postrunner/FlexiTable'
18
+
19
+ module PostRunner
20
+
21
+ # This class can be used to generate reports for sleep data. It uses the
22
+ # DailySleepAnalyzer class to compute the data and generates the report for
23
+ # a certain time period.
24
+ class MonitoringStatistics
25
+
26
+ include Fit4Ruby::Converters
27
+
28
+ # Create a new MonitoringStatistics object.
29
+ # @param monitoring_files [Array of Fit4Ruby::Monitoring_B] FIT files
30
+ def initialize(monitoring_files)
31
+ @monitoring_files = monitoring_files
32
+ # Week starts on Monday
33
+ @first_day_of_week = 1
34
+ end
35
+
36
+ # Generate a report for a certain day.
37
+ # @param day [String] Date of the day as YYYY-MM-DD string.
38
+ def daily(day)
39
+ sleep_analyzer = DailySleepAnalyzer.new(@monitoring_files, day,
40
+ +12 * 60 * 60)
41
+ monitoring_analyzer = DailyMonitoringAnalyzer.new(@monitoring_files, day)
42
+
43
+ str = "Daily Monitoring Report for #{day}\n\n" +
44
+ "#{daily_goals_table(monitoring_analyzer)}\n" +
45
+ "#{daily_stats_table(monitoring_analyzer, sleep_analyzer)}\n"
46
+ if sleep_analyzer.sleep_cycles.empty?
47
+ str += 'No sleep data available for this day'
48
+ else
49
+ str += "Sleep Statistics for " +
50
+ "#{sleep_analyzer.window_start_time.strftime('%Y-%m-%d')} - " +
51
+ "#{sleep_analyzer.window_end_time.strftime('%Y-%m-%d')}\n\n" +
52
+ daily_sleep_cycle_table(sleep_analyzer).to_s
53
+ end
54
+
55
+ str
56
+ end
57
+
58
+ # Generate a report for a certain month.
59
+ # @param day [String] Date of a day in that months as YYYY-MM-DD string.
60
+ def monthly(day)
61
+ day_as_time = Time.parse(day)
62
+ year = day_as_time.year
63
+ month = day_as_time.month
64
+ last_day_of_month = Date.new(year, month, -1).day
65
+
66
+ "Monitoring Statistics for #{day_as_time.strftime('%B %Y')}\n\n" +
67
+ monthly_goal_table(year, month, last_day_of_month).to_s + "\n" +
68
+ monthly_sleep_table(year, month, last_day_of_month).to_s
69
+ end
70
+
71
+ private
72
+
73
+ def percent(value, total)
74
+ "#{'%.0f' % ((value * 100.0) / total)}%"
75
+ end
76
+
77
+ def cell_right_aligned(table, text)
78
+ table.cell(text, { :halign => :right })
79
+ end
80
+
81
+ def time_as_hm(t, utc_offset)
82
+ t.localtime(utc_offset).strftime('%H:%M')
83
+ end
84
+
85
+ def daily_sleep_cycle_table(analyzer)
86
+ ti = FlexiTable.new
87
+ ti.head
88
+ ti.row([ 'Cycle', 'From', 'To', 'Duration', 'REM Sleep',
89
+ 'Light Sleep', 'Deep Sleep'])
90
+ ti.body
91
+ utc_offset = analyzer.utc_offset
92
+ format = { :halign => :right }
93
+ totals = Hash.new(0)
94
+ last_to_time = nil
95
+ analyzer.sleep_cycles.each_with_index do |c, idx|
96
+ if last_to_time && c.from_time > last_to_time
97
+ # We have a gap in the sleep cycles.
98
+ ti.cell('Wake')
99
+ cell_right_aligned(ti, time_as_hm(last_to_time, utc_offset))
100
+ cell_right_aligned(ti, time_as_hm(c.from_time, utc_offset))
101
+ cell_right_aligned(ti, "(#{secsToHM(c.from_time - last_to_time)})")
102
+ ti.cell('')
103
+ ti.cell('')
104
+ ti.cell('')
105
+ ti.new_row
106
+ end
107
+
108
+ ti.cell((idx + 1).to_s, format)
109
+ ti.cell(c.from_time.localtime(utc_offset).strftime('%H:%M'), format)
110
+ ti.cell(c.to_time.localtime(utc_offset).strftime('%H:%M'), format)
111
+
112
+ duration = c.to_time - c.from_time
113
+ totals[:duration] += duration
114
+ ti.cell(secsToHM(duration), format)
115
+
116
+ totals[:rem] += c.total_seconds[:rem]
117
+ ti.cell(secsToHM(c.total_seconds[:rem]), format)
118
+
119
+ light_sleep = c.total_seconds[:nrem1] + c.total_seconds[:nrem2]
120
+ totals[:light_sleep] += light_sleep
121
+ ti.cell(secsToHM(light_sleep), format)
122
+
123
+ totals[:deep_sleep] += c.total_seconds[:nrem3]
124
+ ti.cell(secsToHM(c.total_seconds[:nrem3]), format)
125
+
126
+ ti.new_row
127
+ last_to_time = c.to_time
128
+ end
129
+ ti.foot
130
+ ti.cell('Totals')
131
+ ti.cell(analyzer.sleep_cycles[0].from_time.localtime(utc_offset).
132
+ strftime('%H:%M'), format)
133
+ ti.cell(analyzer.sleep_cycles[-1].to_time.localtime(utc_offset).
134
+ strftime('%H:%M'), format)
135
+ ti.cell(secsToHM(totals[:duration]), format)
136
+ ti.cell(secsToHM(totals[:rem]), format)
137
+ ti.cell(secsToHM(totals[:light_sleep]), format)
138
+ ti.cell(secsToHM(totals[:deep_sleep]), format)
139
+ ti.new_row
140
+
141
+ ti
142
+ end
143
+
144
+ def daily_goals_table(monitoring_analyzer)
145
+ t = FlexiTable.new
146
+
147
+ t.head
148
+ t.row([ 'Steps', 'Intensity Minutes', 'Floors Climbed' ])
149
+
150
+ t.body
151
+ t.set_column_attributes(Array.new(3, { :halign => :center}))
152
+
153
+ steps_distance_calories = monitoring_analyzer.steps_distance_calories
154
+ steps = steps_distance_calories[:steps]
155
+ steps_goal = monitoring_analyzer.steps_goal
156
+ t.cell(steps)
157
+
158
+ intensity_minutes = weekly_intensity_minutes(monitoring_analyzer)
159
+ t.cell(intensity_minutes)
160
+
161
+ floors = monitoring_analyzer.total_floors
162
+ floors_climbed = floors[:floors_climbed]
163
+ t.cell(floors_climbed)
164
+ t.new_row
165
+
166
+ t.cell("#{percent(steps, steps_goal)} of daily goal #{steps_goal}")
167
+ t.cell("#{percent(intensity_minutes, 150)} of weekly goal 150")
168
+ t.cell("#{percent(floors_climbed, 10)} of daily goal 10")
169
+ t.new_row
170
+
171
+ t
172
+ end
173
+
174
+ def daily_stats_table(monitoring_analyzer, sleep_analyzer)
175
+ t = FlexiTable.new
176
+ t.set_column_attributes(Array.new(4, { :halign => :center}))
177
+
178
+ t.head
179
+ t.row([ 'Distance', 'Calories', 'Floors descended',
180
+ 'Resting Heart Rate' ])
181
+
182
+ t.body
183
+ steps_distance_calories = monitoring_analyzer.steps_distance_calories
184
+ t.cell("#{'%.1f' % (steps_distance_calories[:distance] / 1000.0)} km")
185
+
186
+ t.cell("#{steps_distance_calories[:calories].to_i}")
187
+
188
+ floors = monitoring_analyzer.total_floors
189
+ t.cell("#{floors[:floors_descended]}")
190
+
191
+ t.cell("#{sleep_analyzer.resting_heart_rate} BPM")
192
+
193
+ t
194
+ end
195
+
196
+ def monthly_goal_table(year, month, last_day_of_month)
197
+ t = FlexiTable.new
198
+ left = { :halign => :left }
199
+ right = { :halign => :right }
200
+ t.set_column_attributes([ left ] + [ right ] * 7)
201
+ t.head
202
+ t.row([ 'Day', 'Steps', '%', 'Goal', 'Intensity', '%',
203
+ 'Floors', '% of 10' ])
204
+ t.row([ '', '', '', '', 'Minutes', 'Week', '', '' ])
205
+ t.body
206
+ totals = Hash.new(0)
207
+ counted_days = 0
208
+ intensity_minutes_sum = 0
209
+ 1.upto(last_day_of_month).each do |dom|
210
+ break if (time = Time.new(year, month, dom)) > Time.now
211
+
212
+ day_str = time.strftime('%d %a')
213
+ t.cell(day_str)
214
+
215
+ analyzer = DailyMonitoringAnalyzer.new(@monitoring_files, day_str)
216
+
217
+ steps_distance_calories = analyzer.steps_distance_calories
218
+ steps = steps_distance_calories[:steps]
219
+ totals[:steps] += steps
220
+ steps_goal = analyzer.steps_goal
221
+ totals[:steps_goal] += steps_goal
222
+ t.cell(steps)
223
+ t.cell(percent(steps, steps_goal))
224
+ t.cell(steps_goal)
225
+
226
+ if dom == 1
227
+ intensity_minutes = weekly_intensity_minutes(analyzer)
228
+ else
229
+ intensity_minutes_sum = 0 if time.wday == @first_day_of_week
230
+ intensity_minutes =
231
+ analyzer.intensity_minutes[:moderate_minutes] +
232
+ 2 * analyzer.intensity_minutes[:vigorous_minutes]
233
+ end
234
+ intensity_minutes_sum += intensity_minutes
235
+ totals[:intensity_minutes] += intensity_minutes
236
+ t.cell(intensity_minutes_sum.to_i)
237
+ t.cell(percent(intensity_minutes_sum, 150))
238
+
239
+ floors = analyzer.total_floors
240
+ floors_climbed = floors[:floors_climbed]
241
+ totals[:floors] += floors_climbed
242
+ t.cell(floors_climbed)
243
+ t.cell(percent(floors_climbed, 10))
244
+ t.new_row
245
+ counted_days += 1
246
+ end
247
+
248
+ t.foot
249
+ t.cell('Totals')
250
+ t.cell(totals[:steps])
251
+ t.cell('')
252
+ t.cell(totals[:steps_goal])
253
+ t.cell(totals[:intensity_minutes].to_i)
254
+ t.cell('')
255
+ t.cell(totals[:floors])
256
+ t.cell('')
257
+ t.new_row
258
+
259
+ if counted_days > 0
260
+ t.cell('Averages')
261
+ t.cell((totals[:steps] / counted_days).to_i)
262
+ t.cell(percent(totals[:steps], totals[:steps_goal]))
263
+ t.cell((totals[:steps_goal] / counted_days).to_i)
264
+ t.cell((totals[:intensity_minutes] / counted_days).to_i)
265
+ t.cell(percent(totals[:intensity_minutes], (counted_days / 7.0) * 150))
266
+ t.cell((totals[:floors] / counted_days).to_i)
267
+ t.cell(percent(totals[:floors] / counted_days, 10))
268
+ end
269
+
270
+ t
271
+ end
272
+
273
+ def monthly_sleep_table(year, month, last_day_of_month)
274
+ t = FlexiTable.new
275
+ left = { :halign => :left }
276
+ right = { :halign => :right }
277
+ t.set_column_attributes([ left ] + [ right ] * 6)
278
+ t.head
279
+ t.row([ 'Date', 'Total Sleep', 'Cycles', 'REM Sleep', 'Light Sleep',
280
+ 'Deep Sleep', 'RHR' ])
281
+ t.body
282
+ totals = Hash.new(0)
283
+ counted_days = 0
284
+ rhr_days = 0
285
+
286
+ 1.upto(last_day_of_month).each do |dom|
287
+ break if (time = Time.new(year, month, dom)) > Time.now
288
+
289
+ day_str = time.strftime('%d %a')
290
+ t.cell(day_str)
291
+
292
+ analyzer = DailySleepAnalyzer.new(@monitoring_files, day_str,
293
+ -12 * 60 * 60)
294
+
295
+ if (analyzer.sleep_cycles.empty?)
296
+ 5.times { t.cell('-') }
297
+ else
298
+ totals[:total_sleep] += analyzer.total_sleep
299
+ totals[:cycles] += analyzer.sleep_cycles.length
300
+ totals[:rem_sleep] += analyzer.rem_sleep
301
+ totals[:light_sleep] += analyzer.light_sleep
302
+ totals[:deep_sleep] += analyzer.deep_sleep
303
+ counted_days += 1
304
+
305
+ t.cell(secsToHM(analyzer.total_sleep))
306
+ t.cell(analyzer.sleep_cycles.length)
307
+ t.cell(secsToHM(analyzer.rem_sleep))
308
+ t.cell(secsToHM(analyzer.light_sleep))
309
+ t.cell(secsToHM(analyzer.deep_sleep))
310
+ end
311
+
312
+ if (rhr = analyzer.resting_heart_rate) && rhr > 0
313
+ t.cell(rhr)
314
+ totals[:rhr] += rhr
315
+ rhr_days += 1
316
+ else
317
+ t.cell('-')
318
+ end
319
+ t.new_row
320
+ end
321
+ t.foot
322
+ t.cell('Averages')
323
+ if counted_days > 0
324
+ t.cell(secsToHM(totals[:total_sleep] / counted_days))
325
+ t.cell('%.1f' % (totals[:cycles] / counted_days))
326
+ t.cell(secsToHM(totals[:rem_sleep] / counted_days))
327
+ t.cell(secsToHM(totals[:light_sleep] / counted_days))
328
+ t.cell(secsToHM(totals[:deep_sleep] / counted_days))
329
+ else
330
+ 5.times { t.cell('-') }
331
+ end
332
+ if rhr_days > 0
333
+ t.cell('%.0f' % (totals[:rhr] / rhr_days))
334
+ else
335
+ t.cell('-')
336
+ end
337
+ t.new_row
338
+
339
+ t
340
+ end
341
+
342
+ def weekly_intensity_minutes(monitoring_analyzer)
343
+ current_date = monitoring_analyzer.window_start_time.localtime
344
+
345
+ intensity_minutes = 0
346
+ # Get intensity minutes for previous days of the current week.
347
+ if current_date.wday != @first_day_of_week
348
+ 1.upto(5) do |i|
349
+ date = current_date - 24 * 60 * 60 * i
350
+
351
+ ma = DailyMonitoringAnalyzer.new(@monitoring_files,
352
+ date.strftime('%Y-%m-%d'))
353
+ intensity_minutes +=
354
+ ma.intensity_minutes[:moderate_minutes] +
355
+ 2 * ma.intensity_minutes[:vigorous_minutes]
356
+
357
+ break if current_date.wday == @first_day_of_week
358
+ end
359
+ end
360
+
361
+ # Finally add the intensity minutes of the current day.
362
+ intensity_minutes +=
363
+ monitoring_analyzer.intensity_minutes[:moderate_minutes] +
364
+ 2 * monitoring_analyzer.intensity_minutes[:vigorous_minutes]
365
+
366
+ intensity_minutes
367
+ end
368
+
369
+ end
370
+
371
+ end
372
+