diff --git a/glucometer_graphs.py b/glucometer_graphs.py index 722ea12..248dbd1 100755 --- a/glucometer_graphs.py +++ b/glucometer_graphs.py @@ -5,17 +5,20 @@ __author__ = 'Timothy Allen' __email__ = 'tim@treehouse.org.za' __license__ = 'MIT' -''' Included are the Noto Sans and IcoGluco font sets. - Noto Sans is licensed under the SIL Open Font License version 1.1 +''' Included are the OpenSans and IcoGluco font sets. + IcoGluco contains fonts from Noto Sans, which is licensed under the + SIL Open Font License version 1.1 , - IcoGluco contains fonts from Noto Sans, as well as - green apple character from Vectors Market , + as well as a green apple character from + Vectors Market , licensed under Creative Commons BY 3.0, , and syringe and pushpin characters from FreePik, , - licensed under Creative Commons BY 3.0, + licensed under Creative Commons BY 3.0, . ''' # 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 csv @@ -80,6 +83,10 @@ def main(): for row in rows: 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 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): @@ -92,16 +99,6 @@ def main(): args.graph_max = GRAPH_MAX_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 ''' rcParams['font.size'] = 8 rcParams['axes.titlesize'] = 12 @@ -116,15 +113,21 @@ def main(): custom icons on IcoMoon, works around this. ''' if args.icons: 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 ncols = 1 plotnum = 1 with FigurePDF(args.output_file) as pdf: - ''' Overall averages for all data by hour ''' - title = 'Overall Average Daily Glucose Summary' + ''' Overall averages for all data by hour of the day ''' + title = 'Overall Average Glucose Summary' data = {} for row in rows: @@ -144,7 +147,7 @@ def main(): '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) figure = Figure(figsize=args.pagesize) @@ -155,9 +158,10 @@ def main(): 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) + 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, ax=ax, transforms={'spline':False, 'maxmin':True}, @@ -166,6 +170,8 @@ def main(): 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, ax=ax, transforms={'bezier':True, 'avga1c':a_median, \ @@ -178,7 +184,8 @@ def main(): pdf.savefig(figure) ax.clear() - ''' Overall averages for a week by hour ''' + + ''' Overall averages for a week by hour of the dday ''' cnt = 0 for year in reversed(sorted(weeks.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) 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 + title = 'Average Glucose for ' + period weekrows = [] for row in rows: @@ -204,6 +211,8 @@ def main(): '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) intervaldata = {} for i in intervals: @@ -213,7 +222,7 @@ def main(): '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) if cnt % nrows == 0: @@ -226,7 +235,7 @@ def main(): 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) + ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.1, zorder=5) ''' The maxmined curve of maximum and minimum values ''' generate_plot(intervaldata, @@ -237,6 +246,8 @@ def main(): 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, ax=ax, transforms={'bezier':True, \ @@ -280,19 +291,20 @@ def main(): ''' Draw the target range ''' 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, ax=ax, transforms={'spline':True, 'label':True, 'avgglucose':g_median, 'avga1c':a_median}, args=args, color=BLUE, + ) - ) - ''' For max higher than target high ''' + ''' Fill the chart with colour when line is higher or lower than target range ''' 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: @@ -367,7 +379,7 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): else: maxmin = False - ''' Process points to apply smoothing and other fixups ''' + ''' Transform 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 ''' @@ -379,10 +391,11 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): elif transform == 'spline' and transforms.get(transform) is True: ''' 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: s = 8 else: - s = convert_glucose_unit(12, UNIT_MMOLL) + s = 200 if not maxmin: curve = interpolate.UnivariateSpline(x=x, y=y, k=3, s=s) 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, 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 while len(x) > 1000: 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)]) (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 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), \ - ) + 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), \ + 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), ) 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: 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. ''' + \N{SYRINGE} is a straight syringe (modified from FreePik) for rotated labels, + \N{PUSHPIN} is a an angled syringe (from FreePik) for horizontal labels, + \N{DAGGER} is unused (reserved a different syringe icon), + \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) @@ -452,13 +465,9 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): elif key == 'Food': symbol += '\N{GREEN APPLE}' symbol += '$' - ax.annotate( - symbol, - xy=(x_pos, args.graph_max-y_offset), - rotation=45, - zorder=25, - fontsize=10, - fontproperties=args.customfont, + ax.annotate(symbol, xy=(x_pos, args.graph_max-y_offset), + rotation=45, zorder=25, fontsize=10, + fontproperties=args.customfont, ) ''' 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): ''' Turns a relative font path into a matplotlib font property. ''' - basedir = os.path.dirname(os.path.abspath(__file__)) - fontpath = os.path.join(basedir, fontname) + basedir = os.path.dirname(os.path.abspath(__file__)) + fontpath = os.path.join(basedir, fontname) if not os.path.exists(fontpath): raise UserError("Font %s does not exist" % fontpath) prop = fm.FontProperties(fname=fontpath) @@ -516,13 +525,13 @@ def parse_entry(data, icons, fmt='%Y-%m-%d %H:%M:%S'): ''' 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?)(\}.*)$') + 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) + ctype = relevant.group(1) cvalue = relevant.group(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: 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: raise exceptions.InvalidGlucoseUnit(from_unit)