Fix bugs that occur when switching to mg/dL units.

This commit is contained in:
Timothy Allen 2018-01-03 02:45:45 +02:00
parent 9ed89e7f8f
commit 7087b5299a
1 changed files with 127 additions and 78 deletions

View File

@ -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__":