nightscout
Version:
Nightscout acts as a web-based CGM (Continuous Glucose Monitor) to allow multiple caregivers to remotely view a patients glucose data in realtime.
1,158 lines (1,045 loc) • 53.6 kB
JavaScript
'use strict';
//var _ = require('lodash');
var moment = window.moment;
//var times = require('../times');
//var d3 = (global && global.d3) || require('d3');
var loopalyzer = {
name: 'loopalyzer'
, label: 'Loopalyzer'
, pluginType: 'report'
};
function init () {
return loopalyzer;
}
module.exports = init;
var laDebug = false; // If we should print console.logs
var laVersion = '2019-02-02 v6';
var risingInterpolationGap = 6; // How large a gap in COB/IOB graph is allowed to be to be interpolated if end value is larger than start
var fallingInterpolationGap = 24; // And if less than start
var interpolationRatio = 1.25; // But do allow rising interpolation if gap larger than interpolationGap and end value is less than 10% larger than start
loopalyzer.html = function html (client) {
var translate = client.translate;
var ret = '';
ret += '<h2>Loopalyzer <span id="loopalyzer-dateinfo"></span></h2>';
ret += '<span id="loopalyzer-help">' + translate('The primary purpose of Loopalyzer is to visualise how the Loop closed loop system performs. It may work with other setups as well, both closed and open loop, and non loop. However depending on which uploader you use, how frequent it is able to capture your data and upload, and how it is able to backfill missing data some graphs may have gaps or even be completely empty. Always ensure the graphs look reasonable. Best is to view one day at a time and scroll through a number of days first to see.');
ret += '<br/><br/>' + translate('Loopalyzer includes a time shift feature. If you for example have breakfast at 07:00 one day and at 08:00 the day after your average blood glucose curve these two days will most likely look flattened and not show the actual response after a breakfast. Time shift will compute the average time these meals were eaten and then shift all data (carbs, insulin, basal etc.) during both days the corresponding time difference so that both meals align with the average meal start time.');
ret += '<br/>' + translate('In this example all data from first day is pushed 30 minutes forward in time and all data from second day 30 minutes backward in time so it appears as if you had had breakfast at 07:30 both days. This allows you to see your actual average blood glucose response from a meal.');
ret += '<br/></br>' + translate('Time shift highlights the period after the average meal start time in gray, for the duration of the DIA (Duration of Insulin Action). As all data points the entire day are shifted the curves outside the gray area may not be accurate.');
ret += '<br/></br>' + translate('Note that time shift is available only when viewing multiple days.');
ret += '<br/><br/><b>';
ret += translate('To see this report, press SHOW while in this view');
ret += '</b></span>';
ret += '<span id="loopalyzer-notenoughdata" style="display:none;"><b>' + translate('Please select a maximum of two weeks duration and click Show again.') + '</b><br/><br/></span>';
ret += '<div id="loopalyzer-buttons" style="display:none;">';
ret += '<input type="checkbox" id="rp_loopalyzerprofiles">' + translate('Show profiles table') + ' ';
ret += '<input type="checkbox" id="rp_loopalyzerpredictions">' + translate('Show predictions');
ret += '<br/>';
ret += '<span id="rp_loopalyzertimeshiftinput">'; /* So we can show only if viewing multiple days style="display:none;" */
ret += '<input type="checkbox" id="rp_loopalyzertimeshift">';
let numberInput = '<input type="number" style="width: 3.5em" value="10" id="rp_loopalyzermincarbs">';
function genTimePicker(id) {
let timerPicker = ' <select id="' + id + '">';
for (let i = 0; i < 24; i++) {
const H = (i < 10 ? '0' : '') + i;
timerPicker += ' <option t1="' + H + ':00"' + (i == 6 ? ' selected' : '') + '>' + H + ':00</option>';
timerPicker += ' <option t1="' + H + ':30">' + H + ':30</option>';
}
timerPicker += '</select>';
return timerPicker;
}
ret += translate('Timeshift on meals larger than %1 g carbs consumed between %2 and %3', numberInput, genTimePicker('rp_loopalyzert1'), genTimePicker('rp_loopalyzert2'));
ret += '</span>'; /* timeShift */
ret += '<br/><br/>';
ret += '<input type="button" onclick="loopalyzerMoreBackward();" value="<<< ' + translate('Previous') + '">';
ret += '<input type="button" onclick="loopalyzerBackward();" value="< ' + translate('Previous day') + '">';
ret += '<input type="button" onclick="loopalyzerForward();" value="' + translate('Next day') + ' >">';
ret += '<input type="button" onclick="loopalyzerMoreForward();" value="' + translate('Next') + ' >>>">';
ret += '</div>'; /* loopalyzer-button */
ret += '<div id="loopalyzer-charts">';
ret += ' <div class="chart" id="loopalyzer-basal" style="height:100px;margin-bottom:-14px;"></div>';
ret += ' <div class="chart" id="loopalyzer-bg" style="height:200px;margin-bottom:-14px;"></div>';
ret += ' <div class="chart" id="loopalyzer-tempbasal" style="height:150px;margin-bottom:-14px;"></div>';
ret += ' <div class="chart" id="loopalyzer-iob" style="height:150px;margin-bottom:-14px;"></div>';
ret += ' <div class="chart" id="loopalyzer-cob" style="height:150px;"></div>';
ret += '</div>';
ret += '<div id="loopalyzer-profiles">';
ret += '</div>';
return ret;
};
loopalyzer.css =
'#loopalyzer-charts, #loopalyzer-profiles { padding: 20px; } ' +
'#loopalyzer-basal, #loopalyzer-bg, #loopalyzer-tempbasal, #loopalyzer-iob, #loopalyzer-cob, #loopalyzer-profiles {' +
' width: 100%;' +
' height: 100%;' +
'}' +
'#loopalyzer-profiles-table table { margin: 0 10px; border-collapse: collapse; border: 0px; }' +
'#loopalyzer-profiles-table td { vertical-align: top; }' +
'#loopalyzer-profiles-table td table { margin: 0 10px; border-collapse: collapse; border: 0px; }' +
'#loopalyzer-profiles-table td caption { text-align: left; font-weight: bold; }' +
'#loopalyzer-profiles-table td th { background-color: #4CAF50; color: white; }' +
'#loopalyzer-profiles-table td td { text-align: right; vertical-align: top; padding: 0 1px; }' +
'#loopalyzer-profiles-table td td td { padding: 1px 8px; }';
loopalyzer.prepareHtml = function loopalyzerPrepareHtml () {
// $('#loopalyzer-charts').append($('<table><tr><td><div id="loopalyzerchart"></div></td><td><div id="loopalyzerstatchart"></td></tr></table>'));
};
// loopalyzer.ss = require('simple-statistics');
//
// Functions to pull data from datastorage and prepare in bins
//
loopalyzer.getSGVs = function(datastorage, daysToShow) {
var data = datastorage.allstatsrecords;
var bins = loopalyzer.getEmptyBins();
// Loop thru the days to show, for each day find the matching SGVs and insert into the bins entry array
daysToShow.forEach(function(day) {
var entries = []; // Array with all SGVs for this day, we'll fill this and then insert into the bins later
for (let i = 0; i < 288; i++) entries.push(NaN); // Fill the array with NaNs so we have something in case we don't find an SGV
var fromDate = moment(day);
var toDate = moment(day);
fromDate.set({ 'hours': 0, 'minutes': 0, 'seconds': 0, 'milliseconds': 0 });
toDate.set({ 'hours': 0, 'minutes': 5, 'seconds': 0, 'milliseconds': 0 }); // toDate is 5 mins ahead
for (let i = 0; i < 288; i++) {
var found = false;
data.some(function(record) {
var recDate = moment(record.displayTime);
if (!found && recDate.isAfter(fromDate) && recDate.isBefore(toDate)) {
entries[i] = record.sgv;
found = true;
}
return found; // Breaks .some loop if found is true
})
fromDate.add(5, 'minutes');
toDate.add(5, 'minutes');
}
loopalyzer.addArrayToBins(bins, entries);
});
return bins;
}
loopalyzer.getBasals = function(datastorage, daysToShow, profile) {
var bins = loopalyzer.getEmptyBins();
daysToShow.forEach(function(day) {
var dayStart = moment(day).startOf('day');
var dayEnd = moment(day).endOf('day');
var basals = [];
for (var i = 0; i < 288; i++) basals.push(NaN); // Clear the basals by filling with NaNs
var index = 0;
for (var dt = dayStart; dt < dayEnd; dt.add(5, 'minutes')) {
var basal = profile.getTempBasal(dt.toDate());
if (basal)
basals[index++] = basal.basal;
}
if (laDebug) console.log('getBasals ' + day, basals);
loopalyzer.addArrayToBins(bins, basals);
});
return bins;
}
loopalyzer.getTempBasalDeltas = function(datastorage, daysToShow, profile) {
var bins = loopalyzer.getEmptyBins();
daysToShow.forEach(function(day) {
var dayStart = moment(day).startOf('day');
var dayEnd = moment(day).endOf('day');
var temps = [];
for (var i = 0; i < 288; i++) temps.push(NaN); // Clear the basals by filling with NaNs
var index = 0;
for (var dt = dayStart; dt < dayEnd; dt.add(5, 'minutes')) {
var basal = profile.getTempBasal(dt.toDate());
if (basal)
temps[index++] = basal.tempbasal - basal.basal;
}
if (laDebug) console.log('getTempBasalDeltas ' + day, temps);
loopalyzer.addArrayToBins(bins, temps);
});
return bins;
}
loopalyzer.getIOBs = function(datastorage, daysToShow, profile, client, treatments) {
var iobStatusAvailable = client.plugins('iob').isDeviceStatusAvailable(datastorage.devicestatus);
if (laDebug) console.log('getIOBs iobStatusAvailable=' + iobStatusAvailable);
var bins = loopalyzer.getEmptyBins();
daysToShow.forEach(function(day) {
var dayStart = moment(day).startOf('day');
var dayEnd = moment(day).endOf('day');
var iobs = [];
if (iobStatusAvailable) {
// var dayStartMills = dayStart.milliseconds();
for (var i = 0; i < 288; i++) iobs.push(NaN); // Clear the IOBs by filling with NaNs
var iobArray = client.plugins('iob').IOBDeviceStatusesInTimeRange(datastorage.devicestatus, dayStart.valueOf(), dayEnd.valueOf());
if (laDebug) console.log('getIOBs iobArray', iobArray);
iobArray.forEach(function(entry) {
var index = Math.floor(moment(entry.mills).diff(dayStart, 'minutes') / 5);
iobs[index] = entry.iob;
});
if (daysToShow.length === 1) loopalyzer.fillNanWithTreatments(iobs, treatments);
// Loop thru these entries and where no IOB has been found, interpolate between nearby to get a continuous array
var startIndex = 0
, stopIndex = 0;
while (startIndex < iobs.length && isNaN(iobs[startIndex])) {
startIndex++; // Advance start to the first real number
}
if (startIndex < iobs.length) {
stopIndex = startIndex + 1;
while (stopIndex < iobs.length) {
while (stopIndex < iobs.length && isNaN(iobs[stopIndex])) {
stopIndex++; // Advance stop to the first real number after start
}
if (stopIndex < iobs.length) {
// Now we have real numbers at start and stop and NaNs in between
// Compute the y=k*x+m = (y2-y1)/(x2-x1)*x+y1
// Only interpolate on decreasing or steady, or on increasing if the gap is less than interpolationGap
// if (stopIndex-startIndex<interpolationGap && (iobs[stopIndex] <= iobs[startIndex]*interpolationRatio || (stopIndex-startIndex<interpolationGap && iobs[startIndex]!==0))) {
if (loopalyzer.canInterpolate(iobs, startIndex, stopIndex)) {
var k = (iobs[stopIndex] - iobs[startIndex]) / (stopIndex - startIndex);
var m = iobs[startIndex];
for (var x = 0; x < (stopIndex - startIndex); x++) {
iobs[x + startIndex] = k * x + m;
}
}
startIndex = stopIndex;
stopIndex++;
}
}
}
} else {
for (var dt = dayStart; dt < dayEnd; dt.add(5, 'minutes')) {
var iob = client.plugins('iob').calcTotal(datastorage.treatments, datastorage.devicestatus, profile, dt.toDate()).iob;
iobs.push(iob);
}
}
if (laDebug) console.log('getIOBs ' + day, iobs);
loopalyzer.addArrayToBins(bins, iobs);
});
return bins;
}
loopalyzer.getCOBs = function(datastorage, daysToShow, profile, client, treatments) {
var cobStatusAvailable = client.plugins('cob').isDeviceStatusAvailable(datastorage.devicestatus);
if (laDebug) console.log('getCOBs cobStatusAvailable=' + cobStatusAvailable);
var bins = loopalyzer.getEmptyBins();
daysToShow.forEach(function(day) {
var dayStart = moment(day).startOf('day');
var dayEnd = moment(day).endOf('day');
var cobs = [];
if (cobStatusAvailable) {
// var dayStartMills = dayStart.milliseconds();
for (var i = 0; i < 288; i++) cobs.push(NaN); // Clear the COBs by filling with NaNs
var cobArray = client.plugins('cob').COBDeviceStatusesInTimeRange(datastorage.devicestatus, dayStart.valueOf(), dayEnd.valueOf());
if (laDebug) console.log('getCOBs cobArray', cobArray);
cobArray.forEach(function(entry) {
var index = Math.floor(moment(entry.mills).diff(dayStart, 'minutes') / 5);
cobs[index] = entry.cob;
});
if (daysToShow.length === 1) loopalyzer.fillNanWithTreatments(cobs, treatments);
// Loop thru these entries and where no COB has been found, interpolate between nearby to get a continuous array
var startIndex = 0
, stopIndex = 0
, k = 0
, m = 0;
while (startIndex < cobs.length && isNaN(cobs[startIndex])) {
startIndex++; // Advance start to the first real number
}
if (startIndex < cobs.length) {
stopIndex = startIndex + 1;
while (stopIndex < cobs.length) {
while (stopIndex < cobs.length && isNaN(cobs[stopIndex])) {
stopIndex++; // Advance stop to the first real number after start
}
if (stopIndex < cobs.length) {
// Now we have real numbers at start and stop and NaNs in between
// Compute the y=k*x+m = (y2-y1)/(x2-x1)*x+y1
// Only interpolate on decreasing or steady, or on increasing if the gap is less than interpolationGap
// if (stopIndex-startIndex<interpolationGap && (cobs[stopIndex] <= cobs[startIndex]*interpolationRatio || (stopIndex-startIndex<interpolationGap && cobs[startIndex]!==0))) {
if (loopalyzer.canInterpolate(cobs, startIndex, stopIndex)) {
k = (cobs[stopIndex] - cobs[startIndex]) / (stopIndex - startIndex);
m = cobs[startIndex];
for (var x = 0; x < (stopIndex - startIndex); x++) {
cobs[x + startIndex] = k * x + m;
if (cobs[x + startIndex] < 0) {
cobs[x + startIndex] = 0;
}
}
}
startIndex = stopIndex;
stopIndex++;
}
}
}
} else {
for (var dt = dayStart; dt < dayEnd; dt.add(5, 'minutes')) {
var cob = client.plugins('cob').cobTotal(datastorage.treatments, datastorage.devicestatus, profile, dt.toDate()).cob;
cobs.push(cob);
}
}
if (laDebug) console.log('getCOBs ' + day, cobs);
loopalyzer.addArrayToBins(bins, cobs);
});
return bins;
}
/* Fills NaN gaps with treatments if treatments are available and value at start is less than value at stop */
loopalyzer.fillNanWithTreatments = function(array, treatments) {
treatments.forEach(function(treatment) {
var dayStart = moment(treatment.date).startOf('day');
var minutesAfterMidnight = moment(treatment.date).diff(dayStart, 'minutes');
var index = Math.floor(minutesAfterMidnight / 5);
if (isNaN(array[index])) {
var start = index;
var stop = index;
// Now move left and right until we find real numbers, so not NaN
while (start >= 0 && isNaN(array[start])) {
start--;
}
while (stop < array.length && isNaN(array[stop])) {
stop++;
}
// var gap = stop - start;
// if (isNaN(array[start]) || isNaN(array[stop]) || gap > interpolationGap || (gap < interpolationGap && array[start]<array[stop])) {
// if ( isNaN(array[start]) || isNaN(array[stop]) || (array[start] < array[stop] && (gap >= interpolationGap || array[start]==0)) ) {
var interpolate = (isNaN(array[start]) || isNaN(array[stop]) ? true : loopalyzer.canInterpolate(array, start, stop));
if (!interpolate) {
array[index] = treatment.amount;
}
}
})
}
/* Returns true if we can interpolate between this start and end */
loopalyzer.canInterpolate = function(array, start, stop) {
var interpolate = false;
if (array[stop] <= array[start] * interpolationRatio) {
// Falling
if (stop - start < fallingInterpolationGap) interpolate = true;
} else {
// Rising
if (stop - start < risingInterpolationGap && array[start] !== 0) interpolate = true;
}
return interpolate;
}
/* Returns the carbs treatments array as [date, amount] */
loopalyzer.getCarbTreatments = function(datastorage, daysToShow) {
var treatments = []; // Holds the treatments [date, amount]
var startDate = moment(daysToShow[0]);
var endDate = moment(daysToShow[daysToShow.length - 1]).add(1, 'days');
datastorage.treatments.filter(function(treatment) { return treatment.carbs && treatment.carbs > 0 }).forEach(function(treatment) {
if (moment(treatment.created_at).isBetween(startDate, endDate)) {
treatments.push({ date: treatment.created_at, amount: treatment.carbs });
}
})
if (laDebug) console.log('Carb treatments', treatments);
return treatments;
}
/* Returns the insulin treatments array as [date, amount] */
loopalyzer.getInsulinTreatments = function(datastorage, daysToShow) {
var treatments = []; // Holds the treatments [date, amount]
var startDate = moment(daysToShow[0]);
var endDate = moment(daysToShow[daysToShow.length - 1]).add(1, 'days');
datastorage.treatments.filter(function(treatment) { return treatment.insulin && treatment.insulin > 0 }).forEach(function(treatment) {
if (moment(treatment.created_at).isBetween(startDate, endDate)) {
treatments.push({ date: treatment.created_at, amount: treatment.insulin });
}
})
if (laDebug) console.log('Insulin treatments', treatments);
return treatments;
}
// PREDICTIONS START
//
loopalyzer.getAllTreatmentTimestampsForADay = function(datastorage, day) {
var timestamps = [];
var dayStart = moment(day).startOf('day');
var carbTreatments = loopalyzer.getCarbTreatments(datastorage, [day]);
var insulinTreatments = loopalyzer.getInsulinTreatments(datastorage, [day]);
carbTreatments.forEach(function(entry) { timestamps.push(entry.date) });
insulinTreatments.forEach(function(entry) { timestamps.push(entry.date) });
timestamps.sort(function(a, b) { return (a < b ? -1 : 1) });
timestamps.splice(0, 0, dayStart.toDate()); // Insert a fake timestamp at midnight so we can show predictions during night
return timestamps;
}
loopalyzer.getAllPredictionsForADay = function(datastorage, day) {
var predictions = [];
var dayStart = moment(day).startOf('day');
for (var i = datastorage.devicestatus.length - 1; i >= 0; i--) {
if (datastorage.devicestatus[i].loop && datastorage.devicestatus[i].loop.predicted) {
var predicted = datastorage.devicestatus[i].loop.predicted;
if (moment(predicted.startDate).isSame(dayStart, 'day'))
predictions.push(datastorage.devicestatus[i].loop.predicted);
} else if (datastorage.devicestatus[i].openaps && datastorage.devicestatus[i].openaps.suggested && datastorage.devicestatus[i].openaps.suggested.predBGs) {
var entry = {};
entry.startDate = datastorage.devicestatus[i].openaps.suggested.timestamp;
// For OpenAPS/AndroidAPS we fall back from COB if present, to UAM, then IOB
if (datastorage.devicestatus[i].openaps.suggested.predBGs.COB) {
entry.values = datastorage.devicestatus[i].openaps.suggested.predBGs.COB;
} else if (datastorage.devicestatus[i].openaps.suggested.predBGs.UAM) {
entry.values = datastorage.devicestatus[i].openaps.suggested.predBGs.UAM;
} else entry.values = datastorage.devicestatus[i].openaps.suggested.predBGs.IOB;
predictions.push(entry);
}
}
// Remove duplicates before we're done
var p = [];
predictions.forEach(function(prediction) {
if (p.length === 0 || prediction.startDate !== p[p.length - 1].startDate)
p.push(prediction);
})
return p;
}
/* Find the earliest new predicted instance that has a timestamp equal to or larger than timestamp */
/* (so if we have bolused or eaten we want to find the prediction that Loop has estimated just after that) */
/* Returns the index into the predictions array that is the predicted we are looking for */
loopalyzer.findPredicted = function(predictions, timestamp, offset) {
var ts = moment(timestamp).add(offset, 'minutes');
var predicted = null;
if (offset && offset < 0) { // If offset is negative, start searching from first prediction going forward
for (let i = 0; i < predictions.length; i++) {
if (predictions[i] && predictions[i].startDate && moment(predictions[i].startDate) <= ts) {
predicted = i;
}
}
} else { // If offset is positive or zero, start searching from last prediction going backward
for (let i = predictions.length - 1; i > 0; i--) {
if (predictions[i] && predictions[i].startDate && moment(predictions[i].startDate) >= ts) {
predicted = i;
}
}
}
return predicted;
}
loopalyzer.getPredictions = function(datastorage, daysToShow, client) {
if (!datastorage.devicestatus)
return [];
var predictedOffset = 0;
var truncatePredictions = true;
// Fill the bins array with the timestamp, one per 5 minutes
var bins = [];
var date = moment();
date.set({ 'hours': 0, 'minutes': 0, 'seconds': 0, 'milliseconds': 0 });
for (var i = 0; i < 288; i++) {
bins.push([date.toDate(), []]);
date.add(5, 'minutes');
}
daysToShow.forEach(function(day) {
var p = []; // Array with all prediction SGVs for this day, we'll fill this and then insert into the bins later
for (var i = 0; i < 288; i++) p.push(NaN);
var treatmentTimestamps = loopalyzer.getAllTreatmentTimestampsForADay(datastorage, day);
var predictions = loopalyzer.getAllPredictionsForADay(datastorage, day);
if (predictions.length > 0 && treatmentTimestamps.length > 0) {
// Iterate over all treatments, find the predictions for each and add them to the entries array p, aligned on timestamp
for (var treatmentsIndex = 0; treatmentsIndex < treatmentTimestamps.length; treatmentsIndex++) {
var timestamp = treatmentTimestamps[treatmentsIndex];
var predictedIndex = loopalyzer.findPredicted(predictions, timestamp, predictedOffset); // Find predictions offset before or after timestamp
if (predictedIndex != null) {
var entry = predictions[predictedIndex]; // Start entry
var d = moment(entry.startDate);
var end = moment(day).endOf('day'); // Default to stop and end of the day
if (truncatePredictions) {
if (predictedOffset >= 0) {
// But if we are looking forward we want to stop at the next treatment
if (treatmentsIndex < treatmentTimestamps.length - 1) {
end = moment(treatmentTimestamps[treatmentsIndex + 1]);
}
} else {
// And if we are looking backward then we want to stop at "this" treatment
end = moment(treatmentTimestamps[treatmentsIndex]);
}
}
for (var entryIndex in entry.values) {
if (!d.isAfter(end)) {
var dayStart = moment(d).startOf('day');
var minutesAfterMidnight = moment(d).diff(dayStart, 'minutes');
var index = Math.floor(minutesAfterMidnight / 5);
p[index] = client.utils.scaleMgdl(entry.values[entryIndex]);
d.add(5, 'minutes');
}
}
}
}
}
for (let i = 0; i < 288; i++) {
bins[i][1].push(p[i]);
}
})
return bins;
}
//
// PREDICTIONS ENDS
// VARIOUS UTILITY FUNCTIONS //
/* Create an empty bins array with date stamps for today */
loopalyzer.getEmptyBins = function() {
var bins = [];
var todayStart = moment().startOf('day');
var todayEnd = moment().endOf('day');
for (var dt = todayStart; dt < todayEnd; dt.add(5, 'minutes')) {
bins.push([dt.toDate(), []]);
}
return bins;
}
/* Takes an array of 288 values and adds to the bins */
loopalyzer.addArrayToBins = function(bins, values) {
if (bins && bins.length === 288 && values && values.length === 288) {
values.forEach(function(value, index) {
bins[index][1].push(value);
});
} else
console.log('addArrayToBins - array must have 288 items', values);
}
/* Fill all NaNs in an array by interpolating between adjacent values */
loopalyzer.interpolateArray = function(values, allowNegative) {
var startIndex = 0
, stopIndex = 0
, k = 0
, m = 0;
while (isNaN(values[startIndex])) {
startIndex++; // Advance start to the first real number
}
stopIndex = startIndex + 1;
while (stopIndex < values.length) {
while (stopIndex < values.length && isNaN(values[stopIndex])) {
stopIndex++; // Advance stop to the first real number after start
}
if (stopIndex < values.length) {
// Now we have real numbers at start and stop and NaNs in between
// Compute the y=k*x+m = (y2-y1)/(x2-x1)*x+y1
// Only interpolate if decreasing or steady, newer on increasing
if (values[stopIndex] <= values[startIndex]) {
k = (values[stopIndex] - values[startIndex]) / (stopIndex - startIndex);
m = values[startIndex];
}
for (var x = 0; x < (stopIndex - startIndex); x++) {
values[x + startIndex] = k * x + m;
if (!allowNegative && values[x + startIndex] < 0) {
values[x + startIndex] = 0;
}
}
startIndex = stopIndex;
stopIndex++;
}
}
}
/* Compute min value in bins */
loopalyzer.min = function(xBins) {
var min = xBins[0][1];
for (var i = 0; i < xBins.length; i++) {
if (isNaN(min) || min === null) min = xBins[i][1];
if (!isNaN(xBins[i][1]) && xBins[i][1] < min) min = xBins[i][1];
}
return min;
}
/* Compute max value in bins */
loopalyzer.max = function(xBins) {
var max = xBins[0][1];
for (var i = 0; i < xBins.length; i++) {
if (isNaN(max) || max === null) max = xBins[i][1];
if (!isNaN(xBins[i][1]) && xBins[i][1] > max) max = xBins[i][1];
}
return max;
}
/* Compute avg value in bins */
loopalyzer.avg = function(xBins) {
var out = [];
xBins.forEach(function(entry) {
var sum = 0;
var count = 0;
entry[1].forEach(function(value) {
if (value && !isNaN(value)) {
sum += value;
count++;
}
})
var avg = sum / count;
out.push([entry[0], avg]);
})
return out;
}
// Timeshifts a bins array with subarrays for multiple days
loopalyzer.timeShiftBins = function(bins, timeShift) {
if (bins && bins.length > 0) {
timeShift.forEach(function(minutes, dayIndex) {
if (minutes !== 0) {
var tempBin = [];
bins.forEach(function() {
tempBin.push(NaN); // Fill tempBin with NaNs
})
var minutesBy5 = Math.floor(minutes / 5);
if (minutesBy5 > 0) {
let count = 288 - minutesBy5;
// If minutes>0 it means we should shift forward in time
// Example: Shift by 15 mins = 3 buckets
// bin : 0 1 2 3 4 5 6 7 8 9 10
// tempBin: NaN NaN NaN 0 1 2 3 4 5 6 7
for (let i = 0; i < count; i++) {
tempBin[i + minutesBy5] = bins[i][1][dayIndex];
}
}
if (minutesBy5 < 0) {
let count = 288 + minutesBy5;
// If minutes<0 it means we should shift backward in time
// Example: Shift by 15 mins = 3 buckets
// bin : 0 1 2 3 4 5 6 7 8 9 10
// tempBin: 3 4 5 6 7 8 9 10 NaN NaN NaN
for (var i = 0; i < count; i++) {
tempBin[i] = bins[i - minutesBy5][1][dayIndex];
}
}
// Put the shifted data back into original bins variable (pass by pointer)
for (let i = 0; i < 288; i++) {
bins[i][1][dayIndex] = tempBin[i];
}
}
});
}
}
// Modifies the timestamp in the bin by timeShift minutes, for each day
loopalyzer.timeShiftSingleBin = function(bin, daysToShow, timeShift) {
if (bin && bin.length > 0) {
daysToShow.forEach(function(day, dayIndex) {
var minutesToAdd = timeShift[dayIndex];
var date = moment(day);
bin.forEach(function(entry, entryIndex) {
var entryDate = moment(entry.date);
if (entryDate.isSame(date, 'day')) {
entryDate.add(minutesToAdd, 'minutes');
bin[entryIndex].date = entryDate.toDate();
}
})
})
}
}
/* Returns true if the profile values in a is identical to values in b, false otherwise */
loopalyzer.isSameProfileValues = function(a, b) {
// Because the order of the keys are random when stringifying we do our own custom stringify ourselves
var aString = '';
var bString = '';
if (a.basal) {
aString += 'basal:';
a.basal.forEach(function(entry) {
aString += 's' + entry.timeAsSeconds + 't' + entry.time + 'v' + entry.value;
})
}
if (a.carbratio) {
aString += 'carbratio:';
a.carbratio.forEach(function(entry) {
aString += 's' + entry.timeAsSeconds + 't' + entry.time + 'v' + entry.value;
})
}
if (a.sens) {
aString += 'sens:';
a.sens.forEach(function(entry) {
aString += 's' + entry.timeAsSeconds + 't' + entry.time + 'v' + entry.value;
})
}
if (b.basal) {
bString += 'basal:';
b.basal.forEach(function(entry) {
bString += 's' + entry.timeAsSeconds + 't' + entry.time + 'v' + entry.value;
})
}
if (b.carbratio) {
bString += 'carbratio:';
b.carbratio.forEach(function(entry) {
bString += 's' + entry.timeAsSeconds + 't' + entry.time + 'v' + entry.value;
})
}
if (b.sens) {
bString += 'sens:';
b.sens.forEach(function(entry) {
bString += 's' + entry.timeAsSeconds + 't' + entry.time + 'v' + entry.value;
})
}
return (aString == bString);
}
loopalyzer.renderProfilesTable = function(datastoreProfiles, daysToShow, client) {
// Loop thru the daysToShow and get the timestamp of the first day displayed
var beginningOfFirstDay = null;
var endOfLastDay = null;
daysToShow.forEach(function(day) {
var dayStart = moment(day).startOf('day');
var dayEnd = moment(day).endOf('day');
if (!beginningOfFirstDay || dayStart < beginningOfFirstDay)
beginningOfFirstDay = dayStart;
if (!endOfLastDay || dayEnd > endOfLastDay)
endOfLastDay = dayEnd;
});
// Now some profile juggling... We want to display only the profiles relevant to the days we are showing.
// This includes the last profile created before the first display date, and the profiles created on the display dates.
// However we don't want to show duplicate profiles and we also don't want to show more than just a few if there are many.
// First, extract the profiles that have a startDate less than the endOfLastDay as only these are relevant, and sort
// these on ascending startDate (create a clone array so we don't modify the Store array). And only save the profiles
// that have basal, carbratio, or sens.
var profilesArray1 = [];
datastoreProfiles.forEach(function(entry) {
var newEntry = {};
newEntry.startDate = entry.startDate;
var store = entry.store;
if (store) {
for (var key in store) {
if (laDebug) console.log('profile ' + key);
if (Object.prototype.hasOwnProperty.call(store, key)) {
var defaultProfile = store[key];
newEntry.profileName = key;
if (defaultProfile.basal) newEntry.basal = defaultProfile.basal;
if (defaultProfile.carbratio) newEntry.carbratio = defaultProfile.carbratio;
if (defaultProfile.sens) newEntry.sens = defaultProfile.sens;
if ((newEntry.basal || newEntry.carbratio || newEntry.sens) && moment(entry.startDate).isBefore(endOfLastDay))
profilesArray1.push(newEntry);
}
}
}
})
profilesArray1.sort(function(a, b) { return (a.startDate > b.startDate ? 1 : -1) }); // Ascending
if (laDebug) {
profilesArray1.forEach(function(entry) {
console.log('profilesArray1 - ' + entry.startDate);
})
}
if (laDebug) console.log('profilesArray1 has ' + profilesArray1.length + ' profiles');
// Second, the deduplication - remove all duplicates which have a later startDate but identical data
var profilesArray2 = [];
var profileToCompareWith = profilesArray1[0];
profilesArray2.push(profileToCompareWith); // Push the first profile, which should always be included.
profilesArray1.forEach(function(entry) {
if (laDebug) {
console.log('Comparing ' + JSON.stringify(profileToCompareWith.startDate) + ' to ' + JSON.stringify(entry.startDate));
console.log(profileToCompareWith, entry);
}
if (!loopalyzer.isSameProfileValues(profileToCompareWith, entry)) {
profilesArray2.push(entry);
profileToCompareWith = entry;
if (laDebug)
console.log('ADDING IT');
} else {
// Do NOT push the entry to profilesArray2, and keep comparing with the same (olders unique) profile
if (laDebug)
console.log('SKIPPING IT');
}
})
if (laDebug) console.log('profilesArray2 has ' + profilesArray2.length + ' profiles');
// Sort the newest Profile first
profilesArray2.sort(function(a, b) { return (a.startDate > b.startDate ? 1 : -1) }); // Ascending
// Third, find the latest profile with a startDate before beginningOfFirstDay
var latestProfile = profilesArray2[0]; // This is the oldest one
profilesArray2.forEach(function(entry) {
if (laDebug)
console.log(entry.startDate + ' isBefore ' + beginningOfFirstDay + ' = ' + moment(entry.startDate).isBefore(beginningOfFirstDay));
if (moment(entry.startDate).isBefore(beginningOfFirstDay))
latestProfile = entry;
});
if (laDebug) console.log('latest profile is ' + latestProfile.startDate);
// Now create a final array with the latest profile found above and add all
// the other profiles with a startDate between beginningOfFirstDay and endOfLastDay
var profiles = [];
profiles.push(latestProfile); // Add the latest one
profilesArray2.forEach(function(entry) {
if (laDebug)
console.log(entry.startDate + ' isAfter ' + beginningOfFirstDay + ' = ' + moment(entry.startDate).isAfter(beginningOfFirstDay));
if (moment(entry.startDate).isAfter(beginningOfFirstDay))
profiles.push(entry); // Add the profile if it's between beginning and end of show dates
});
// Now we have an array of all the profiles that are relevant for the days we are displaying.
if (laDebug) {
profiles.forEach(function(entry) {
console.log('profiles - ' + entry.startDate);
})
}
if (laDebug) console.log('profiles has ' + profiles.length + ' profiles');
var translate = client.translate;
var tableHtml = '<table id="loopalyzer-profiles-table"><tbody><tr>';
profiles.forEach(function(theProfile, index) {
if (index < 3) {
tableHtml += '<td><table>';
tableHtml += '<caption>' + theProfile.profileName + ' (' + new Date(theProfile.startDate).toLocaleString() + ')</caption>';
tableHtml += '<thead><tr><th>' + translate('Basal') + '</th><th>' + translate('Carb ratio') + '</th><th>' + translate('Sensitivity') + '</th></tr></thead>';
tableHtml += '<tbody><tr>';
// Add Basal as a table in the first td
tableHtml += '<td><table>';
if (theProfile.basal) {
theProfile.basal.forEach(function(entry) {
tableHtml += '<tr><td>' + entry.time + '</td><td>' + parseFloat(entry.value).toFixed(3) + '</td></tr>'
});
}
tableHtml += '</table></td>';
// Add Carb Ratio as a table in the second td
tableHtml += '<td><table>';
if (theProfile.carbratio) {
theProfile.carbratio.forEach(function(entry) {
tableHtml += '<tr><td>' + entry.time + '</td><td>' + parseFloat(entry.value).toFixed(1) + '</td></tr>'
});
}
tableHtml += '</table></td>';
// Add Sensitivity as a table in the third td
tableHtml += '<td><table>';
if (theProfile.sens) {
theProfile.sens.forEach(function(entry) {
tableHtml += '<tr><td>' + entry.time + '</td><td>' + parseFloat(entry.value).toFixed(1) + '</td></tr>'
});
}
tableHtml += '</table></td>';
// Close theProfile table
tableHtml += '</tr></tbody></table></td>';
} else
if (index == 3) {
// Add ellipsis if too many profiles to display, but only one ellipsis even if there are more profiles
tableHtml += '<td><table><caption>.....</caption></table></td>';
}
});
// Close the entire table
tableHtml += '</tr></tbody></table>';
// And add our HTML to the view
$("#loopalyzer-profiles").html(tableHtml);
};
// Main method
loopalyzer.report = function(datastorage, sorteddaystoshow, options) {
if (laDebug) console.log('Loopalyzer ' + laVersion);
// Copy the sorteddaystoshow into new array (clone) and re-sort ascending (so we don't mess with original array)
var daysToShow = [];
sorteddaystoshow.forEach(function(day) { daysToShow.push(day) });
daysToShow.sort(function(a, b) { return (a < b ? -1 : 1) }); // We always want them chronological order
var firstDay = moment(daysToShow[0]);
var lastDay = moment(daysToShow[daysToShow.length - 1]);
var days = lastDay.diff(firstDay, 'day') + 1;
if (laDebug) console.log('Loopalyzer ' + firstDay.format() + ' - ' + lastDay.format() + ' is ' + days + ' days');
if (days <= 14) {
$("#loopalyzer-notenoughdata").hide();
$("#loopalyzer-dateinfo").show();
$("#loopalyzer-buttons").show();
$("#loopalyzer-charts").show();
$("#loopalyzer-profiles-table").show();
$("#loopalyzer-help").hide();
loopalyzer.generateReport(datastorage, daysToShow, options);
} else {
$("#loopalyzer-notenoughdata").show();
$("#loopalyzer-dateinfo").hide();
$("#loopalyzer-buttons").hide();
$("#loopalyzer-charts").hide();
$("#loopalyzer-profiles-table").hide();
$("#loopalyzer-help").hide();
}
}
loopalyzer.generateReport = function(datastorage, daysToShow, options) {
var Nightscout = window.Nightscout;
var client = Nightscout.client;
var translate = client.translate;
var profile = client.sbx.data.profile;
// var report_plugins = Nightscout.report_plugins;
// var scaledTreatmentBG = report_plugins.utils.scaledTreatmentBG;
var today = new Date();
var todayJSON = { 'year': today.getFullYear(), 'month': today.getMonth(), 'date': today.getDate() };
var dateInfo = moment(daysToShow[0]).format('ddd MMM D'); // .split(',')[0];
if (daysToShow.length > 1) dateInfo += ' - ' + moment(daysToShow[daysToShow.length - 1]).format('ddd MMM D'); // .split(',')[0];
$("#loopalyzer-dateinfo").html(dateInfo);
loopalyzer.prepareHtml();
$("#loopalyzer-buttons").show();
if (daysToShow.length == 1) {
// Disable and gray out timeShift if only a single day
$("#rp_loopalyzertimeshift").prop('checked', false);
$("#rp_loopalyzertimeshift").attr("disabled", true);
$("#rp_loopalyzermincarbs").attr("disabled", true);
$("#rp_loopalyzert1").attr("disabled", true);
$("#rp_loopalyzert2").attr("disabled", true);
$("#rp_loopalyzertimeshiftinput").css('color', 'gray');
} else {
// Enable and turn the timeShift black if multiple days
$("#rp_loopalyzertimeshift").removeAttr("disabled");
$("#rp_loopalyzermincarbs").removeAttr("disabled");
$("#rp_loopalyzert1").removeAttr("disabled");
$("#rp_loopalyzert2").removeAttr("disabled");
$("#rp_loopalyzertimeshiftinput").css('color', 'black');
}
// Check if there is data in the profiles and render the profiles table if there is
if ($("#rp_loopalyzerprofiles").is(":checked") && (datastorage.profiles && datastorage.profiles.length > 0)) {
$("#loopalyzer-profiles-table").show();
loopalyzer.renderProfilesTable(datastorage.profiles, daysToShow, client);
} else
$("#loopalyzer-profiles-table").hide();
// Pull all necessary treatment information
profile.updateTreatments(datastorage.profileSwitchTreatments, datastorage.tempbasalTreatments, datastorage.combobolusTreatments);
var carbTreatments = loopalyzer.getCarbTreatments(datastorage, daysToShow);
var insulinTreatments = loopalyzer.getInsulinTreatments(datastorage, daysToShow);
var sgvBin = loopalyzer.getSGVs(datastorage, daysToShow);
var basalsBin = loopalyzer.getBasals(datastorage, daysToShow, profile);
var tempBasalsBin = loopalyzer.getTempBasalDeltas(datastorage, daysToShow, profile);
var iobBin = loopalyzer.getIOBs(datastorage, daysToShow, profile, client, insulinTreatments);
var cobBin = loopalyzer.getCOBs(datastorage, daysToShow, profile, client, carbTreatments);
var predictionsBin = [];
if ($("#rp_loopalyzerpredictions").is(":checked")) {
predictionsBin = loopalyzer.getPredictions(datastorage, daysToShow, client);
}
// Prepare an array with the minutes to timeShift each day (0 as default since timeShift is off by default)
var timeShifts = [];
var firstCarbs = [];
var timeShiftStartTime = null; // If timeShifting this is the average time the meals were eaten
var timeShiftStopTime = null; // and this is the start + DIA according to profile
var doTimeShift = false;
daysToShow.forEach(function() { timeShifts.push(0);
firstCarbs.push(NaN) });
// Check to see if we are doing timeShift or not
if ($("#rp_loopalyzertimeshift").is(":checked") && daysToShow.length > 1) {
var mealMinCarbs = $("#rp_loopalyzermincarbs").val();
var t1 = $("#rp_loopalyzert1").val();
var t2 = $("#rp_loopalyzert2").val();
if (t2 > t1) {
var h1 = t1.split(':')[0];
var m1 = t1.split(':')[1];
var h2 = t2.split(':')[0];
var m2 = t2.split(':')[1];
var timeShiftBegin = moment();
timeShiftBegin.set({ 'hours': h1, 'minutes': m1, 'seconds': 0 });
var timeShiftEnd = moment();
timeShiftEnd.set({ 'hours': h2, 'minutes': m2, 'seconds': 0 });
//Loop through the carb treatments and find the first meal each day
daysToShow.forEach(function(day, dayIndex) {
var timeShiftBegin = moment(day);
var timeShiftEnd = moment(day);
timeShiftBegin.set({ 'hours': h1, 'minutes': m1, 'seconds': 0 });
timeShiftEnd.set({ 'hours': h2, 'minutes': m2, 'seconds': 0 });
var found = false;
carbTreatments.forEach(function(entry) {
if (!found && entry.amount >= mealMinCarbs) {
var date = moment(entry.date);
if ((date.isSame(timeShiftBegin, 'minute') || date.isAfter(timeShiftBegin, 'minute')) &&
(date.isSame(timeShiftEnd, 'minute') || date.isBefore(timeShiftEnd, 'minute'))) {
var startOfDay = moment(entry.date);
startOfDay.set({ 'hours': 0, 'minutes': 0, 'seconds': 0 });
var minutesAfterMidnight = date.diff(startOfDay, 'minutes');
firstCarbs[dayIndex] = minutesAfterMidnight;
found = true;
doTimeShift = true;
}
}
})
})
// Calculate the average starting time, in minutes after midnight
var sum = 0
, count = 0;
firstCarbs.forEach(function(minutesAfterMidnight) {
if (minutesAfterMidnight) { // Avoid NaN
sum += minutesAfterMidnight;
count++;
}
});
var averageMinutesAfterMidnight = Math.round(sum / count);
var dia = profile.getDIA();
if (!dia || dia <= 0)
dia = 6; // Default to 6h if DIA not set in profile
timeShiftStartTime = moment(todayJSON);
timeShiftStartTime.minutes(averageMinutesAfterMidnight);
timeShiftStopTime = moment(todayJSON);
if (averageMinutesAfterMidnight + dia * 60 < 24 * 60)
timeShiftStopTime.minutes(averageMinutesAfterMidnight + dia * 60); // If not beyond midnight, stop at end of DIA
else
timeShiftStopTime.minutes(24 * 60 - 1); // If beyond midnight, stop at midnight
// Compute the timeShift (+ / -) that we should add to each entry (sgv, iob, carbs, etc) for each day
firstCarbs.forEach(function(minutesAfterMidnight, index) {
if (minutesAfterMidnight) { // Avoid NaN
var delta = Math.round(averageMinutesAfterMidnight - minutesAfterMidnight);
timeShifts[index] = delta;
}
});
if (doTimeShift) {
loopalyzer.timeShiftBins(sgvBin, timeShifts);
loopalyzer.timeShiftBins(basalsBin, timeShifts);
loopalyzer.timeShiftBins(tempBasalsBin, timeShifts);
loopalyzer.timeShiftBins(iobBin, timeShifts);
loopalyzer.timeShiftBins(cobBin, timeShifts);
loopalyzer.timeShiftBins(predictionsBin, timeShifts);
loopalyzer.timeShiftSingleBin(carbTreatments, daysToShow, timeShifts);
loopalyzer.timeShiftSingleBin(insulinTreatments, daysToShow, timeShifts);
}
} else {
console.log('Loopalyzer - Timeshift end must be later than beginning.');
}
}
// After timeShift code block, get the average values
var sgvAvg = loopalyzer.avg(sgvBin);
var basalsAvg = loopalyzer.avg(basalsBin);
var tempBasalsAvg = loopalyzer.avg(tempBasalsBin);
var iobAvg = loopalyzer.avg(iobBin);
var cobAvg = loopalyzer.avg(cobBin);
var predictionsAvg = loopalyzer.avg(predictionsBin);
var high = options.targetHigh;
var low = options.targetLow;
// Set up the charts basics
function tickFormatter (val, axis) {
if (val <= axis.min) { return ''; }
if (val >= axis.max) { return ''; }
return val + '';
}
var tickColor = '#DDDDDD';
var basalColor = '#33A0FF';
var glucoseColor = '#33AA33';
var predictionsColor = '#8E1578';
var glucoseRangeColor = '#D6FFD6';
var insulinColor = '#FF7000';
var carbColor = '#23D820';
var timeShiftBackgroundColor = "#F3F3F3";
var barWidth = (24 * 60 * 60 * 1000 / 288);
var borderWidth = 1;
var labelWidth = 25;
var xaxisCfg = {
mode: 'time'
, timezone: 'browser'
, timeformat: '%H:%M'
, tickColor: tickColor
, tickSize: [1, "hour"]
, font: { size: 0 }
};
var hiddenAxis = {
position: "right"
, show: true
, labelWidth: 10
, tickColor: "#FFFFFF"
, font: { size: 0 }
}
// For drawing the carbs and insulin treatments
var markings = [];
var markingColor = "#000000";
// Chart 1: Basal
markings = [];
if (doTimeShift)
markings.push({ xaxis: { from: timeShiftStartTime.toDate(), to: timeShiftStopTime.toDate() }, color: timeShiftBackgroundColor });
var chartBasalData = [{
data: basalsAvg
, label: translate('Basal profile')
, id: 'basals'
, color: basalColor
, points: { show: false }
, bars: { show: true, fill: true, barWidth: barWidth }
, yaxis: 1
}];
var chartBasalOptions = {
xaxis: xaxisCfg
, yaxes: [{
tickColor: tickColor
, labelWidth: labelWidth
, tickFormatter: function(val, axis) { return tickFormatter(val, axis); }
}
, hiddenAxis]
, grid: {
borderWidth: borderWidth
, markings: markings
}
};
$.plot('#loopalyzer-basal', chartBasalData, chartBasalOptions);
// Chart 2: Blood glucose
markings = [];
if (doTimeShift)
markings.push({ xaxis: { from: timeShiftStartTime.toDate(), to: timeShiftStopTime.toDate() }, color: timeShiftBackgroundColor });
markings.push({ yaxis: { from: low, to: high }, color: glucoseRangeColor });
var chartBGData = [{
label: translate('Blood glucose')
, data: sgvAvg
, id: 'glucose'
, color: glucoseColor
, points: { show: false }
, lines: { show: true }
}];
if (predictionsAvg && predictionsAvg.length > 0) {
chartBGData.push({
label: translate('Predictions')
, data: predictionsAvg
, id: 'predictions'
, color: predictionsColor
, points: { show: true, fill: true, radius: 0.75, fillColor: predictionsColor }
, lines: { show: false }
});
}
var chartBGOptions = {
xaxis: xaxisCfg
, yaxes: [{
min: 0
, max: options.units === 'mmol' ? 20 : 400
, tickColor: tickColor
, labelWidth: labelWidth
, tickFormatter: function(val, axis) { return tickFormatter(val, a