diff --git a/fonts/OpenSansEmoji.ttf b/fonts/OpenSansEmoji.ttf new file mode 100644 index 0000000..57d86a6 Binary files /dev/null and b/fonts/OpenSansEmoji.ttf differ diff --git a/fonts/icogluco.ttf b/fonts/icogluco.ttf new file mode 100644 index 0000000..980a55f Binary files /dev/null and b/fonts/icogluco.ttf differ diff --git a/glucometer_graphs.py b/glucometer_graphs.py index d3b7dc0..a615792 100755 --- a/glucometer_graphs.py +++ b/glucometer_graphs.py @@ -19,10 +19,14 @@ 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 @@ -72,7 +76,7 @@ def main(): # 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['value'] for l in rows]), 1) + 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) @@ -96,11 +100,12 @@ def main(): 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'] - ''' Misuse the mathtext "mathcal" definition for the Unicode characters in Symbola ''' - rcParams['mathtext.fontset'] = 'custom' - rcParams['mathtext.cal'] = 'Symbola' 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 @@ -311,9 +316,6 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): ''' 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) - else: - z = np.asarray(z, dtype='unicode_') - ''' Calculations the axis limits ''' firstminute = mdates.num2date(x[0]).replace(hour=0, minute=0, second=0, microsecond=0) @@ -351,21 +353,21 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): ax.yaxis.set_ticks_position('none') - if 'maxmin' in transforms and transforms['maxmin'] is True: + 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[transform] is True: + 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[transform] is True: + 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 @@ -375,7 +377,7 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): curve = interpolate.UnivariateSpline(x=x, y=y, k=3, s=s) y = curve(x) - elif transform == 'bezier' and transforms[transform] is True: + 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) @@ -398,18 +400,18 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): (x, y) = (curve[:,0], curve[:,1]) ''' Add the mean or median glucose and A1c values ''' - if transform == 'avgglucose' and isinstance(transforms[transform], (int, float)): + if transform == 'avgglucose' and isinstance(transforms.get(transform), (int, float)): if args.units == UNIT_MMOLL: - gmtext = 'Median glucose: %.1f%s' % (round(transforms['avgglucose'], 1), args.units) + gmtext = 'Median glucose: %.1f%s' % (round(transforms.get('avgglucose'), 1), args.units) else: - gmtext = 'Median glucose: %.0f%s' % (round(transforms['avgglucose'], 1), args.units) + 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[transform], (int, float)): - ax.annotate('Median HbA1c: %.1f%%' % round(transforms['avga1c'], 1), fontsize=9, \ + 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), \ ) @@ -422,21 +424,56 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): else: y_offset = convert_glucose_unit(6, UNIT_MMOLL) - if transform == 'label' and transforms[transform] 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): - if len(label) > 0: - #print(label) + 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( - label, + 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['color']) - norm = BoundaryNorm(transforms['boundaries'], cmap.N) + 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) @@ -448,7 +485,7 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): if 'boundaries' in transforms and 'color' in transforms: ax.add_collection(lc) - elif 'fill' in transforms and transforms['fill'] is True: + 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) @@ -463,6 +500,23 @@ def generate_plot(data, ax=None, transforms={}, args=[], **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 @@ -478,17 +532,24 @@ def parse_entry(data, icons, fmt='%Y-%m-%d %H:%M:%S'): ValueError if an incorrectly-formatted date exists in data['timestamp'] ''' 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) - rduplicate = re.compile('.*?(\N{SYRINGE})') - comment_parts = [] - for comment_part in data.get('comment').split('; '): - relevant = rrelevant.search(comment_part) + 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) + #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: @@ -498,22 +559,20 @@ def parse_entry(data, icons, fmt='%Y-%m-%d %H:%M:%S'): # 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', '$\mathcal{💉}$', ctype, flags=re.IGNORECASE) - #ctype = re.sub('Long-acting insulin', '$\mathcal{💉}$', ctype, flags=re.IGNORECASE) + #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) - idx = [i for i, x in enumerate(comment_parts) if rduplicate.search(x)] - if idx: - comment_parts[idx[0]] = re.sub('^(.*?\d+\S?)(.*)$', '\g<1>/'+cvalue+'\g<2>', comment_parts[idx[0]]) + if ctype in commentparts: + commentparts[ctype] = commentparts[ctype] + '/' + cvalue else: - comment_parts.append(ctype) + commentparts[ctype] = cvalue - comment = ''.join(comment_parts) - data['comment'] = comment + data['comment'] = commentparts else: - data['comment'] = '' + data['comment'] = {} ''' Convert timestamp to ISO8601 (by default, at least), and store datetime object ''' try: @@ -655,14 +714,14 @@ def fill_gaps(rows, interval, maxinterval=dt.timedelta(days=1)): continue ''' If the next row has a time gap, create new rows to insert ''' - if rows[i+1]['date'] - rows[i]['date'] > interval and \ - rows[i+1]['date'] - rows[i]['date'] < maxinterval: + 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]['date'] - rows[i]['date'])//interval - start = mdates.date2num(rows[i]['date']) - end = mdates.date2num(rows[i+1]['date']) - lower = rows[i]['value'] - upper = rows[i+1]['value'] + 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.