From 7087b5299a294858de3287da6437ed612b093ed9 Mon Sep 17 00:00:00 2001 From: tim Date: Wed, 3 Jan 2018 02:45:45 +0200 Subject: [PATCH] Fix bugs that occur when switching to mg/dL units. --- glucometer_graphs.py | 205 +++++++++++++++++++++++++++---------------- 1 file changed, 127 insertions(+), 78 deletions(-) diff --git a/glucometer_graphs.py b/glucometer_graphs.py index 5009c85..b0bea12 100755 --- a/glucometer_graphs.py +++ b/glucometer_graphs.py @@ -9,7 +9,7 @@ __license__ = 'MIT' # TODO: comments -- unicode # TODO: prettify # TODO: weekly graph with each day's figures as a different-coloured line -# TODO: verify either set of units (mmol,mg/dl) works with the data +# TODO: verify either set of units (mmol/L,mg/dl) works with the data import argparse import csv @@ -39,17 +39,20 @@ 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_MAX = 21 +GRAPH_MIN = 1 +DEFAULT_HIGH = 8 +DEFAULT_LOW = 4 -# Set colour for below-target maxmins +# Colour for below-target maxmins RED = '#d71920' -# Set colour for above-target maxmins +# Colour for above-target maxmins YELLOW = '#f1b80e' -# Set colour for graph lines +# Colour for graph lines BLUE = '#02538f' -# Set colour for median glucose box +# Colour for median glucose box GREEN = '#009e73' -# Set colour for median A1c box +# Colour for median A1c box BOXYELLOW = '#e69f00' def main(): @@ -64,10 +67,22 @@ def main(): ''' 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['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)) @@ -100,7 +115,7 @@ def main(): data = {} for row in rows: mpdate = dt.datetime.combine(rows[0]['date'], row.get('date').time()) - data[mdates.date2num(mpdate)] = { + data[mdates.date2num(mpdate)] = { 'value' : row.get('value'), 'comment' : row.get('comment'), } @@ -110,7 +125,7 @@ def main(): intervaldata = {} for i in intervals: mpdate = dt.datetime.combine(rows[0]['date'], i) - intervaldata[mdates.date2num(mpdate)] = { + intervaldata[mdates.date2num(mpdate)] = { 'max' : intervals.get(i).get('max'), 'min' : intervals.get(i).get('min'), } @@ -129,10 +144,10 @@ def main(): 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, + generate_plot(intervaldata, ax=ax, transforms={'spline':False, 'maxmin':True}, - args=args, + args=args, color='#979797', alpha=0.5, ) @@ -140,8 +155,8 @@ def main(): generate_plot(data, ax=ax, transforms={'bezier':True, 'avga1c':a_median, \ - 'color':[RED, BLUE, RED], 'boundaries':[0, args.low, args.high, GRAPH_MAX]}, - args=args, + 'color':[RED, BLUE, RED], 'boundaries':[args.graph_min, args.low, args.high, args.graph_max]}, + args=args, color=BLUE, ) @@ -166,11 +181,11 @@ def main(): 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)] = { + data[mdates.date2num(mpdate)] = { 'value' : row.get('value'), 'comment' : row.get('comment'), } @@ -179,7 +194,7 @@ def main(): intervaldata = {} for i in intervals: mpdate = dt.datetime.combine(monday.date(), i) - intervaldata[mdates.date2num(mpdate)] = { + intervaldata[mdates.date2num(mpdate)] = { 'max' : intervals.get(i).get('max'), 'min' : intervals.get(i).get('min'), } @@ -200,19 +215,19 @@ def main(): 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, + generate_plot(intervaldata, ax=ax, transforms={'spline':False, 'maxmin':True, 'avga1c':a_median}, - args=args, + args=args, color='#979797', alpha=0.5, ) - generate_plot(data, + generate_plot(data, ax=ax, transforms={'bezier':True, \ - 'color':[RED, BLUE, RED], 'boundaries':[0, args.low, args.high, GRAPH_MAX]}, - args=args, + 'color':[RED, BLUE, RED], 'boundaries':[args.graph_min, args.low, args.high, args.graph_max]}, + args=args, color=BLUE, ) @@ -231,7 +246,7 @@ def main(): 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)] = { + data[mdates.date2num(mpdate)] = { 'value' : row.get('value'), 'comment' : row.get('comment'), } @@ -251,18 +266,18 @@ def main(): ''' Draw the target range ''' ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=5) - generate_plot(data, + generate_plot(data, ax=ax, transforms={'spline':True, 'label':True, 'avgglucose':g_median, 'avga1c':a_median}, - args=args, + args=args, color=BLUE, - + ) ''' For max higher than target high ''' - generate_plot(data, + generate_plot(data, ax=ax, 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 ''' @@ -308,13 +323,17 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): x_min = mdates.date2num(firstminute) x_max = mdates.date2num(lastminute) ax.set_xlim(x_min, x_max) - ax.set_ylim(0, GRAPH_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) @@ -322,16 +341,16 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): 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') + 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(0, GRAPH_MAX) + 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(0, GRAPH_MAX, 2)]) + 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') + ax.yaxis.set_ticks_position('none') if 'maxmin' in transforms and transforms['maxmin'] is True: @@ -347,19 +366,17 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): 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: ''' 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=8) + curve = interpolate.UnivariateSpline(x=x, y=y, k=3, s=s) y = curve(x) - #else: - # TODO Apply spline to each curve? - #curve1 = interpolate.UnivariateSpline(x=x, y=y, k=3, s=5) - #curve2 = interpolate.UnivariateSpline(x=x, y=z, k=3, s=5) - #y = curve1(x) - #z = curve2(x) - + elif transform == 'bezier' and transforms[transform] is True: ''' Create bezier function for transforming (s is transforming factor) ''' def bezier(points, s=100): @@ -369,13 +386,11 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): 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. + 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(p) > 1000: - p = np.delete(p, np.arange(0, len(p), n), axis=0) 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) @@ -383,18 +398,15 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): if not maxmin: curve = np.array([c for _, c in bezier(np.array([x,y]).T, 250)]) (x, y) = (curve[:,0], curve[:,1]) - #else: - # TODO Apply bezier to each curve? Will this work for x? - #while len(q) > 1000: - # q = np.delete(q, np.arange(0, len(q), n), axis=0) - #curve1 = np.array([c for _, c in bezier(p[:], 250)]) - #curve2 = np.array([c for _, c in bezier(q[:], 250)]) - #(x, y) = (curve1[:,0], curve1[:,1]) - #(x, z) = (curve2[:,0], curve2[:,1]) - + ''' Add the mean or median glucose and A1c values ''' if transform == 'avgglucose' and isinstance(transforms[transform], (int, float)): - ax.annotate('Median glucose: %.1f%s' % (round(transforms['avgglucose'], 1), args.units), fontsize=9, \ + if args.units == UNIT_MMOLL: + gmtext = 'Median glucose: %.1f%s' % (round(transforms['avgglucose'], 1), args.units) + else: + gmtext = 'Median glucose: %.0f%s' % (round(transforms['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), \ ) @@ -404,8 +416,8 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): 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 + # 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 transform == 'label' and transforms[transform] is True: for a, b, label in zip(x, y, z): @@ -413,7 +425,7 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): #print(label) ax.annotate( label, - xy=(a, GRAPH_MAX-6), + xy=(a, args.graph_max-6), rotation=45, zorder=25, ) @@ -449,16 +461,16 @@ def generate_plot(data, ax=None, transforms={}, args=[], **plot_args): return ax def parse_entry(data, icons, fmt='%Y-%m-%d %H:%M:%S'): - ''' Parse a row to create the icons and modify the timestamp - + ''' Parse a row to create the icons and modify the timestamp + Args: - data: a dict containing the entries 'timestamp' and 'comment' + 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'] ''' @@ -479,8 +491,8 @@ def parse_entry(data, icons, fmt='%Y-%m-%d %H:%M:%S'): 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 + # 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) @@ -511,14 +523,17 @@ def parse_entry(data, icons, fmt='%Y-%m-%d %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 - + ''' 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 + 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: @@ -592,7 +607,7 @@ def calculate_max_min(data): datas: a dict with elements 'timestamp' and 'value' Returns: - intervals: a dictionary of minimum and maximum values for a a time period + intervals: a dictionary of minimum and maximum values for a a time period Raises: ValueError if an incorrectly-formatted date exists in data['timestamp'] @@ -602,7 +617,7 @@ def calculate_max_min(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') @@ -618,11 +633,11 @@ def calculate_max_min(data): 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 + maxinterval: a datetime.timedelta object that defines the maximum amount of time, over which we ignore the difference between two consecutive entries Returns: @@ -661,7 +676,7 @@ def fill_gaps(rows, interval, maxinterval=dt.timedelta(days=1)): item = { 'date': period, 'meal': '', - 'value': float('%.2f' % val), + 'value': float('%.2f' % val), 'comment': '', 'timestamp': period.strftime('%Y-%m-%dT%H:%M:%S'), 'measure_method': 'Estimate', @@ -689,7 +704,7 @@ def verify_units(units = None, high = None, low = None): elif re.search('mg', 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 + ''' 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)): @@ -726,16 +741,22 @@ def parse_arguments(): 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=4, + '--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=8, + '--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: @@ -745,7 +766,7 @@ def parse_arguments(): def from_csv(csv_file, newline=''): '''Returns the reading as a formatted comma-separated value string.''' - data = csv.reader(csv_file, delimiter=',', quotechar='"') + data = csv.reader(csv_file, delimiter=',', quotechar='"') fields = [ 'timestamp', 'value', 'meal', 'measure_method', 'comment' ] rows = [] for row in data: @@ -753,6 +774,34 @@ def from_csv(csv_file, newline=''): 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__":