Cleanup documentation.

This commit is contained in:
Timothy Allen 2018-01-05 17:29:59 +02:00
parent 4d77fac610
commit 9f3b597f55

View File

@ -5,17 +5,20 @@ __author__ = 'Timothy Allen'
__email__ = 'tim@treehouse.org.za' __email__ = 'tim@treehouse.org.za'
__license__ = 'MIT' __license__ = 'MIT'
''' Included are the Noto Sans and IcoGluco font sets. ''' Included are the OpenSans and IcoGluco font sets.
Noto Sans is licensed under the SIL Open Font License version 1.1 IcoGluco contains fonts from Noto Sans, which is licensed under the
SIL Open Font License version 1.1
<http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL>, <http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL>,
IcoGluco contains fonts from Noto Sans, as well as as well as a green apple character from
green apple character from Vectors Market <https://www.flaticon.com/authors/vectors-market>, Vectors Market <https://www.flaticon.com/authors/vectors-market>,
licensed under Creative Commons BY 3.0, <http://creativecommons.org/licenses/by/3.0/>, and licensed under Creative Commons BY 3.0, <http://creativecommons.org/licenses/by/3.0/>, and
syringe and pushpin characters from FreePik, <http://www.freepik.com>, syringe and pushpin characters from FreePik, <http://www.freepik.com>,
licensed under Creative Commons BY 3.0, <http://creativecommons.org/licenses/by/3.0/> licensed under Creative Commons BY 3.0, <http://creativecommons.org/licenses/by/3.0/>.
''' '''
# TODO: weekly graph with each day's figures as a different-coloured line # TODO: weekly graph with each day's figures as a different-coloured line
# TODO: Split each type of charts into a separate function and offer a means
# of selecting which charts to generate
import argparse import argparse
import csv import csv
@ -80,6 +83,10 @@ def main():
for row in rows: for row in rows:
row = parse_entry(row, args.icons) row = parse_entry(row, args.icons)
''' 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))
''' If we're on the default values for units, highs and lows, check that the average ''' 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) ''' 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): if args.units == UNIT_MMOLL and (args.high == DEFAULT_HIGH or args.low == DEFAULT_LOW):
@ -92,16 +99,6 @@ def main():
args.graph_max = GRAPH_MAX_MGDL args.graph_max = GRAPH_MAX_MGDL
args.graph_min = GRAPH_MIN_MGDL args.graph_min = GRAPH_MIN_MGDL
''' 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 ''' ''' Set some defaults '''
rcParams['font.size'] = 8 rcParams['font.size'] = 8
rcParams['axes.titlesize'] = 12 rcParams['axes.titlesize'] = 12
@ -116,15 +113,21 @@ def main():
custom icons on IcoMoon, works around this. ''' custom icons on IcoMoon, works around this. '''
if args.icons: if args.icons:
args.customfont = import_font('fonts/icogluco.ttf') args.customfont = import_font('fonts/icogluco.ttf')
#args.customfont = import_font('fonts/OpenSansEmoji.ttf') # Alternate working font #args.customfont = import_font('fonts/OpenSansEmoji.ttf') # Alternate font
''' 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)
nrows = args.graphs_per_page nrows = args.graphs_per_page
ncols = 1 ncols = 1
plotnum = 1 plotnum = 1
with FigurePDF(args.output_file) as pdf: with FigurePDF(args.output_file) as pdf:
''' Overall averages for all data by hour ''' ''' Overall averages for all data by hour of the day '''
title = 'Overall Average Daily Glucose Summary' title = 'Overall Average Glucose Summary'
data = {} data = {}
for row in rows: for row in rows:
@ -144,7 +147,7 @@ def main():
'min' : intervals.get(i).get('min'), 'min' : intervals.get(i).get('min'),
} }
''' Calculate the mean and median blood glucose levels for the day ''' ''' Calculate the mean and median blood glucose and HbA1c levels '''
(g_mean, g_median, a_mean, a_median) = calculate_averages(data, args) (g_mean, g_median, a_mean, a_median) = calculate_averages(data, args)
figure = Figure(figsize=args.pagesize) figure = Figure(figsize=args.pagesize)
@ -155,9 +158,10 @@ def main():
figure.set_tight_layout({'pad':3}) figure.set_tight_layout({'pad':3})
''' Draw the target range ''' ''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=5) ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.1, zorder=5)
''' The maxmined curve of maximum and minimum values ''' ''' The maxmin curve (maximum and minimum values for each 15 minute
period of the data set, by day) '''
generate_plot(intervaldata, generate_plot(intervaldata,
ax=ax, ax=ax,
transforms={'spline':False, 'maxmin':True}, transforms={'spline':False, 'maxmin':True},
@ -166,6 +170,8 @@ def main():
alpha=0.5, alpha=0.5,
) )
''' The graph with a bezier curve applied, and a boundary transform to change line colour
above and below the target values '''
generate_plot(data, generate_plot(data,
ax=ax, ax=ax,
transforms={'bezier':True, 'avga1c':a_median, \ transforms={'bezier':True, 'avga1c':a_median, \
@ -178,7 +184,8 @@ def main():
pdf.savefig(figure) pdf.savefig(figure)
ax.clear() ax.clear()
''' Overall averages for a week by hour '''
''' Overall averages for a week by hour of the dday '''
cnt = 0 cnt = 0
for year in reversed(sorted(weeks.keys())): for year in reversed(sorted(weeks.keys())):
for week in reversed(sorted(weeks[year].keys())): for week in reversed(sorted(weeks[year].keys())):
@ -187,7 +194,7 @@ def main():
monday = time + dt.timedelta(days=-time.weekday(), weeks=week-1) monday = time + dt.timedelta(days=-time.weekday(), weeks=week-1)
sunday = monday + dt.timedelta(days=6) sunday = monday + dt.timedelta(days=6)
period = monday.strftime('%A, %-d %B %Y') + ' to ' + sunday.strftime('%A, %-d %B %Y'); period = monday.strftime('%A, %-d %B %Y') + ' to ' + sunday.strftime('%A, %-d %B %Y');
title = 'Average Daily Glucose for ' + period title = 'Average Glucose for ' + period
weekrows = [] weekrows = []
for row in rows: for row in rows:
@ -204,6 +211,8 @@ def main():
'comment' : row.get('comment'), 'comment' : row.get('comment'),
} }
''' Calculate the maximum and minimum value for each 15-minute period
of the day, across the week '''
intervals = calculate_max_min(weekrows) intervals = calculate_max_min(weekrows)
intervaldata = {} intervaldata = {}
for i in intervals: for i in intervals:
@ -213,7 +222,7 @@ def main():
'min' : intervals.get(i).get('min'), 'min' : intervals.get(i).get('min'),
} }
''' Calculate the mean and median blood glucose levels for the day ''' ''' Calculate the mean and median blood glucose levels for the week '''
(g_mean, g_median, a_mean, a_median) = calculate_averages(data, args) (g_mean, g_median, a_mean, a_median) = calculate_averages(data, args)
if cnt % nrows == 0: if cnt % nrows == 0:
@ -226,7 +235,7 @@ def main():
figure.set_tight_layout({'pad':3}) figure.set_tight_layout({'pad':3})
''' Draw the target range ''' ''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=5) ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.1, zorder=5)
''' The maxmined curve of maximum and minimum values ''' ''' The maxmined curve of maximum and minimum values '''
generate_plot(intervaldata, generate_plot(intervaldata,
@ -237,6 +246,8 @@ def main():
alpha=0.5, alpha=0.5,
) )
''' The graph with a bezier curve applied, and a boundary transform to change line colour
above and below the target values '''
generate_plot(data, generate_plot(data,
ax=ax, ax=ax,
transforms={'bezier':True, \ transforms={'bezier':True, \
@ -280,19 +291,20 @@ def main():
''' Draw the target range ''' ''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=5) ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=5)
''' Draw graph with a spline tranform and labels '''
generate_plot(data, generate_plot(data,
ax=ax, ax=ax,
transforms={'spline':True, 'label':True, 'avgglucose':g_median, 'avga1c':a_median}, transforms={'spline':True, 'label':True, 'avgglucose':g_median, 'avga1c':a_median},
args=args, args=args,
color=BLUE, color=BLUE,
)
) ''' Fill the chart with colour when line is higher or lower than target range '''
''' For max higher than target high '''
generate_plot(data, generate_plot(data,
ax=ax, ax=ax,
transforms={'spline':True, 'fill':True}, transforms={'spline':True, 'fill':True},
args=args, args=args,
) )
''' Save the graph to the output PDF if we're at the end of the page ''' ''' 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: if (cnt + 1) % nrows == 0 or (cnt + 1) == totaldays:
@ -367,7 +379,7 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
else: else:
maxmin = False maxmin = False
''' Process points to apply smoothing and other fixups ''' ''' Transform points to apply smoothing and other fixups '''
for transform in transforms: for transform in transforms:
if transform == 'linear' and transforms.get(transform) is True: if transform == 'linear' and transforms.get(transform) is True:
''' Use SciPy's interp1d for linear transforming ''' ''' Use SciPy's interp1d for linear transforming '''
@ -379,10 +391,11 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
elif transform == 'spline' and transforms.get(transform) is True: elif transform == 'spline' and transforms.get(transform) is True:
''' Use SciPy's UnivariateSpline for transforming (s is transforming factor) ''' ''' Use SciPy's UnivariateSpline for transforming (s is transforming factor) '''
''' An s of 8 (mmol/L) or 200 (mg/dL) was chosen by experimentation! '''
if args.units == UNIT_MMOLL: if args.units == UNIT_MMOLL:
s = 8 s = 8
else: else:
s = convert_glucose_unit(12, UNIT_MMOLL) s = 200
if not maxmin: if not maxmin:
curve = interpolate.UnivariateSpline(x=x, y=y, k=3, s=s) curve = interpolate.UnivariateSpline(x=x, y=y, k=3, s=s)
y = curve(x) y = curve(x)
@ -399,7 +412,7 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
''' The binomial calculation for the bezier curve overflows with arrays of 1020 or more elements, ''' 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. For large arrays, get a smaller slice of the full array.
Do this by removing every nth element from the array ''' Do this by removing every nth element from the array. '''
n = 5 n = 5
while len(x) > 1000: while len(x) > 1000:
x = np.delete(x, np.arange(0, len(x), n), axis=0) x = np.delete(x, np.arange(0, len(x), n), axis=0)
@ -409,21 +422,22 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
curve = np.array([c for _, c in bezier(np.array([x,y]).T, 250)]) curve = np.array([c for _, c in bezier(np.array([x,y]).T, 250)])
(x, y) = (curve[:,0], curve[:,1]) (x, y) = (curve[:,0], curve[:,1])
''' Add the mean or median glucose and A1c values ''' ''' Add the mean or median glucose and A1c values in an annotation box '''
if transform == 'avgglucose' and isinstance(transforms.get(transform), (int, float)): if transform == 'avgglucose' and isinstance(transforms.get(transform), (int, float)):
if args.units == UNIT_MMOLL: if args.units == UNIT_MMOLL:
gmtext = 'Median glucose: %.1f%s' % (round(transforms.get('avgglucose'), 1), args.units) gmtext = 'Median glucose: %.1f%s' % (round(transforms.get('avgglucose'), 1), args.units)
else: else:
gmtext = 'Median glucose: %.0f%s' % (round(transforms.get('avgglucose'), 1), args.units) gmtext = 'Median glucose: %.0f%s' % (round(transforms.get('avgglucose'), 1), args.units)
ax.annotate(gmtext, fontsize=9, \ ax.annotate(gmtext, fontsize=9, xy=(0.95, 0.85),
xy=(0.95, 0.85), xycoords='axes fraction', verticalalignment='top', horizontalalignment='right', \ xycoords='axes fraction', verticalalignment='top', horizontalalignment='right',
zorder=40, bbox=dict(facecolor=GREEN, edgecolor='#009e73', alpha=0.7, pad=8), \ zorder=40, bbox=dict(facecolor=GREEN, edgecolor='#009e73', alpha=0.7, pad=8),
) )
if transform == 'avga1c' and isinstance(transforms.get(transform), (int, float)): if transform == 'avga1c' and isinstance(transforms.get(transform), (int, float)):
ax.annotate('Median HbA1c: %.1f%%' % round(transforms.get('avga1c'), 1), fontsize=9, \ ax.annotate('Median HbA1c: %.1f%%' % round(transforms.get('avga1c'), 1), fontsize=9,
xy=(0.05, 0.85), xycoords='axes fraction', verticalalignment='top', horizontalalignment='left', \ xy=(0.05, 0.85), xycoords='axes fraction',
zorder=40, bbox=dict(facecolor=BOXYELLOW, edgecolor='#e69f00', alpha=0.7, pad=8), \ verticalalignment='top', horizontalalignment='left',
zorder=40, bbox=dict(facecolor=BOXYELLOW, edgecolor='#e69f00', alpha=0.7, pad=8),
) )
if args.units == UNIT_MMOLL: if args.units == UNIT_MMOLL:
@ -434,14 +448,13 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
if transform == 'label' and transforms.get(transform) is True and args.icons is True: 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): for x_pos, y_pos, label in zip(x, y, z):
if isinstance(label, dict) and len(label) > 0: if isinstance(label, dict) and len(label) > 0:
symbol = ''
symbol = '$' symbol = '$'
for key in label: for key in label:
''' In the included IcoGluco font use for args.customfont, ''' In the included IcoGluco font use for args.customfont,
\N{SYRINGE} is a straight syringe (from FreePik), \N{SYRINGE} is a straight syringe (modified from FreePik) for rotated labels,
\N{PUSHPIN} is a an angled syringe (from FreePik), \N{PUSHPIN} is a an angled syringe (from FreePik) for horizontal labels,
\N{DAGGER} is unused, \N{DAGGER} is unused (reserved a different syringe icon),
\N{GREEN APPLE} is an apple (from Vectors Market. ''' \N{GREEN APPLE} is an apple (from Vectors Market). '''
if key == 'Insulin': if key == 'Insulin':
if isinstance(label.get(key), str): if isinstance(label.get(key), str):
symbol += '\N{SYRINGE}^{%s}' % label.get(key) symbol += '\N{SYRINGE}^{%s}' % label.get(key)
@ -452,13 +465,9 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
elif key == 'Food': elif key == 'Food':
symbol += '\N{GREEN APPLE}' symbol += '\N{GREEN APPLE}'
symbol += '$' symbol += '$'
ax.annotate( ax.annotate(symbol, xy=(x_pos, args.graph_max-y_offset),
symbol, rotation=45, zorder=25, fontsize=10,
xy=(x_pos, args.graph_max-y_offset), fontproperties=args.customfont,
rotation=45,
zorder=25,
fontsize=10,
fontproperties=args.customfont,
) )
''' Create a line coloured according to the list in transforms['color'] ''' ''' Create a line coloured according to the list in transforms['color'] '''
@ -493,8 +502,8 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
def import_font(fontname): def import_font(fontname):
''' Turns a relative font path into a matplotlib font property. ''' ''' Turns a relative font path into a matplotlib font property. '''
basedir = os.path.dirname(os.path.abspath(__file__)) basedir = os.path.dirname(os.path.abspath(__file__))
fontpath = os.path.join(basedir, fontname) fontpath = os.path.join(basedir, fontname)
if not os.path.exists(fontpath): if not os.path.exists(fontpath):
raise UserError("Font %s does not exist" % fontpath) raise UserError("Font %s does not exist" % fontpath)
prop = fm.FontProperties(fname=fontpath) prop = fm.FontProperties(fname=fontpath)
@ -516,13 +525,13 @@ def parse_entry(data, icons, fmt='%Y-%m-%d %H:%M:%S'):
''' '''
if icons: if icons:
''' Ignore comments that aren't relevant ''' ''' Ignore comments that aren't relevant '''
rrelevant = re.compile('(Food|Rapid-acting insulin|Long-acting insulin)(?: \((.*?)\))', flags=re.IGNORECASE) rrelevant = re.compile('(Food|Rapid-acting insulin|Long-acting insulin)(?: \((.*?)\))', flags=re.IGNORECASE)
rduplicate = re.compile('^(I\$\^\{\d+\S?)(\}.*)$') rduplicate = re.compile('^(I\$\^\{\d+\S?)(\}.*)$')
commentparts = {} commentparts = {}
for part in data.get('comment').split('; '): for part in data.get('comment').split('; '):
relevant = rrelevant.search(part) relevant = rrelevant.search(part)
if relevant is not None: if relevant is not None:
ctype = relevant.group(1) ctype = relevant.group(1)
cvalue = relevant.group(2) cvalue = relevant.group(2)
''' Convert floating point-style strings (2.0) to integer-style strings (2) ''' ''' Convert floating point-style strings (2.0) to integer-style strings (2) '''
@ -828,6 +837,9 @@ def convert_glucose_unit(value, from_unit, to_unit=None):
Raises: Raises:
exceptions.InvalidGlucoseUnit: If the parameters are incorrect. exceptions.InvalidGlucoseUnit: If the parameters are incorrect.
Note that this is defined by the main glucometerutils package, from which
this function is duplicated, and is not a valid exception for this script.
So let's hope it doesn't get triggered!
""" """
if from_unit not in VALID_UNITS: if from_unit not in VALID_UNITS:
raise exceptions.InvalidGlucoseUnit(from_unit) raise exceptions.InvalidGlucoseUnit(from_unit)