utility-scripts/glucometer_graphs.py

873 lines
31 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
'''Utility to convert data from a glucometer into charts.'''
__author__ = 'Timothy Allen'
__email__ = 'tim@treehouse.org.za'
__license__ = 'MIT'
# TODO: comments -- unicode/images/np.array
# TODO: weekly graph with each day's figures as a different-coloured line
import argparse
import csv
import datetime as dt
from matplotlib import rcParams
from matplotlib.figure import Figure
from matplotlib.backends.backend_pdf import FigureCanvasPdf as FigureCanvas
from matplotlib.backends.backend_pdf import PdfPages as FigurePDF
from matplotlib.collections import LineCollection
from matplotlib.colors import ListedColormap, BoundaryNorm
from matplotlib import dates as mdates
from matplotlib import font_manager as fm
from matplotlib import image
from matplotlib.patches import Circle, PathPatch
from matplotlib.path import Path
from matplotlib import ticker as mticker
from matplotlib.offsetbox import (OffsetImage, AnnotationBbox)
import numpy as np
import os
import re
from scipy import interpolate
from scipy.special import binom
import sys
import pprint
# Constants for units
UNIT_MGDL = 'mg/dL'
UNIT_MMOLL = 'mmol/L'
VALID_UNITS = [UNIT_MGDL, UNIT_MMOLL]
# When averaging, set the period to this number of minutes
INTERVAL = 15
# Maximum gluclose value to display (TODO: mmol/mg)
GRAPH_MAX = 21
GRAPH_MIN = 1
DEFAULT_HIGH = 8
DEFAULT_LOW = 4
# Colour for below-target maxmins
RED = '#d71920'
# Colour for above-target maxmins
YELLOW = '#f1b80e'
# Colour for graph lines
BLUE = '#02538f'
# Colour for median glucose box
GREEN = '#009e73'
# Colour for median A1c box
BOXYELLOW = '#e69f00'
def main():
if sys.version_info < (3, 2):
raise Exception(
'Unsupported Python version, please use at least Python 3.2')
pp = pprint.PrettyPrinter(depth=6)
args = parse_arguments()
''' This could be done directly from glucometerutils instead of via CSV '''
with open(args.input_file, 'r', newline='') as f:
rows = from_csv(f)
for row in rows:
row = parse_entry(row, args.icons)
# If we're on the default values for units, highs and lows, check that the average
# value is under 35 (assuming that average mmol/L < 35 and average mg/dL > 35)
if args.units == UNIT_MMOLL and (args.high == DEFAULT_HIGH or args.low == DEFAULT_LOW):
mean = round(np.mean([l.get('value') for l in rows]), 1)
if mean > 35:
args.units = UNIT_MGDL
args.high = convert_glucose_unit(args.high, UNIT_MMOLL)
args.low = convert_glucose_unit(args.low, UNIT_MMOLL)
args.graph_max = convert_glucose_unit(args.graph_max, UNIT_MMOLL)
args.graph_min = convert_glucose_unit(args.graph_min, UNIT_MMOLL)
''' Fill in gaps that might exist in the data, in order to smooth the curves and fills '''
''' We're using 8 minute gaps in order to have more accurate fills '''
rows = fill_gaps(rows, interval=dt.timedelta(minutes=10))
''' Calculate the days and weeks in which we are interested '''
''' Note that trim_weeks should be adjusted based on the interval passed to fill_gaps() '''
(days, weeks) = list_days_and_weeks(rows, trim_weeks=300)
totalweeks = sum([len(weeks[y]) for y in weeks])
totaldays = len(days)
''' Set some defaults '''
rcParams['font.size'] = 8
rcParams['axes.titlesize'] = 12
rcParams['font.family'] = 'sans-serif'
rcParams['font.sans-serif'] = ['Calibri','Verdana','Geneva','Arial','Helvetica','DejaVu Sans','Bitstream Vera Sans','sans-serif']
rcParams['mathtext.default'] = 'regular'
# Load custom fonts for the icon sets
if args.icons:
args.customfont = import_font('fonts/icogluco.ttf') # Works
#args.customfont = import_font('fonts/OpenSansEmoji.ttf') # Alternate working font
nrows = args.graphs_per_page
ncols = 1
plotnum = 1
with FigurePDF(args.output_file) as pdf:
''' Overall averages for all data by hour '''
title = 'Overall Average Daily Glucose Summary'
data = {}
for row in rows:
mpdate = dt.datetime.combine(rows[0]['date'], row.get('date').time())
data[mdates.date2num(mpdate)] = {
'value' : row.get('value'),
'comment' : row.get('comment'),
}
''' Calculate max and min values for each 15 minute interval across the data set '''
intervals = calculate_max_min(rows)
intervaldata = {}
for i in intervals:
mpdate = dt.datetime.combine(rows[0]['date'], i)
intervaldata[mdates.date2num(mpdate)] = {
'max' : intervals.get(i).get('max'),
'min' : intervals.get(i).get('min'),
}
''' Calculate the mean and median blood glucose levels for the day '''
(g_mean, g_median, a_mean, a_median) = calculate_averages(data, args)
figure = Figure(figsize=args.pagesize)
canvas = FigureCanvas(figure)
ax = figure.add_subplot(nrows, ncols, plotnum)
ax.set_title(title)
figure.set_tight_layout({'pad':3})
''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=5)
''' The maxmined curve of maximum and minimum values '''
generate_plot(intervaldata,
ax=ax,
transforms={'spline':False, 'maxmin':True},
args=args,
color='#979797',
alpha=0.5,
)
generate_plot(data,
ax=ax,
transforms={'bezier':True, 'avga1c':a_median, \
'color':[RED, BLUE, RED], 'boundaries':[args.graph_min, args.low, args.high, args.graph_max]},
args=args,
color=BLUE,
)
''' Save the graph to the output PDF if we're at the end of the page '''
pdf.savefig(figure)
ax.clear()
''' Overall averages for a week by hour '''
cnt = 0
for year in reversed(sorted(weeks.keys())):
for week in reversed(sorted(weeks[year].keys())):
''' Turn the year into a date (the first week of the year is the one containing January 4th) '''
time = dt.datetime.combine(dt.date(year, 1, 4), dt.time(0, 0, 0))
monday = time + dt.timedelta(days=-time.weekday(), weeks=week-1)
sunday = monday + dt.timedelta(days=6)
period = monday.strftime('%A, %-d %B %Y') + ' to ' + sunday.strftime('%A, %-d %B %Y');
title = 'Average Daily Glucose for ' + period
weekrows = []
for row in rows:
for dow in range(7):
day = monday + dt.timedelta(days=dow)
if row.get('date').date() == day.date():
weekrows.append(row)
data = {}
for row in weekrows:
mpdate = dt.datetime.combine(monday, row.get('date').time())
data[mdates.date2num(mpdate)] = {
'value' : row.get('value'),
'comment' : row.get('comment'),
}
intervals = calculate_max_min(weekrows)
intervaldata = {}
for i in intervals:
mpdate = dt.datetime.combine(monday.date(), i)
intervaldata[mdates.date2num(mpdate)] = {
'max' : intervals.get(i).get('max'),
'min' : intervals.get(i).get('min'),
}
''' Calculate the mean and median blood glucose levels for the day '''
(g_mean, g_median, a_mean, a_median) = calculate_averages(data, args)
if cnt % nrows == 0:
figure = Figure(figsize=args.pagesize)
canvas = FigureCanvas(figure)
plotnum = (cnt % nrows) + 1
ax = figure.add_subplot(nrows, ncols, plotnum)
ax.set_title(title)
figure.set_tight_layout({'pad':3})
''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=5)
''' The maxmined curve of maximum and minimum values '''
generate_plot(intervaldata,
ax=ax,
transforms={'spline':False, 'maxmin':True, 'avga1c':a_median},
args=args,
color='#979797',
alpha=0.5,
)
generate_plot(data,
ax=ax,
transforms={'bezier':True, \
'color':[RED, BLUE, RED], 'boundaries':[args.graph_min, args.low, args.high, args.graph_max]},
args=args,
color=BLUE,
)
''' Save the graph to the output PDF if we're at the end of the page or at the end of the data '''
if (cnt + 1) % nrows == 0 or (cnt + 1) == totalweeks:
pdf.savefig(figure)
ax.clear()
cnt += 1
''' Daily graphs '''
cnt = 0
for day in reversed(sorted(days.keys())):
title = 'Daily Glucose Summary for ' + day.strftime('%A, %-d %B %Y')
data = {}
for row in rows:
if row.get('date').date() == day.date():
mpdate = dt.datetime.combine(day.date(), row.get('date').time())
data[mdates.date2num(mpdate)] = {
'value' : row.get('value'),
'comment' : row.get('comment'),
}
''' Calculate the mean and median blood glucose levels for the day '''
(g_mean, g_median, a_mean, a_median) = calculate_averages(data, args)
if cnt % nrows == 0:
figure = Figure(figsize=args.pagesize)
canvas = FigureCanvas(figure)
plotnum = (cnt % nrows) + 1
ax = figure.add_subplot(nrows, ncols, plotnum)
ax.set_title(title)
figure.set_tight_layout({'pad':3})
''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=5)
generate_plot(data,
ax=ax,
transforms={'spline':True, 'label':True, 'avgglucose':g_median, 'avga1c':a_median},
args=args,
color=BLUE,
)
''' For max higher than target high '''
generate_plot(data,
ax=ax,
transforms={'spline':True, 'fill':True},
args=args,
)
''' Save the graph to the output PDF if we're at the end of the page '''
if (cnt + 1) % nrows == 0 or (cnt + 1) == totaldays:
pdf.savefig(figure)
ax.clear()
cnt += 1
return 1
def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
pp = pprint.PrettyPrinter(depth=6)
(x, y, z, p, q) = (list(), list(), list(), list(), list())
for (key, value) in sorted(data.items()):
# Time
a = key
if 'maxmin' in transforms:
# If a max and a min exists, initialise them to y and z
b = value.get('max')
c = value.get('min')
else:
# Glucose and comment
b = value.get('value')
c = value.get('comment', '')
x.append(a)
y.append(b)
z.append(c)
x = np.asarray(x)
y = np.asarray(y)
''' Don't convert z to a numpy array if it has text in it '''
if len(z) > 0 and isinstance(z[0], (int, float)):
z = np.asarray(z)
''' Calculations the axis limits '''
firstminute = mdates.num2date(x[0]).replace(hour=0, minute=0, second=0, microsecond=0)
lastminute = mdates.num2date(x[-1]).replace(hour=23, minute=59, second=59, microsecond=59)
x_min = mdates.date2num(firstminute)
x_max = mdates.date2num(lastminute)
ax.set_xlim(x_min, x_max)
ax.set_ylim(args.graph_min, args.graph_max)
''' Calculate the time intervals in 2 hour segments '''
xtimes = []
time = firstminute
while time < lastminute:
xtimes.append(time)
time += dt.timedelta(hours=2)
if args.units == UNIT_MMOLL:
y_tick_freq = 2
else:
y_tick_freq = convert_glucose_unit(2, UNIT_MMOLL)
''' Formatting for axis labels, using date calculations from above '''
ax.set_xlabel('Time', fontsize=9)
ax.set_xbound(firstminute, lastminute)
ax.grid(axis='x', color = '#f0f0f0', zorder=1)
ax.set_xticks(xtimes)
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
ax.xaxis.set_ticks_position('none')
for tick in ax.xaxis.get_major_ticks():
tick.label1.set_horizontalalignment('left')
ax.set_ylabel('Blood Glucose (' + args.units + ')', fontsize=9)
ax.set_ybound(args.graph_min, args.graph_max)
ax.grid(axis='y', color = '#d0d0d0', linestyle = (1,(0.5,2)), zorder=1)
ax.set_yticks([a for a in range(int(args.graph_min), int(args.graph_max), int(y_tick_freq))])
ax.yaxis.set_major_formatter(mticker.FormatStrFormatter("%d"))
ax.yaxis.set_ticks_position('none')
if 'maxmin' in transforms and transforms.get('maxmin') is True:
maxmin = True
else:
maxmin = False
''' Process points to apply smoothing and other fixups '''
for transform in transforms:
if transform == 'linear' and transforms.get(transform) is True:
''' Use SciPy's interp1d for linear transforming '''
if not maxmin:
f = interpolate.interp1d(x, y, kind='linear')
x = np.linspace(x.min(), x.max(), 50) # 50 is number of points to make between x.max & x.min
y = f(x)
elif transform == 'spline' and transforms.get(transform) is True:
''' Use SciPy's UnivariateSpline for transforming (s is transforming factor) '''
if args.units == UNIT_MMOLL:
s = 8
else:
s = convert_glucose_unit(12, UNIT_MMOLL)
if not maxmin:
curve = interpolate.UnivariateSpline(x=x, y=y, k=3, s=s)
y = curve(x)
elif transform == 'bezier' and transforms.get(transform) is True:
''' Create bezier function for transforming (s is transforming factor) '''
def bezier(points, s=100):
n = len(points)
b = [binom(n - 1, i) for i in range(n)]
r = np.arange(n)
for t in np.linspace(0, 1, s):
u = np.power(t, r) * np.power(1 - t, n - r - 1) * b
yield t, u @ points
''' The binomial calculation for the bezier curve overflows with arrays of 1020 or more elements,
For large arrays, get a smaller slice of the full array.
Do this by removing every nth element from the array '''
n = 5
while len(x) > 1000:
x = np.delete(x, np.arange(0, len(x), n), axis=0)
y = np.delete(y, np.arange(0, len(y), n), axis=0)
if not maxmin:
curve = np.array([c for _, c in bezier(np.array([x,y]).T, 250)])
(x, y) = (curve[:,0], curve[:,1])
''' Add the mean or median glucose and A1c values '''
if transform == 'avgglucose' and isinstance(transforms.get(transform), (int, float)):
if args.units == UNIT_MMOLL:
gmtext = 'Median glucose: %.1f%s' % (round(transforms.get('avgglucose'), 1), args.units)
else:
gmtext = 'Median glucose: %.0f%s' % (round(transforms.get('avgglucose'), 1), args.units)
ax.annotate(gmtext, fontsize=9, \
xy=(0.95, 0.85), xycoords='axes fraction', verticalalignment='top', horizontalalignment='right', \
zorder=40, bbox=dict(facecolor=GREEN, edgecolor='#009e73', alpha=0.7, pad=8), \
)
if transform == 'avga1c' and isinstance(transforms.get(transform), (int, float)):
ax.annotate('Median HbA1c: %.1f%%' % round(transforms.get('avga1c'), 1), fontsize=9, \
xy=(0.05, 0.85), xycoords='axes fraction', verticalalignment='top', horizontalalignment='left', \
zorder=40, bbox=dict(facecolor=BOXYELLOW, edgecolor='#e69f00', alpha=0.7, pad=8), \
)
# XXX At present, backend_pdf does not parse unicode correctly, and all recent
# unicode chacters that lack proper glyph names are massed together and printed
# as the same character
if args.units == UNIT_MMOLL:
y_offset = 6
else:
y_offset = convert_glucose_unit(6, UNIT_MMOLL)
if transform == 'label' and transforms.get(transform) is True and args.icons is True:
for x_pos, y_pos, label in zip(x, y, z):
if isinstance(label, dict) and len(label) > 0:
symbol = ''
symbol = '$'
for key in label:
''' In the included IcoGluco font use for args.customfont,
\N{SYRINGE} is a straight syringe (from FreePik),
\N{PUSHPIN} is a an angled syringe (from FreePik),
\N{DAGGER} is unused,
\N{GREEN APPLE} is an apple (from Vectors Market. '''
if key == 'Insulin':
if isinstance(label.get(key), str):
symbol += '\N{SYRINGE}^{%s}' % label.get(key)
#symbol += '\N{PUSHPIN}^{%s}' % label.get(key)
#symbol += '\N{SYRINGE}'
else:
symbol += '\N{SYRINGE}'
elif key == 'Food':
symbol += '\N{GREEN APPLE}'
symbol += '$'
print(symbol)
ax.annotate(
symbol,
xy=(x_pos, args.graph_max-y_offset),
rotation=45,
zorder=25,
fontsize=10,
fontproperties=args.customfont,
)
'''
if len(label) > 0:
if re.sub('F', '', label):
imagebox = OffsetImage(apple, axes=ax, zoom=.1, filternorm=None)
elif re.sub('I', '', label):
imagebox = OffsetImage(syringe, axes=ax, zoom=.1)
ab = AnnotationBbox(
imagebox,
xy=(x_pos, args.graph_max-y_offset),
frameon=False,
)
ab.zorder = 25
ax.add_artist(ab)
'''
''' Create a line coloured according to the list in transforms['color'] '''
if transform == 'boundaries' and 'color' in transforms:
cmap = ListedColormap(transforms.get('color'))
norm = BoundaryNorm(transforms.get('boundaries'), cmap.N)
''' create an array of points on the plot, and split into segments '''
p = np.array([x, y]).T.reshape(-1, 1, 2)
segments = np.concatenate([p[:-1], p[1:]], axis=1)
''' Colour the line according to the values in norm and the colours in cmap '''
lc = LineCollection(segments, cmap=cmap, norm=norm)
lc.set_array(y)
if 'boundaries' in transforms and 'color' in transforms:
ax.add_collection(lc)
elif 'fill' in transforms and transforms.get('fill') is True:
z = np.clip(y, None, args.high)
ax.fill_between(x, y, z, interpolate=True, facecolor=YELLOW, alpha=0.7, zorder=12, **plot_args)
z = np.clip(y, args.low, None)
ax.fill_between(x, y, z, interpolate=True, facecolor=RED, alpha=0.7, zorder=12, **plot_args)
elif maxmin:
ax.fill_between(x, y, z, interpolate=True, zorder=10, **plot_args)
else:
ax.plot(x, y, '-', zorder=20, **plot_args)
return ax
def import_icon(filename):
basedir = os.path.dirname(os.path.abspath(__file__))
filepath = os.path.join(basedir, filename)
if not os.path.exists(filepath):
raise UserError("Image %s does not exist" % filepath)
icon = image.imread(filepath, format='png')
return icon
def import_font(fontname):
basedir = os.path.dirname(os.path.abspath(__file__))
fontdir = os.path.join(basedir, 'fonts')
fontpath = os.path.join(fontdir, fontname)
if not os.path.exists(fontpath):
raise UserError("Font %s does not exist" % fontpath)
prop = fm.FontProperties(fname=fontpath)
return prop
def parse_entry(data, icons, fmt='%Y-%m-%d %H:%M:%S'):
''' Parse a row to create the icons and modify the timestamp
Args:
data: a dict containing the entries 'timestamp' and 'comment'
icons: bool indicating whether to display food/injection icons on the graph
date_format: the format of the timestamp in data
Returns:
data: the modified dict
Raises:
ValueError if an incorrectly-formatted date exists in data['timestamp']
'''
if icons:
''' Ignore comments that aren't relevant '''
rrelevant = re.compile('(Food|Rapid-acting insulin|Long-acting insulin)(?: \((.*?)\))', flags=re.IGNORECASE)
rduplicate = re.compile('^(I\$\^\{\d+\S?)(\}.*)$')
commentparts = {}
for part in data.get('comment').split('; '):
relevant = rrelevant.search(part)
if relevant is not None:
ctype = relevant.group(1)
cvalue = relevant.group(2)
#cvalue = re.sub('(\d+)(\.\d+)?', '\g<1>', cvalue)
''' Convert floating point-style strings (2.0) to integer-style strings (2) '''
try:
cvalue = int(float(cvalue))
except:
pass
cvalue = str(cvalue)
if re.search('Rapid', ctype) is not None:
cvalue += 'R'
if re.search('Long', ctype) is not None:
cvalue += 'L'
# XXX At present, backend_pdf does not parse unicode correctly, and all recent
# unicode chacters that lack proper glyph names are massed together and printed
# as the same character
# XXX Alternatives include replacing the glyph with an image, or a Path
#ctype = re.sub('Food', '$\mathcal{\N{GREEN APPLE}}$', ctype, flags=re.IGNORECASE)
#ctype = re.sub('Rapid-acting insulin', '$\mathcal{\N{SYRINGE}}^\mathrm{'+cvalue+'}$', ctype, flags=re.IGNORECASE)
#ctype = re.sub('Long-acting insulin', '$\mathcal{\N{SYRINGE}}^\mathrm{'+cvalue+'}$', ctype, flags=re.IGNORECASE)
ctype = re.sub('Rapid-acting insulin', 'Insulin', ctype, flags=re.IGNORECASE)
ctype = re.sub('Long-acting insulin', 'Insulin', ctype, flags=re.IGNORECASE)
if ctype in commentparts:
commentparts[ctype] = commentparts[ctype] + '/' + cvalue
else:
commentparts[ctype] = cvalue
data['comment'] = commentparts
else:
data['comment'] = {}
''' Convert timestamp to ISO8601 (by default, at least), and store datetime object '''
try:
date = dt.datetime.strptime(data.get('timestamp'), fmt)
data['date'] = date
except ValueError:
raise ValueError('Invalid date: %s (should be of format %s)' % (data.get('timestamp'), fmt))
data['timestamp'] = date.strftime('%Y-%m-%dT%H:%M:%S')
''' Convert value from string to float '''
data['value'] = float(data.get('value'))
# XXX convert everything to mg/dL for testing
#data['value'] = float(round(data.get('value') * 18.0, 0))
return data
def list_days_and_weeks(data, trim_weeks=192):
''' Create a dictionary of the days and weeks that occur in the CSV
Args:
data: a dict containing a 'timestamp' entry
trim_weeks: the minimum number of entries a week should have in order to be considered for
a weekly average graph. A reading taken every 15 minutes over two days would yield 192 readings.
Returns:
seendays: a dict containing all days in data
seenweeks: a dict containing all weeks in data, subdivided by year
'''
seenweeks = {}
seendays = {}
for d in data:
date = d.get('date')
day = dt.datetime.combine(date.date(), dt.time.min)
(year, week, weekday) = date.isocalendar()
if not year in seenweeks:
seenweeks[year] = {}
if not week in seenweeks[year]:
seenweeks[year][week] = 0
else:
seenweeks[year][week] += 1
if not day in seendays:
seendays[day] = 1
else:
seendays[day] += 1
''' Remove weeks for which there is less than two days of results in that week. '''
''' Note that if we smooth the data to generate a reading every 10 minutes, there will be 144 readings per day '''
editedweeks = dict(seenweeks)
for year in seenweeks:
editedweeks = dict(seenweeks[year])
for week in seenweeks[year]:
if seenweeks[year][week] < trim_weeks:
del editedweeks[week]
seenweeks[year] = dict(editedweeks)
return (seendays, seenweeks)
def calculate_averages(data, args):
''' Return a dictionary with the maximum and mimimum values for each time interval
Args:
data: a dict with the element 'value'
args: a dict with the elements ;
Returns:
g_mean: The mean of all blood glucose 'value' elements
g_median: The median of all blood glucose 'value' elements
a_mean: The blood glucose mean converted to an HbA1c value
a_median: The blood glucose median converted to an HbA1c value
Raises:
ValueError if the blood glucose units can't be parsed or are unknown.
'''
g_mean = round(np.mean([data[k].get('value', 0) for k in data]), 1)
g_median = round(np.median([data[k].get('value', 0) for k in data]), 1)
if args.units == UNIT_MGDL:
a_median = (g_median + 46.7) / 28.7
a_mean = (g_mean + 46.7) / 28.7
elif args.units == UNIT_MMOLL:
a_median = (g_median + 2.59) / 1.59
a_mean = (g_mean + 2.59) / 1.59
else:
raise ValueError('Unknown blood glucose units for HbA1c calculations')
return (g_mean, g_median, a_mean, a_median)
def calculate_max_min(data):
''' Return a dictionary with the maximum and mimimum values for each time interval
Args:
datas: a dict with elements 'timestamp' and 'value'
Returns:
intervals: a dictionary of minimum and maximum values for a a time period
Raises:
ValueError if an incorrectly-formatted date exists in data['timestamp']
'''
intervals = {}
for d in data:
date = d.get('date')
date = date.replace(minute=int(date.minute/INTERVAL)*INTERVAL, second=0, microsecond=0, tzinfo=None)
time = date.time()
if not time in intervals:
intervals[time] = {}
intervals[time]['min'] = d.get('value')
intervals[time]['max'] = d.get('value')
if intervals[time]['min'] < d.get('value'):
intervals[time]['min'] = d.get('value')
if intervals[time]['max'] > d.get('value'):
intervals[time]['max'] = d.get('value')
return intervals
def fill_gaps(rows, interval, maxinterval=dt.timedelta(days=1)):
''' Fill in time gaps that may exist in a set of rows, in order to smooth drawn curves and fills
Args:
rows: a dict containing a 'date' entry (the result of parse_entry())
interval: a datetime.timedelta object that defines the maximum distance allowed between two entries
maxinterval: a datetime.timedelta object that defines the maximum amount of time, over which we ignore
the difference between two consecutive entries
Returns:
filledrows: a dict containing the rows with inserted items.
'''
filledrows = []
for i, row in enumerate(rows):
filledrows.append(row)
''' Don't check the distance between the last value and anything! '''
if i >= len(rows)-1:
continue
''' If the next row has a time gap, create new rows to insert '''
if rows[i+1].get('date') - rows[i].get('date') > interval and \
rows[i+1].get('date') - rows[i].get('date') < maxinterval:
n = (rows[i+1].get('date') - rows[i].get('date'))//interval
start = mdates.date2num(rows[i].get('date'))
end = mdates.date2num(rows[i+1].get('date'))
lower = rows[i].get('value')
upper = rows[i+1].get('value')
''' Calculate an range for each interval, assuming a straight line between the start and
end of the gap.
Use n+2 so we can remove the first and last value which overlap with existing values '''
periods = np.linspace(start, end, n+2)
periods = periods[1:n+1]
values = np.linspace(lower, upper, n+2)
values = values[1:n+1]
for j, val in enumerate(values):
period = mdates.num2date(periods[j])
period = period.replace(microsecond=0, tzinfo=None)
item = {
'date': period,
'meal': '',
'value': float('%.2f' % val),
'comment': '',
'timestamp': period.strftime('%Y-%m-%dT%H:%M:%S'),
'measure_method': 'Estimate',
}
filledrows.append(item)
return filledrows
def verify_pagesize(pagesize = None):
''' Check the page size '''
if re.search('a4', pagesize, flags=re.IGNORECASE) is not None:
pagesize = (11.69, 8.27)
elif re.search('letter', pagesize, flags=re.IGNORECASE) is not None:
pagesize = (11, 8.5)
elif re.search('\d+(cm|in),\d+/', pagesize, flags=re.IGNORECASE) is not None:
''' Do nothing '''
else: # A4 size default
pagesize = (11.69, 8.27)
return pagesize
def verify_units(units = None, high = None, low = None):
''' Standardise units for output and for the A1c calculations '''
if re.search('mg', units, flags=re.IGNORECASE) is not None:
units = UNIT_MGDL
elif re.search('mm', units, flags=re.IGNORECASE) is not None:
units = UNIT_MMOLL
elif isinstance(high, (int, float)) or isinstance(low, (int, float)):
''' If units are not specified by the arguments or calling function, let's assume they are
mg/dL if the high is more than 35 or the low more than 20 '''
if (isinstance(high, (int, float)) and (high > 35) or
isinstance(low, (int, float)) and (low > 20)):
units = UNIT_MGDL
else:
units = UNIT_MMOLL
else:
''' Leave empty so we can auto-detect based on input '''
units = ''
return units
def parse_arguments():
parser = argparse.ArgumentParser(description='Convert a CSV file containing blood sugar measurements into graphs')
parser.add_argument(
'--input', '-i', action='store', required=True, type=str, dest='input_file',
help='Select the CSV file exported by glucometerutils.')
parser.add_argument(
'--output', '-o', action='store', type=str, dest='output_file',
help=('Select the path for the output file.'))
parser.add_argument(
'--pagesize', action='store', required=False, type=str, default='',
help=('Page size of output PDF (currently, letter or A4).'))
parser.add_argument(
'--graphs', action='store', required=False, type=int, default=2, dest='graphs_per_page',
help=('Number of graphs to print per page.'))
parser.add_argument(
'--icons', action='store_true', required=False, default=True,
help=('Print food and injection indicators (default: true).'))
parser.add_argument(
'--units', action='store', required=False, type=str,
default='mmol/L', choices=(UNIT_MGDL, UNIT_MMOLL),
help=('The measurement units used (mmol/L or mg/dL).'))
parser.add_argument(
'--low', action='store', required=False, type=float, default=DEFAULT_LOW,
help=('Minimum of target glucose range.'))
parser.add_argument(
'--high', action='store', required=False, type=float, default=DEFAULT_HIGH,
help=('Maximum of target glucose range.'))
args = parser.parse_args()
args.pagesize = verify_pagesize(args.pagesize)
args.units = verify_units(args.units, args.high, args.low)
if args.units == UNIT_MMOLL:
args.graph_max = GRAPH_MAX
args.graph_min = GRAPH_MIN
else:
args.graph_max = convert_glucose_unit(GRAPH_MAX, UNIT_MMOLL)
args.graph_min = convert_glucose_unit(GRAPH_MIN, UNIT_MMOLL)
''' Ensure we have a valid number of graphs_per_page '''
if not isinstance(args.graphs_per_page, int) or args.graphs_per_page < 1:
args.graphs_per_page = 2
return args
def from_csv(csv_file, newline=''):
'''Returns the reading as a formatted comma-separated value string.'''
data = csv.reader(csv_file, delimiter=',', quotechar='"')
fields = [ 'timestamp', 'value', 'meal', 'measure_method', 'comment' ]
rows = []
for row in data:
item = dict(zip(fields, row))
rows.append(item)
return rows
def convert_glucose_unit(value, from_unit, to_unit=None):
"""Convert the given value of glucose level between units.
Args:
value: The value of glucose in the current unit
from_unit: The unit value is currently expressed in
to_unit: The unit to conver the value to: the other if empty.
Returns:
The converted representation of the blood glucose level.
Raises:
exceptions.InvalidGlucoseUnit: If the parameters are incorrect.
"""
if from_unit not in VALID_UNITS:
raise exceptions.InvalidGlucoseUnit(from_unit)
if from_unit == to_unit:
return value
if to_unit is not None:
if to_unit not in VALID_UNITS:
raise exceptions.InvalidGlucoseUnit(to_unit)
if from_unit is UNIT_MGDL:
return round(value / 18.0, 2)
else:
return round(value * 18.0, 0)
if __name__ == "__main__":
main()
# vim: set expandtab shiftwidth=2 softtabstop=2 tw=0 :