forked from glucometers-tech/glucometerutils
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathglucometer_graphs.py
executable file
·888 lines (747 loc) · 32.5 KB
/
glucometer_graphs.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
''' Utility to convert data from a glucometer into charts. '''
__author__ = 'Timothy Allen'
__email__ = '[email protected]'
__license__ = 'MIT'
''' 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
<http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL>,
as well as a green apple character from
Vectors Market <https://www.flaticon.com/authors/vectors-market>,
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>,
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: Split each type of charts into a separate function and offer a means
# of selecting which charts to generate
import argparse
import csv
import datetime as dt
from matplotlib import rcParams
from matplotlib.figure import Figure
from matplotlib.backends.backend_pdf import FigureCanvasPdf as FigureCanvas
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.patches import Circle, PathPatch
from matplotlib.path import Path
from matplotlib import ticker as mticker
import numpy as np
import os
import re
from scipy import interpolate
from scipy.special import binom
import sys
''' Constants for units '''
UNIT_MGDL = 'mg/dL'
UNIT_MMOLL = 'mmol/L'
VALID_UNITS = [UNIT_MGDL, UNIT_MMOLL]
''' When averaging, set the period to this number of minutes '''
INTERVAL = 15
''' Set the default high and low in mmol/L; it will be reset to mg/dL if neccessary '''
DEFAULT_HIGH = 8
DEFAULT_LOW = 4
''' Maximum glucose value to display '''
GRAPH_MAX_MMOLL = 21
GRAPH_MIN_MMOLL = 0
GRAPH_MAX_MGDL = 400
GRAPH_MIN_MGDL = 0
''' Colour for below-target maxmins '''
RED = '#d71920'
'''' Colour for above-target maxmins '''
YELLOW = '#f1b80e'
''' Colour for graph lines '''
BLUE = '#02538f'
''' Colour for median glucose box '''
GREEN = '#009e73'
''' Colour for median A1c box '''
BOXYELLOW = '#e69f00'
def main():
if sys.version_info < (3, 2):
raise Exception(
'Unsupported Python version, please use at least Python 3.2')
args = parse_arguments()
''' This could be done directly from glucometerutils instead of via CSV '''
with open(args.input_file, 'r', newline='') as f:
rows = from_csv(f)
''' Skip ketone entries '''
rketones = re.compile('Ketone', flags=re.IGNORECASE);
for row in rows:
if rketones.search(row.get('measure_method')):
rows.remove(row);
elif rketones.search(row.get('comment')):
rows.remove(row);
''' Skip finger stick test entries '''
rfinger = re.compile('Blood', flags=re.IGNORECASE);
if not args.fingerstick:
for row in rows:
if rfinger.search(row.get('comment')):
rows.remove(row);
for row in rows:
row = parse_entry(row, args.icons)
''' Ensure that the rows are sorted by date '''
rows = sorted(rows, key=lambda row: row.get('date'), reverse=False)
''' Fill in gaps that might exist in the data, in order to smooth the curves and fills '''
''' We're using 10 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):
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)
args.low = convert_glucose_unit(args.low, UNIT_MMOLL)
''' Manually specify max and min for mg/dL '''
args.graph_max = GRAPH_MAX_MGDL
args.graph_min = GRAPH_MIN_MGDL
''' Set some defaults '''
rcParams['font.size'] = 8
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']
rcParams['mathtext.default'] = 'regular'
''' Load custom fonts for the icon sets
At present, backend_pdf does not parse unicode correctly, and unicode
characters from many fonts that lack proper glyph names are massed together
and printed as the same character. The IcoGluco font, generated from Noto Sans and
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 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 of the day '''
start = rows[0].get('date')
end = rows[-1].get('date')
period = start.strftime('%A, %-d %B %Y') + ' to ' + end.strftime('%A, %-d %B %Y')
title = 'Overall Average Glucose Summary for ' + period
data = {}
for row in rows:
mpdate = dt.datetime.combine(rows[0]['date'], row.get('date').time())
data[mdates.date2num(mpdate)] = {
'value' : row.get('value'),
'comment' : row.get('comment'),
}
''' Calculate max and min values for each 15 minute interval across the data set '''
intervals = calculate_max_min(rows)
intervaldata = {}
for i in intervals:
mpdate = dt.datetime.combine(rows[0]['date'], i)
intervaldata[mdates.date2num(mpdate)] = {
'max' : intervals.get(i).get('max'),
'min' : intervals.get(i).get('min'),
}
''' 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)
canvas = FigureCanvas(figure)
ax = figure.add_subplot(nrows, ncols, plotnum)
ax.set_title(title)
figure.set_tight_layout({'pad':3})
''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.1, zorder=15)
''' The maxmin fill (maximum and minimum values for each 15 minute
period of the data set, by day) '''
generate_plot(intervaldata,
ax=ax,
transforms={'spline':True, 'maxmin':True},
args=args,
color='#979797',
)
''' 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, \
'color':[RED, BLUE, RED], 'boundaries':[args.graph_min, args.low, args.high, args.graph_max]},
args=args,
color=BLUE,
)
''' Save the graph to the output PDF if we're at the end of the page '''
pdf.savefig(figure)
ax.clear()
''' 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())):
''' Turn the year into a date (the first week of the year is the one containing January 4th) '''
time = dt.datetime.combine(dt.date(year, 1, 4), dt.time(0, 0, 0))
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 Glucose for ' + period
weekrows = []
for row in rows:
for dow in range(7):
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)] = {
'value' : row.get('value'),
'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:
mpdate = dt.datetime.combine(monday.date(), i)
intervaldata[mdates.date2num(mpdate)] = {
'max' : intervals.get(i).get('max'),
'min' : intervals.get(i).get('min'),
}
''' 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:
figure = Figure(figsize=args.pagesize)
canvas = FigureCanvas(figure)
plotnum = (cnt % nrows) + 1
ax = figure.add_subplot(nrows, ncols, plotnum)
ax.set_title(title)
figure.set_tight_layout({'pad':3})
''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.1, zorder=15)
''' The maxmin fill of maximum and minimum values '''
generate_plot(intervaldata,
ax=ax,
transforms={'spline':True, 'maxmin':True, 'avga1c':a_median},
args=args,
color='#979797',
)
''' 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, \
'color':[RED, BLUE, RED], 'boundaries':[args.graph_min, args.low, args.high, args.graph_max]},
args=args,
color=BLUE,
)
''' Save the graph to the output PDF if we're at the end of the page or at the end of the data '''
if (cnt + 1) % nrows == 0 or (cnt + 1) == totalweeks:
pdf.savefig(figure)
ax.clear()
cnt += 1
''' Daily graphs '''
cnt = 0
for day in reversed(sorted(days.keys())):
title = 'Daily Glucose Summary for ' + day.strftime('%A, %-d %B %Y')
data = {}
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)] = {
'value' : row.get('value'),
'comment' : row.get('comment'),
}
''' Calculate the mean and median blood glucose levels for the day '''
(g_mean, g_median, a_mean, a_median) = calculate_averages(data, args)
if cnt % nrows == 0:
figure = Figure(figsize=args.pagesize)
canvas = FigureCanvas(figure)
plotnum = (cnt % nrows) + 1
ax = figure.add_subplot(nrows, ncols, plotnum)
ax.set_title(title)
figure.set_tight_layout({'pad':3})
''' Draw the target range '''
ax.axhspan(args.low, args.high, facecolor='#0072b2', edgecolor='#a8a8a8', alpha=0.2, zorder=15)
''' Draw graph with a spline tranform and labels '''
generate_plot(data,
ax=ax,
transforms={'spline':True, 'label':True, 'avgglucose':g_median},
args=args,
color=BLUE,
)
''' 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:
pdf.savefig(figure)
ax.clear()
cnt += 1
return 1
def generate_plot(data, ax=None, transforms={}, args=[], **plot_args):
(x, y, z, p, q) = (list(), list(), list(), list(), list())
for (key, value) in sorted(data.items()):
''' Time '''
a = key
if 'maxmin' in transforms:
''' If a max and a min exists, initialise them to y and z '''
b = value.get('max')
c = value.get('min')
else:
''' Glucose and comment '''
b = value.get('value')
c = value.get('comment', '')
x.append(a)
y.append(b)
z.append(c)
x = np.asarray(x)
y = np.asarray(y)
''' 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)
''' Calculations the axis limits '''
firstminute = mdates.num2date(x[0]).replace(hour=0, minute=0, second=0, microsecond=0)
lastminute = mdates.num2date(x[-1]).replace(hour=23, minute=59, second=59, microsecond=59)
x_min = mdates.date2num(firstminute)
x_max = mdates.date2num(lastminute)
ax.set_xlim(x_min, x_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 = 50
''' Formatting for axis labels, using date calculations from above '''
ax.set_xlabel('Time', fontsize=9)
ax.set_xbound(firstminute, lastminute)
ax.grid(axis='x', color = '#f0f0f0', zorder=5)
ax.set_xticks(xtimes)
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
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(args.graph_min, args.graph_max)
ax.grid(axis='y', color = '#d0d0d0', linestyle = (1,(0.5,2)), zorder=5)
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')
if 'maxmin' in transforms and transforms.get('maxmin') is True:
maxmin = True
else:
maxmin = False
''' 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 '''
if not maxmin:
f = interpolate.interp1d(x, y, kind='linear')
''' 50 is number of points to make between x.max & x.min '''
x = np.linspace(x.min(), x.max(), 50)
y = f(x)
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 = 200
if not maxmin:
curve = interpolate.UnivariateSpline(x=x, y=y, k=3, s=s)
y = curve(x)
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)
b = [binom(n - 1, i) for i in range(n)]
r = np.arange(n)
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.
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)
y = np.delete(y, np.arange(0, len(y), n), axis=0)
if not maxmin:
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 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=60, 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=60, bbox=dict(facecolor=BOXYELLOW, edgecolor='#e69f00', alpha=0.7, pad=8),
)
if args.units == UNIT_MMOLL:
y_offset = 6
else:
y_offset = convert_glucose_unit(6, UNIT_MMOLL)
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 = '$'
for key in label:
''' In the included IcoGluco font use for args.customfont,
\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)
#symbol += '\N{PUSHPIN}^{%s}' % label.get(key)
#symbol += '\N{SYRINGE}'
else:
symbol += '\N{SYRINGE}'
elif key == 'Food':
symbol += '\N{GREEN APPLE}'
symbol += '$'
ax.annotate(symbol, xy=(x_pos, args.graph_max-y_offset),
rotation=45, zorder=40, fontsize=10,
fontproperties=args.customfont,
)
''' Create a line coloured according to the list in transforms['color'] '''
if transform == 'boundaries' and 'color' in transforms:
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)
''' Colour the line according to the values in norm and the colours in cmap '''
lc = LineCollection(segments, cmap=cmap, norm=norm, alpha=1, zorder=30)
lc.set_array(y)
if 'boundaries' in transforms and 'color' in transforms:
ax.add_collection(lc)
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=20, **plot_args)
z = np.clip(y, args.low, None)
ax.fill_between(x, y, z, interpolate=True, facecolor=RED, alpha=0.7, zorder=20, **plot_args)
elif maxmin:
ax.fill_between(x, y, z, interpolate=True, alpha=0.5, zorder=10, **plot_args)
else:
ax.plot(x, y, '-', alpha=1, zorder=30, **plot_args)
return ax
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)
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
Args:
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']
'''
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?)(\}.*)$')
commentparts = {}
if data.get('comment') is not None:
for part in data.get('comment').split('; '):
relevant = rrelevant.search(part)
if relevant is not None:
ctype = relevant.group(1)
cvalue = relevant.group(2)
''' Convert floating point-style strings (2.0) to integer-style strings (2) '''
try:
if int(float(cvalue)) == float(cvalue):
cvalue = int(float(cvalue))
else:
cvalue = 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:
cvalue += 'L'
ctype = re.sub('Rapid-acting insulin', 'Insulin', ctype, flags=re.IGNORECASE)
ctype = re.sub('Long-acting insulin', 'Insulin', ctype, flags=re.IGNORECASE)
if ctype in commentparts:
commentparts[ctype] = commentparts[ctype] + '/' + cvalue
else:
commentparts[ctype] = cvalue
data['comment'] = commentparts
else:
data['comment'] = {}
''' Convert timestamp to ISO8601 (by default, at least), and store datetime object '''
try:
date = dt.datetime.strptime(data.get('timestamp'), fmt)
data['date'] = date
except ValueError:
raise ValueError('Invalid date: %s (should be of format %s)' % (data.get('timestamp'), fmt))
data['timestamp'] = date.strftime('%Y-%m-%dT%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
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
a weekly average graph. A reading taken every 15 minutes over two days would yield 192 readings.
Returns:
seendays: a dict containing all days in data
seenweeks: a dict containing all weeks in data, subdivided by year
'''
seenweeks = {}
seendays = {}
for d in data:
date = d.get('date')
day = dt.datetime.combine(date.date(), dt.time.min)
(year, week, weekday) = date.isocalendar()
if not year in seenweeks:
seenweeks[year] = {}
if not week in seenweeks[year]:
seenweeks[year][week] = 0
else:
seenweeks[year][week] += 1
if not day in seendays:
seendays[day] = 1
else:
seendays[day] += 1
''' Remove weeks for which there is less than two days of results in that week. '''
''' Note that if we smooth the data to generate a reading every 10 minutes, there will be 144 readings per day '''
editedweeks = dict(seenweeks)
for year in seenweeks:
editedweeks = dict(seenweeks[year])
for week in seenweeks[year]:
if seenweeks[year][week] < trim_weeks:
del editedweeks[week]
seenweeks[year] = dict(editedweeks)
return (seendays, seenweeks)
def calculate_averages(data, args):
''' Return a dictionary with the maximum and mimimum values for each time interval
Args:
data: a dict with the element 'value'
args: a dict with the elements ;
Returns:
g_mean: The mean of all blood glucose 'value' elements
g_median: The median of all blood glucose 'value' elements
a_mean: The blood glucose mean converted to an HbA1c value
a_median: The blood glucose median converted to an HbA1c value
Raises:
ValueError if the blood glucose units can't be parsed or are unknown.
'''
g_mean = round(np.mean([data[k].get('value', 0) for k in data]), 1)
g_median = round(np.median([data[k].get('value', 0) for k in data]), 1)
if args.units == UNIT_MGDL:
a_median = (g_median + 46.7) / 28.7
a_mean = (g_mean + 46.7) / 28.7
elif args.units == UNIT_MMOLL:
a_median = (g_median + 2.59) / 1.59
a_mean = (g_mean + 2.59) / 1.59
else:
raise ValueError('Unknown blood glucose units for HbA1c calculations')
return (g_mean, g_median, a_mean, a_median)
def calculate_max_min(data):
''' Return a dictionary with the maximum and mimimum values for each time interval
Args:
datas: a dict with elements 'timestamp' and 'value'
Returns:
intervals: a dictionary of minimum and maximum values for a a time period
Raises:
ValueError if an incorrectly-formatted date exists in data['timestamp']
'''
intervals = {}
for d in 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')
intervals[time]['max'] = d.get('value')
if intervals[time]['min'] < d.get('value'):
intervals[time]['min'] = d.get('value')
if intervals[time]['max'] > d.get('value'):
intervals[time]['max'] = d.get('value')
return intervals
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
the difference between two consecutive entries
Returns:
filledrows: a dict containing the rows with inserted items.
'''
filledrows = []
for i, row in enumerate(rows):
filledrows.append(row)
''' Don't check the distance between the last value and anything! '''
if i >= len(rows)-1:
continue
''' If the next row has a time gap, create new rows to insert '''
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].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.
Use n+2 so we can remove the first and last value which overlap with existing values '''
periods = np.linspace(start, end, n+2)
periods = periods[1:n+1]
values = np.linspace(lower, upper, n+2)
values = values[1:n+1]
for j, val in enumerate(values):
period = mdates.num2date(periods[j])
period = period.replace(microsecond=0, tzinfo=None)
item = {
'date': period,
'meal': '',
'value': float('%.2f' % val),
'comment': '',
'timestamp': period.strftime('%Y-%m-%dT%H:%M:%S'),
'measure_method': 'Estimate',
}
filledrows.append(item)
return filledrows
def verify_pagesize(pagesize = None):
''' Check the page size '''
if re.search('a4', pagesize, flags=re.IGNORECASE) is not None:
pagesize = (11.69, 8.27)
elif re.search('letter', pagesize, flags=re.IGNORECASE) is not None:
pagesize = (11, 8.5)
elif re.search('\d+(cm|in),\d+/', pagesize, flags=re.IGNORECASE) is not None:
''' Do nothing '''
else: # A4 size default
pagesize = (11.69, 8.27)
return pagesize
def verify_units(units = None, high = None, low = None):
''' Standardise units for output and for the A1c calculations '''
if re.search('mg', units, flags=re.IGNORECASE) is not None:
units = UNIT_MGDL
elif re.search('mm', 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
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)):
units = UNIT_MGDL
else:
units = UNIT_MMOLL
else:
''' Leave empty so we can auto-detect based on input '''
units = ''
return units
def parse_arguments():
parser = argparse.ArgumentParser(description='Convert a CSV file containing blood sugar measurements into graphs')
parser.add_argument(
'--input', '-i', action='store', required=True, type=str, dest='input_file',
help='Select the CSV file exported by glucometerutils.')
parser.add_argument(
'--output', '-o', action='store', type=str, dest='output_file',
help=('Select the path for the output file.'))
parser.add_argument(
'--pagesize', action='store', required=False, type=str, default='',
help=('Page size of output PDF (currently, letter or A4).'))
parser.add_argument(
'--graphs', action='store', required=False, type=int, default=2, dest='graphs_per_page',
help=('Number of graphs to print per page.'))
parser.add_argument(
'--icons', action='store_true', required=False, default=True,
help=('Print food and injection indicators (default: true).'))
parser.add_argument(
'--fingerstick', action='store_true', required=False, default=True,
help=('Include manual finger stick results (default: true).'))
parser.add_argument(
'--units', action='store', required=False, type=str,
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=DEFAULT_LOW,
help=('Minimum of target glucose range.'))
parser.add_argument(
'--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_MMOLL
args.graph_min = GRAPH_MIN_MMOLL
else:
args.graph_max = GRAPH_MAX_MGDL
args.graph_min = GRAPH_MIN_MGDL
''' If the user specified the units but not the high or low targets, set them now '''
if args.high == DEFAULT_HIGH or args.low == DEFAULT_LOW:
args.high = convert_glucose_unit(args.high, UNIT_MMOLL)
args.low = convert_glucose_unit(args.low, 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:
args.graphs_per_page = 2
return args
def from_csv(csv_file, newline=''):
'''Returns the reading as a formatted comma-separated value string.'''
data = csv.reader(csv_file, delimiter=',', quotechar='"')
fields = [ 'timestamp', 'value', 'meal', 'measure_method', 'comment' ]
rows = []
for row in data:
item = dict(zip(fields, row))
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.
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)
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__":
main()
# vim: set expandtab shiftwidth=2 softtabstop=2 tw=0 :