mybuddy/reports/graphs/sleep_pattern.py

206 lines
7.0 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
from collections import OrderedDict
from django.utils import timezone, formats
from django.utils.translation import gettext as _
import plotly.offline as plotly
import plotly.graph_objs as go
from core.utils import duration_string
from reports import utils
from datetime import timedelta
ASLEEP_COLOR = 'rgb(35, 110, 150)'
AWAKE_COLOR = 'rgba(255, 255, 255, 0)'
def sleep_pattern(sleeps):
"""
Create a graph showing blocked out periods of sleep during each day.
:param sleeps: a QuerySet of Sleep instances.
:returns: a tuple of the the graph's html and javascript.
"""
last_end_time = None
adjustment = None
days = _init_days(sleeps.first().start, sleeps.last().end)
for sleep in sleeps:
start_time = timezone.localtime(sleep.start)
end_time = timezone.localtime(sleep.end)
start_date = start_time.date().isoformat()
end_date = end_time.date().isoformat()
duration = sleep.duration
# Check if the previous entry crossed midnight (see below).
if adjustment:
_add_adjustment(adjustment, days)
last_end_time = timezone.localtime(adjustment['end_time'])
adjustment = None
# If the dates do not match, set up an adjustment for the next day.
if end_time.date() != start_time.date():
adj_start_time = end_time.replace(hour=0, minute=0, second=0)
adjustment = {
'column': end_date,
'start_time': adj_start_time,
'end_time': end_time,
'duration': end_time - adj_start_time
}
# Adjust end_time for the current entry.
end_time = end_time.replace(
year=start_time.year, month=start_time.month,
day=start_time.day, hour=23, minute=59, second=0)
duration = end_time - start_time
if not last_end_time:
last_end_time = start_time.replace(hour=0, minute=0, second=0)
# Awake time.
days[start_date].append({
'time': (start_time - last_end_time).seconds / 60,
'label': None
})
# Asleep time.
days[start_date].append({
'time': duration.seconds / 60,
'label': _format_label(duration, start_time, end_time)}
)
# Update the previous entry duration if an offset change occurred.
# This can happen when an entry crosses a daylight savings time change.
if start_time.utcoffset() != end_time.utcoffset():
diff = start_time.utcoffset() - end_time.utcoffset()
duration -= timezone.timedelta(seconds=diff.seconds)
yesterday = (end_time - timezone.timedelta(days=1))
yesterday = yesterday.date().isoformat()
days[yesterday][len(days[yesterday]) - 1] = {
'time': duration.seconds / 60,
'label': _format_label(duration, start_time, end_time)
}
last_end_time = end_time
# Handle any left over adjustment (if the last entry crossed midnight).
if adjustment:
_add_adjustment(adjustment, days)
# Create dates for x-axis using a 12:00:00 time to ensure correct
# positioning of bars (covering entire day).
dates = []
for time in list(days.keys()):
dates.append('{} 12:00:00'.format(time))
traces = []
color = AWAKE_COLOR
# Set iterator and determine maximum iteration for dates.
i = 0
max_i = 0
for date_times in days.values():
max_i = max(len(date_times), max_i)
while i < max_i:
y = {}
text = {}
for date in days.keys():
try:
2021-11-04 03:03:59 +00:00
y[date] = days[date][i]['time']
text[date] = days[date][i]['label']
except IndexError:
y[date] = None
text[date] = None
i += 1
traces.append(go.Bar(
x=dates,
y=list(y.values()),
hovertext=list(text.values()),
# `hoverinfo` is deprecated but if we use the new `hovertemplate`
# the "filler" areas for awake time get a hover that says "null"
# and there is no way to prevent this currently with Plotly.
hoverinfo='text',
marker={'color': color},
showlegend=False,
))
if color == AWAKE_COLOR:
color = ASLEEP_COLOR
else:
color = AWAKE_COLOR
layout_args = utils.default_graph_layout_options()
layout_args['margin']['b'] = 100
layout_args['barmode'] = 'stack'
layout_args['bargap'] = 0
layout_args['hovermode'] = 'closest'
layout_args['title'] = _('<b>Sleep Pattern</b>')
layout_args['height'] = 800
layout_args['xaxis']['title'] = _('Date')
layout_args['xaxis']['tickangle'] = -65
layout_args['xaxis']['tickformat'] = '%b %e\n%Y'
layout_args['xaxis']['ticklabelmode'] = 'period'
layout_args['xaxis']['rangeselector'] = utils.rangeselector_date()
start = timezone.localtime().strptime('12:00 AM', '%I:%M %p')
ticks = OrderedDict()
ticks[0] = start.strftime('%I:%M %p')
for i in range(0, 60*24, 30):
ticks[i] = formats.time_format(start + timezone.timedelta(minutes=i),
'TIME_FORMAT')
layout_args['yaxis']['title'] = _('Time of day')
layout_args['yaxis']['range'] = [24*60, 0]
layout_args['yaxis']['tickmode'] = 'array'
layout_args['yaxis']['tickvals'] = list(ticks.keys())
layout_args['yaxis']['ticktext'] = list(ticks.values())
layout_args['yaxis']['tickfont'] = {'size': 10}
fig = go.Figure({
'data': traces,
'layout': go.Layout(**layout_args)
})
output = plotly.plot(fig, output_type='div', include_plotlyjs=False)
return utils.split_graph_output(output)
def _init_days(first_day, last_day):
period = (last_day.date() - first_day.date()).days + 1
def new_day(d): return (first_day + timedelta(days=d)).date().isoformat()
return {new_day(day): [] for day in range(period)}
def _add_adjustment(adjustment, days):
"""
Adds "adjustment" data for entries that cross midnight.
:param adjustment: Column, start time, end time, and duration of entry.
:param blocks: List of days
"""
column = adjustment.pop('column')
# Fake (0) entry to keep the color switching logic working.
days[column].append({'time': 0, 'label': 0})
# Real adjustment entry.
days[column].append({
'time': adjustment['duration'].seconds / 60,
'label': _format_label(**adjustment)
})
def _format_label(duration, start_time, end_time):
"""
Formats a time block label.
:param duration: Duration.
:param start_time: Start time.
:param end_time: End time.
:return: Formatted string with duration, start, and end time.
"""
return 'Asleep {} ({} to {})'.format(
duration_string(duration),
formats.time_format(start_time, 'TIME_FORMAT'),
formats.time_format(end_time, 'TIME_FORMAT'))