postrunner 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+