def insulin_on_board_calc(type_, start_date, end_date, value, scheduled_basal_rate, date, model, delay, delta): """ Calculates the insulin on board for a specific dose at a specific time Arguments: type_ -- String with type of dose (bolus, basal, etc) start_date -- the date the dose started at (datetime object) end_date -- the date the dose ended at (datetime object) value -- insulin value for dose scheduled_basal_rate -- basal rate scheduled during the times of dose (0 for a bolus) date -- date the IOB is being calculated (datetime object) model -- list of insulin model parameters in format [DIA, peak_time] delay -- the time to delay the dose effect delta -- the differential between timeline entries Output: IOB at date """ time = time_interval_since(date, start_date) if start_date > end_date or time < 0: return 0 if len(model) == 1: # walsh model if time_interval_since(end_date, start_date) <= 1.05 * delta * 60: return net_basal_units( type_, value, start_date, end_date, scheduled_basal_rate) * walsh_percent_effect_remaining( (time / 60 - delay), model[0]) # This will normally be for basals return net_basal_units( type_, value, start_date, end_date, scheduled_basal_rate) * continuous_delivery_insulin_on_board( start_date, end_date, date, model, delay, delta) # Consider doses within the delta time window as momentary # This will normally be for boluses or short temp basals if time_interval_since(end_date, start_date) <= 1.05 * delta * 60: return net_basal_units( type_, value, start_date, end_date, scheduled_basal_rate) * percent_effect_remaining( (time / 60 - delay), model[0], model[1]) # This will normally be for basals return net_basal_units( type_, value, start_date, end_date, scheduled_basal_rate) * continuous_delivery_insulin_on_board( start_date, end_date, date, model, delay, delta)
def glucose_effect(dose_type, dose_start_date, dose_end_date, dose_value, scheduled_basal_rate, date, model, insulin_sensitivity, delay, delta): """ Calculates the timeline of glucose effects for a specific dose Arguments: dose_type -- types of dose (basal, bolus, etc) dose_start_date -- datetime object representing date doses start at dose_end_date -- datetime object representing date dose ended at dose_value -- insulin value for dose scheduled_basal_rate -- basal rate scheduled during the time of dose date -- datetime object of time to calculate the effect at insulin_sensitivity -- sensitivity (mg/dL/U) delay -- the time to delay the dose effect delta -- the differential between timeline entries Output: Glucose effect (mg/dL) """ time = time_interval_since(date, dose_start_date) delay *= 60 delta *= 60 if time < 0: return 0 # Consider doses within the delta time window as momentary # This will normally be for boluses if time_interval_since(dose_end_date, dose_start_date) <= 1.05 * delta: # pylint: disable=C0330 if len(model) == 1: # walsh model return net_basal_units( dose_type, dose_value, dose_start_date, dose_end_date, scheduled_basal_rate) * -insulin_sensitivity * ( 1 - walsh_percent_effect_remaining( (time - delay) / 60, model[0])) return net_basal_units( dose_type, dose_value, dose_start_date, dose_end_date, scheduled_basal_rate) * -insulin_sensitivity * ( 1 - percent_effect_remaining( (time - delay) / 60, model[0], model[1])) # This will normally be for basals, and handles Walsh model automatically return net_basal_units( dose_type, dose_value, dose_start_date, dose_end_date, scheduled_basal_rate ) * -insulin_sensitivity * continuous_delivery_glucose_effect( dose_start_date, dose_end_date, date, model, delay / 60, delta / 60)
def carbs_on_board_helper(carb_start, carb_value, at_time, default_absorption_time, delay, carb_absorption_time=None): """ Find partial COB for a particular carb entry Arguments: carb_start -- time of carb entry (datetime objects) carb_value -- grams of carbs eaten at_date -- date to calculate the glucose effect (datetime object) default_absorption_time -- absorption time to use for unspecified carb entries delay -- the time to delay the carb effect carb_absorption_time -- time carbs will take to absorb (mins) Output: Carbohydrate value (g) """ time = time_interval_since(at_time, carb_start) delay *= 60 if time >= 0: value = (carb_value * (1 - parabolic_percent_absorption_at_time( (time - delay) / 60, carb_absorption_time or default_absorption_time))) else: value = 0 return value
def absorption_result(builder_index): # absorption list structure: [observed grams absorbed, clamped grams, # total carbs in entry, remaining carbs, observed absorption start, # observed absorption end, estimated time remaining] observed_grams = (observed_effects[builder_index] / builder_carb_sensitivities[builder_index]) entry_grams = carb_entry_quantities[builder_index] time = (time_interval_since(last_effect_dates[builder_index], carb_entry_starts[builder_index]) / 60 - delay) min_predicted_grams = linearly_absorbed_carbs( entry_grams, time, builder_max_absorb_times[builder_index]) clamped_grams = min(entry_grams, max(min_predicted_grams, observed_grams)) min_absorption_rate = (carb_entry_quantities[builder_index] / builder_max_absorb_times[builder_index]) estimated_time_remaining = ((entry_grams - clamped_grams) / min_absorption_rate if min_absorption_rate > 0 else 0) absorption = [ observed_grams, clamped_grams, entry_grams, entry_grams - clamped_grams, carb_entry_starts[builder_index], observed_completion_dates[builder_index] or last_effect_dates[builder_index], estimated_time_remaining ] return absorption
def continuous_delivery_glucose_effect(dose_start_date, dose_end_date, at_date, model, delay, delta): """ Calculates the percent of glucose effect at a specific time for a dose given over a period greater than 1.05x the delta (this will almost always be a basal) Arguments: dose_start_date -- the date the dose started at (datetime object) dose_end_date -- the date the dose ended at (datetime object) at_date -- date the IOB is being calculated (datetime object) model -- list of insulin model parameters in format [DIA, peak_time] delay -- the time to delay the dose effect delta -- the differential between timeline entries Output: Percentage of insulin remaining at the at_date """ dose_duration = time_interval_since(dose_end_date, dose_start_date) delay *= 60 delta *= 60 if dose_duration < 0: return 0 time = time_interval_since(at_date, dose_start_date) activity = 0 dose_date = 0 while (dose_date <= min( floor((time + delay) / delta) * delta, dose_duration)): if dose_duration > 0: segment = (max(0, min(dose_date + delta, dose_duration) - dose_date) / dose_duration) else: segment = 1 if len(model) == 1: # if walsh model activity += segment * (1 - walsh_percent_effect_remaining( (time - delay - dose_date) / 60, model[0])) else: activity += segment * (1 - percent_effect_remaining( (time - delay - dose_date) / 60, model[0], model[1])) dose_date += delta return activity
def get_pending_insulin( at_date, basal_starts, basal_rates, basal_minutes, last_temp_basal, pending_bolus_amount=None ): """ Get the pending insulin for the purposes of calculating a recommended bolus Arguments: at_date -- the "now" time (roughly equivalent to datetime.now) basal_starts -- list of times the basal rates start at basal_rates -- list of basal rates (U/hr) basal_minutes -- list of basal lengths (in mins) last_temp_basal -- information about the last temporary basal in the form [type, start time, end time, basal rate] pending_bolus_amount -- amount of unconfirmed bolus insulin (U) Output: Amount of insulin that is "pending" """ assert len(basal_starts) == len(basal_rates),\ "expected input shapes to match" if (not basal_starts or not last_temp_basal or last_temp_basal[1] > last_temp_basal[2] ): return 0 # if the end date for the temp basal is greater than current date, # find the pending insulin if (last_temp_basal[2] > at_date and last_temp_basal[0] in [DoseType.tempbasal, DoseType.basal]): normal_basal_rate = find_ratio_at_time( basal_starts, [], basal_rates, at_date ) remaining_time = time_interval_since( last_temp_basal[2], at_date ) / 60 / 60 remaining_units = ( last_temp_basal[3] - normal_basal_rate ) * remaining_time pending_basal_insulin = max(0, remaining_units) else: pending_basal_insulin = 0 if pending_bolus_amount: pending_bolus = pending_bolus_amount else: pending_bolus = 0 return pending_basal_insulin + pending_bolus
def linear_momentum_effect( date_list, glucose_value_list, display_list, provenance_list, duration=30, delta=5 ): """ Calculates the short-term predicted momentum effect using linear regression Arguments: date_list -- list of datetime objects glucose_value_list -- list of glucose values (unit: mg/dL) display_list -- list of display_only booleans provenance_list -- list of provenances (Strings) duration -- the duration of the effects delta -- the time differential for the returned values Output: tuple with format (date_of_glucose_effect, value_of_glucose_effect) """ assert len(date_list) == len(glucose_value_list) == len(display_list)\ == len(provenance_list), "expected input shape to match" if (len(date_list) <= 2 or not is_continuous(date_list) or is_calibrated(display_list) or not has_single_provenance(provenance_list) ): return ([], []) first_time = date_list[0] last_time = date_list[-1] (start_date, end_date) = simulation_date_range_for_samples( [last_time], [], duration, delta ) def create_times(time): return abs(time_interval_since(time, first_time)) slope = linear_regression( list(map(create_times, date_list)), glucose_value_list ) if math.isnan(slope) or math.isinf(slope): return ([], []) date = start_date momentum_effect_dates = [] momentum_effect_values = [] while date <= end_date: value = (max(0, time_interval_since(date, last_time)) * slope) momentum_effect_dates.append(date) momentum_effect_values.append(value) date += timedelta(minutes=delta) assert len(momentum_effect_dates) == len(momentum_effect_values),\ "expected output shape to match" return (momentum_effect_dates, momentum_effect_values)
def absorbed_carbs(start_date, carb_value, absorption_time, at_date, delay): """ Find absorbed carbs using a parabolic model Parameters: start_date -- date of carb consumption (datetime object) carb_value -- carbs consumed absorption_time -- time for carbs to completely absorb (in minutes) at_date -- date to calculate the absorbed carbs (datetime object) delay -- minutes to delay the start of absorption Output: Grams of absorbed carbs """ time = time_interval_since(at_date, start_date) / 60 return parabolic_absorbed_carbs(carb_value, time - delay, absorption_time)
def if_necessary( temp_basal, at_date, scheduled_basal_rate, last_temp_basal, continuation_interval ): """ Determine whether the recommendation is necessary given the current state of the pump Arguments: temp_basal -- recommended temp basal at_date -- date to calculate temp basal at (datetime) scheduled_basal_rate -- basal rate scheduled during "at_date" last_temp_basal -- the previously set temp basal continuation_interval -- duration of time before an ongoing temp basal should be continued with a new command Output: None (if the scheduled temp basal or basal rate should be allowed to continue to run), cancel (if the temp basal should be cancelled), or the recommended temp basal (if it should be set) """ # Adjust behavior for the currently active temp basal if (last_temp_basal and last_temp_basal[0] in [DoseType.tempbasal, DoseType.basal] and last_temp_basal[2] > at_date ): # If the last temp basal has the same rate, and has more than # "continuation_interval" of time remaining, don't set a new temp if (matches_rate(temp_basal[0], last_temp_basal[3]) and ( (time_interval_since(last_temp_basal[2], at_date) / 60) > continuation_interval) ): return None # If our new temp matches the scheduled rate, cancel the current temp elif matches_rate(temp_basal[0], scheduled_basal_rate): return Correction.cancel # If we recommend the in-progress scheduled basal rate, do nothing elif matches_rate(temp_basal[0], scheduled_basal_rate): return None return temp_basal
def dose_entries(reservoir_dates, unit_volumes): """ Converts a continuous, chronological sequence of reservoir values to a sequence of doses Runtime: O(n) Arguments: reservoir_dates -- list of datetime objects unit_volumes -- list of reservoir volumes (in units of insulin) Output: A tuple of lists in (dose_type (basal/bolus), start_dates, end_dates, insulin_values) format """ assert len(reservoir_dates) > 1,\ "expected input lists to contain two or more items" assert len(reservoir_dates) == len(unit_volumes),\ "expected input shape to match" dose_types = [] start_dates = [] end_dates = [] insulin_values = [] previous_date = reservoir_dates[0] previous_unit_volume = unit_volumes[0] for i in range(1, len(reservoir_dates)): volume_drop = previous_unit_volume - unit_volumes[i] duration = time_interval_since(reservoir_dates[i], previous_date) if (duration > 0 and 0 <= volume_drop <= MAXIMUM_RESERVOIR_DROP_PER_MINUTE * duration / 60): dose_types.append(DoseType.tempbasal) start_dates.append(previous_date) end_dates.append(reservoir_dates[i]) insulin_values.append(volume_drop) previous_date = reservoir_dates[i] previous_unit_volume = unit_volumes[i] assert len(dose_types) == len(start_dates) == len(end_dates) ==\ len(insulin_values), "expected output shape to match" return (dose_types, start_dates, end_dates, insulin_values)
def test_time_interval_since(self): date = datetime.now() self.assertEqual(0, time_interval_since(date, date)) self.assertEqual( -371, time_interval_since(date, date + timedelta(seconds=371))) self.assertEqual( 123456, time_interval_since(date, date + timedelta(seconds=-123456))) self.assertEqual( -200, time_interval_since(date, date + timedelta(seconds=200))) self.assertEqual( 200, time_interval_since(date, date + timedelta(seconds=-200))) self.assertEqual( 86400, time_interval_since(date, date + timedelta(seconds=-86400))) self.assertEqual( -86400, time_interval_since(date, date + timedelta(seconds=86400)))
def decay_effect(glucose_date, glucose_value, rate, duration, delta=5): """ Calculates a timeline of glucose effects by applying a linear decay to a rate of change. Arguments: glucose_date -- time of glucose value (datetime) glucose_value -- value at the time of glucose_date rate -- the glucose velocity duration -- the duration the effect should continue before ending delta -- the time differential for the returned values Output: Glucose effects in format (effect_date, effect_value) """ (start_date, end_date) = simulation_date_range_for_samples([glucose_date], [], duration, delta) # The starting rate, which we will decay to 0 over the specified duration intercept = rate last_value = glucose_value effect_dates = [start_date] effect_values = [glucose_value] date = decay_start_date = start_date + timedelta(minutes=delta) slope = (-intercept / (duration - delta)) while date < end_date: value = ( last_value + (intercept + slope * time_interval_since(date, decay_start_date) / 60) * delta) effect_dates.append(date) effect_values.append(value) last_value = value date = date + timedelta(minutes=delta) assert len(effect_dates) == len(effect_values),\ "expected output shapes to match" return (effect_dates, effect_values)
def is_continuous(date_list, delta=5): """ Checks whether the collection can be considered continuous Arguments: date_list -- list of datetime objects delta -- the (expected) time interval between CGM values Output: Whether the collection is continuous """ try: return ( abs(time_interval_since(date_list[0], date_list[-1])) < delta * (len(date_list)) * 60 ) except IndexError: print("Out of bounds error: list doesn't contain date values") return False
def test_decay_effect_with_even_glucose(self): glucose_date = datetime(2016, 2, 1, 10, 15, 0) glucose_value = 100 starting_effect = 2 (dates, values ) = decay_effect( glucose_date, glucose_value, starting_effect, 30 ) self.assertEqual( [100, 110, 118, 124, 128, 130], values ) start_date = dates[0] time_deltas = [] for time in dates: time_deltas.append( time_interval_since(time, start_date) / 60 ) self.assertEqual( [0, 5, 10, 15, 20, 25], time_deltas ) (dates, values ) = decay_effect( glucose_date, glucose_value, -0.5, 30 ) self.assertEqual( [100, 97.5, 95.5, 94, 93, 92.5], values )
def clamped_timeline(builder_index): entry_grams = carb_entry_quantities[builder_index] time = (time_interval_since(last_effect_dates[builder_index], carb_entry_starts[builder_index]) / 60 - delay) min_predicted_grams = linearly_absorbed_carbs( entry_grams, time, builder_max_absorb_times[builder_index]) observed_grams = (observed_effects[builder_index] / builder_carb_sensitivities[builder_index]) output = [] for i in range(0, len(observed_timeline_starts[builder_index])): output.append([ observed_timeline_starts[builder_index][i], observed_timeline_ends[builder_index][i], observed_timeline_carb_values[builder_index][i] ] if ( observed_grams >= min_predicted_grams) else [None, None, None]) return output
def insulin_correction( prediction_dates, prediction_values, target_starts, target_ends, target_mins, target_maxes, at_date, suspend_threshold_value, sensitivity_value, model ): """ Computes a total insulin amount necessary to correct a glucose differential at a given sensitivity prediction_dates -- dates glucose values were predicted (datetime) prediction_values -- predicted glucose values (mg/dL) target_starts -- start times for given target ranges (datetime) target_ends -- stop times for given target ranges (datetime) target_mins -- the lower bounds of target ranges (mg/dL) target_maxes -- the upper bounds of target ranges (mg/dL) at_date -- date to calculate correction suspend_threshold -- value to suspend all insulin delivery at (mg/dL) sensitivity_value -- the sensitivity (mg/dL/U) model -- list of insulin model parameters in format [DIA, peak_time] if exponential model, or [DIA] if Walsh model Output: A list of insulin correction information. All lists have the type as the first index, and may include additional information based on the type. Types: - entirely_below_range Structure: [type, glucose value to be corrected, minimum target, units of correction insulin] - suspend Structure: [type, min glucose value] - in_range Structure: [type] - above_range Structure: [type, minimum predicted glucose value, glucose value to be corrected, minimum target, units of correction insulin] """ assert len(prediction_dates) == len(prediction_values),\ "expected input shapes to match" assert len(target_starts) == len(target_ends) == len(target_mins)\ == len(target_maxes), "expected input shapes to match" (min_glucose, eventual_glucose, correcting_glucose, min_correction_units ) = ([], None, None, None) # only calculate a correction if the prediction is between # "now" and now + DIA if len(model) == 1: # if Walsh model date_range = [at_date, at_date + timedelta(hours=model[0]) ] else: date_range = [at_date, at_date + timedelta(minutes=model[0]) ] # if we don't know the suspend threshold, it defaults to the lower # bound of the correction range at the time the "loop" is being run at if not suspend_threshold_value: suspend_threshold_value = find_ratio_at_time( target_starts, target_ends, target_mins, at_date ) # For each prediction above target, determine the amount of insulin # necessary to correct glucose based on the modeled effectiveness of # the insulin at that time for i in range(0, len(prediction_dates)): if not is_time_between( date_range[0], date_range[1], prediction_dates[i] ): continue # If any predicted value is below the suspend threshold, # return immediately if prediction_values[i] < suspend_threshold_value: return [Correction.suspend, prediction_values[i]] # Update range statistics if not min_glucose or prediction_values[i] < min_glucose[1]: min_glucose = [prediction_dates[i], prediction_values[i]] eventual_glucose = [prediction_dates[i], prediction_values[i]] predicted_glucose_value = prediction_values[i] time = time_interval_since( prediction_dates[i], at_date ) / 60 average_target = ( find_ratio_at_time( target_starts, target_ends, target_maxes, prediction_dates[i] ) + find_ratio_at_time( target_starts, target_ends, target_mins, prediction_dates[i] ) ) / 2 # Compute the target value as a function of time since the dose started target_value = target_glucose_value( (time / ( (60 * model[0]) if len(model) == 1 else model[0] ) ), suspend_threshold_value, average_target ) # Compute the dose required to bring this prediction to target: # dose = (Glucose delta) / (% effect × sensitivity) if len(model) == 1: # if Walsh model percent_effected = 1 - walsh_percent_effect_remaining( time, model[0] ) else: percent_effected = 1 - percent_effect_remaining( time, model[0], model[1] ) effected_sensitivity = percent_effected * sensitivity_value # calculate the Units needed to correct that predicted glucose value correction_units = insulin_correction_units( predicted_glucose_value, target_value, effected_sensitivity ) if not correction_units or correction_units <= 0: continue # Update the correction only if we've found a new minimum if min_correction_units: if correction_units >= min_correction_units: continue correcting_glucose = [prediction_dates[i], prediction_values[i]] min_correction_units = correction_units if not eventual_glucose or not min_glucose: return None # Choose either the minimum glucose or eventual glucose as correction delta min_glucose_targets = [ find_ratio_at_time( target_starts, target_ends, target_mins, min_glucose[0] ), find_ratio_at_time( target_starts, target_ends, target_maxes, min_glucose[0] ) ] eventual_glucose_targets = [ find_ratio_at_time( target_starts, target_ends, target_mins, eventual_glucose[0] ), find_ratio_at_time( target_starts, target_ends, target_maxes, eventual_glucose[0] ) ] # Treat the mininum glucose when both are below range if (min_glucose[1] < min_glucose_targets[0] and eventual_glucose[1] < min_glucose_targets[0] ): time = time_interval_since(min_glucose[0], at_date) / 60 # For time = 0, assume a small amount effected. # This will result in large (negative) unit recommendation # rather than no recommendation at all. if len(model) == 1: percent_effected = max( sys.float_info.epsilon, 1 - walsh_percent_effect_remaining(time, model[0]) ) else: percent_effected = max( sys.float_info.epsilon, 1 - percent_effect_remaining(time, model[0], model[1]) ) units = insulin_correction_units( min_glucose[1], sum(min_glucose_targets) / len(min_glucose_targets), sensitivity_value * percent_effected ) if not units: return None # we're way below target return [ Correction.entirely_below_range, min_glucose[1], min_glucose_targets[0], units ] # we're above target elif (eventual_glucose[1] > eventual_glucose_targets[1] and min_correction_units and correcting_glucose ): return [ Correction.above_range, min_glucose[1], correcting_glucose[1], eventual_glucose_targets[0], min_correction_units ] # we're in range else: return [Correction.in_range]
def dynamic_absorbed_carbs( carb_start, carb_value, absorption_dict, observed_timeline, at_date, carb_absorption_time, delay, delta, ): """ Find partial absorbed carbs for a particular carb entry *dynamically* Arguments: carb_start -- time of carb entry (datetime objects) carb_value -- grams of carbs eaten absorption_dict -- list of absorption information (computed via map_) observed_timeline -- list of carb absorption info at various times (computed via map_) at_date -- date to calculate the glucose effect (datetime object) carb_absorption_time -- time carbs will take to absorb (mins) delay -- the time to delay the carb effect Output: Carbohydrate value (g) """ # We have to have absorption info for dynamic calculation if (at_date < carb_start or not absorption_dict): return carb_math.absorbed_carbs( carb_start, carb_value, carb_absorption_time, at_date, delay, ) # Less than minimum observed; calc based on min absorption rate if observed_timeline and None in observed_timeline[0]: time = time_interval_since(at_date, carb_start) / 60 - delay estimated_date_duration = ( time_interval_since(absorption_dict[5], absorption_dict[4]) / 60 + absorption_dict[6]) return carb_math.linearly_absorbed_carbs(absorption_dict[2], time, estimated_date_duration) if (not observed_timeline # no absorption was observed (empty list) or not observed_timeline[len(observed_timeline) - 1] or at_date > observed_timeline[len(observed_timeline) - 1][1]): # Predict absorption for remaining carbs, post-observation total = absorption_dict[3] # these are the still-unabsorbed carbs time = time_interval_since(at_date, absorption_dict[5]) / 60 absorption_time = absorption_dict[6] return absorption_dict[1] + carb_math.linearly_absorbed_carbs( total, time, absorption_time) sum_ = 0 # There was observed absorption def filter_dates(sub_timeline): return sub_timeline[0] + timedelta(minutes=delta) <= at_date before_timelines = list(filter(filter_dates, observed_timeline)) if before_timelines: last = before_timelines.pop() observation_interval = (last[1] - last[0]).total_seconds() if observation_interval > 0: # find the minutes of overlap between calculation_interval # and observation_interval calculation_interval = (last[1] - min(last[0], at_date)).total_seconds() sum_ += (calculation_interval / observation_interval * last[2]) for dict_ in before_timelines: sum_ += dict_[2] return min(sum_, absorption_dict[0])
def annotate_individual_dose(dose_type, dose_start_date, dose_end_date, value, basal_start_times, basal_rates, basal_minutes, convert_to_units_hr=True): """ Annotates a dose with the context of the scheduled basal rate If the dose crosses a schedule boundary, it will be split into multiple doses so each dose has a single scheduled basal rate. Arguments: dose_type -- type of dose (basal, bolus, etc) dose_start_date -- start date of the dose (datetime obj) dose_end_date -- end date of the dose (datetime obj) value -- actual basal rate of dose in U/hr (if a basal) or the value of the bolus in U basal_start_times -- list of times the basal rates start at basal_rates -- list of basal rates(U/hr) basal_minutes -- list of basal lengths (in mins) convert_to_units_hr -- set to True if you want to convert a dose to U/hr (ex: 0.05 U given from 1/1/01 1:00:00 to 1/1/01 1:05:00 -> 0.6 U/hr) Output: Tuple with properties of doses, annotated with the current basal rates """ if dose_type not in [DoseType.basal, DoseType.tempbasal, DoseType.suspend]: return ([dose_type], [dose_start_date], [dose_end_date], [value], [0]) output_types = [] output_start_dates = [] output_end_dates = [] output_values = [] output_scheduled_basal_rates = [] # these are the lists containing the scheduled basal value(s) within # the temp basal's duration (sched_basal_starts, sched_basal_ends, sched_basal_rates) = between( basal_start_times, basal_rates, basal_minutes, dose_start_date, dose_end_date, ) for i in range(0, len(sched_basal_starts)): if i == 0: start_date = dose_start_date else: start_date = sched_basal_starts[i] if i == len(sched_basal_starts) - 1: end_date = dose_end_date else: end_date = sched_basal_starts[i + 1] output_types.append(dose_type) output_start_dates.append(start_date) output_end_dates.append(end_date) if convert_to_units_hr: output_values.append( 0 if dose_type == DoseType.suspend else value / (time_interval_since(dose_end_date, dose_start_date) / 60 / 60)) else: output_values.append(value) output_scheduled_basal_rates.append(sched_basal_rates[i]) assert len(output_types) == len(output_start_dates) ==\ len(output_end_dates) == len(output_values) ==\ len(output_scheduled_basal_rates), "expected output shapes to match" return (output_types, output_start_dates, output_end_dates, output_values, output_scheduled_basal_rates)
def between(basal_start_times, basal_rates, basal_minutes, start_date, end_date, repeat_interval=24): """ Returns a slice of scheduled basal rates that occur between two dates Arguments: basal_start_times -- list of times the basal rates start at basal_rates -- list of basal rates(U/hr) basal_minutes -- list of basal lengths (in mins) start_date -- start date of the range (datetime obj) end_date -- end date of the range (datetime obj) repeat_interval -- the duration over which the rates repeat themselves (24 hours by default) Output: Tuple in format (basal_start_times, basal_rates, basal_minutes) within the range of dose_start_date and dose_end_date """ timezone_info = start_date.tzinfo if start_date > end_date: return ([], [], []) reference_time_interval = timedelta(hours=basal_start_times[0].hour, minutes=basal_start_times[0].minute, seconds=basal_start_times[0].second) max_time_interval = (reference_time_interval + timedelta(hours=repeat_interval)) start_offset = schedule_offset(start_date, basal_start_times[0]) end_offset = (start_offset + timedelta(seconds=time_interval_since(end_date, start_date))) # if a dose is crosses days, split it into separate doses if end_offset > max_time_interval: boundary_date = start_date + (max_time_interval - start_offset) (start_times_1, end_times_1, basal_rates_1) = between(basal_start_times, basal_rates, basal_minutes, start_date, boundary_date, repeat_interval=repeat_interval) (start_times_2, end_times_2, basal_rates_2) = between(basal_start_times, basal_rates, basal_minutes, boundary_date, end_date, repeat_interval=repeat_interval) return (start_times_1 + start_times_2, end_times_1 + end_times_2, basal_rates_1 + basal_rates_2) start_index = 0 end_index = len(basal_start_times) for (i, start_time) in enumerate(basal_start_times): start_time = timedelta(hours=start_time.hour, minutes=start_time.minute, seconds=start_time.second) if start_offset >= start_time: start_index = i if end_offset < start_time: end_index = i break reference_date = start_date - start_offset reference_date = datetime(year=reference_date.year, month=reference_date.month, day=reference_date.day, hour=reference_date.hour, minute=reference_date.minute, second=reference_date.second, tzinfo=timezone_info) if start_index > end_index: return ([], [], []) (output_start_times, output_end_times, output_basal_rates) = ([], [], []) for i in range(start_index, end_index): end_time = (timedelta(hours=basal_start_times[i + 1].hour, minutes=basal_start_times[i + 1].minute, seconds=basal_start_times[i + 1].second) if i + 1 < len(basal_start_times) else max_time_interval) output_start_times.append( reference_date + timedelta(hours=basal_start_times[i].hour, minutes=basal_start_times[i].minute, seconds=basal_start_times[i].second)) output_end_times.append(reference_date + end_time) output_basal_rates.append(basal_rates[i]) assert len(output_start_times) == len(output_end_times) ==\ len(output_basal_rates), "expected output shape to match" return (output_start_times, output_end_times, output_basal_rates)
def overlay_basal_schedule(dose_types, starts, ends, values, basal_start_times, basal_rates, basal_minutes, starting_at, ending_at, inserting_basal_entries): """ Applies the current basal schedule to a collection of reconciled doses in chronological order Arguments: dose_types -- types of doses (basal, bolus, etc) starts -- datetime objects of times doses started at ends -- datetime objects of times doses ended at values -- amounts, in U/hr (if a basal) or U (if bolus) of insulin in doses basal_start_times -- list of times the basal rates start at basal_rates -- list of basal rates(U/hr) basal_minutes -- list of basal lengths (in mins) starting_at -- start of interval to overlay the basal schedule (datetime object) ending_at -- end of interval to overlay the basal schedule (datetime object) inserting_basal_entries -- whether basal doses should be created from the schedule. Pass true only for pump models that do not report their basal rates in event history. Output: Tuple with dose properties in range (start_interval, end_interval), overlayed with the basal schedule. It returns *four* dose properties, and does *not* return scheduled_basal_rates """ assert len(dose_types) == len(starts) == len(ends) == len(values),\ "expected input shapes to match" (out_dose_types, out_starts, out_ends, out_values) = ([], [], [], []) last_basal = [] if inserting_basal_entries: last_basal = [DoseType.tempbasal, starting_at, starting_at, 0] for (i, type_) in enumerate(dose_types): if type_ in [DoseType.tempbasal, DoseType.basal, DoseType.suspend]: if ending_at and ends[i] > ending_at: continue if last_basal: if inserting_basal_entries: (sched_basal_starts, sched_basal_ends, sched_basal_rates) = between(basal_start_times, basal_rates, basal_minutes, last_basal[2], starts[i]) for j in range(0, len(sched_basal_starts)): start = max(last_basal[2], sched_basal_starts[j]) end = min(starts[i], sched_basal_ends[j]) if time_interval_since(end, start)\ < sys.float_info.epsilon: continue out_dose_types.append(DoseType.basal) out_starts.append(start) out_ends.append(end) out_values.append(sched_basal_rates[j]) last_basal = [dose_types[i], starts[i], ends[i], values[i]] if last_basal: out_dose_types.append(last_basal[0]) out_starts.append(last_basal[1]) out_ends.append(last_basal[2]) out_values.append(last_basal[3]) elif type_ == DoseType.resume: assert "No resume events should be present in reconciled doses" elif type_ == DoseType.bolus: out_dose_types.append(dose_types[i]) out_starts.append(starts[i]) out_ends.append(ends[i]) out_values.append(values[i]) assert len(out_dose_types) == len(out_starts) == len(out_ends)\ == len(out_values), "expected output shape to match" return (out_dose_types, out_starts, out_ends, out_values)
def is_continuous(reservoir_dates, unit_volumes, start, end, maximum_duration): """ Whether a span of chronological reservoir values is considered continuous and therefore reliable. Reservoir values of 0 are automatically considered unreliable due to the assumption that an unknown amount of insulin can be delivered after the 0 marker. Arguments: reservoir_dates -- list of datetime objects that correspond by index to unit_volumes unit_volumes -- volume of reservoir in units, corresponds by index to reservoir_dates start -- datetime object that is start of the interval which to validate continuity end -- datetime object that is end of the interval which to validate continuity maximum_duration -- the maximum interval to consider reliable for a reservoir-derived dose Variable names: start_date -- the beginning of the interval in which to validate continuity end_date -- the end of the interval in which to validate continuity Outputs: Whether the reservoir values meet the critera for continuity """ try: first_date_value = reservoir_dates[0] first_volume_value = unit_volumes[0] except IndexError: return False if end < start: return False start_date = start # The first value has to be at least as old as the start date # as a reference point. if first_date_value > start_date: return False last_date_value = first_date_value last_volume_value = first_volume_value for i in range(0, len(unit_volumes)): # pylint: disable=C0200 # Volume and interval validation only applies for values in # the specified range if reservoir_dates[i] < start_date or reservoir_dates[i] > end: last_date_value = reservoir_dates[i] last_volume_value = unit_volumes[i] continue # We can't trust 0. What else was delivered? if unit_volumes[i] <= 0: return False # Rises in reservoir volume indicate a rewind + prime, and primes # can be easily confused with boluses. # Small rises (1 U) can be ignored as they're indicative of a # mixed-precision sequence. if unit_volumes[i] > last_volume_value + 1: return False # Ensure no more than the maximum interval has passed if (time_interval_since(reservoir_dates[i], last_date_value) > maximum_duration * 60): return False last_date_value = reservoir_dates[i] last_volume_value = unit_volumes[i] return True
def update_retrospective_glucose_effect( glucose_dates, glucose_values, carb_effect_dates, carb_effect_values, counteraction_starts, counteraction_ends, counteraction_values, recency_interval, retrospective_correction_grouping_interval, now_time, effect_duration=60, delta=5 ): """ Generate an effect based on how large the discrepancy is between the current glucose and its predicted value. Arguments: glucose_dates -- time of glucose value (datetime) glucose_values -- value at the time of glucose_date carb_effect_dates -- date the carb effects occur at (datetime) carb_effect_values -- value of carb effect counteraction_starts -- start times for counteraction effects counteraction_ends -- end times for counteraction effects counteraction_values -- values of counteraction effects recency_interval -- amount of time since a given date that data should be considered valid retrospective_correction_grouping_interval -- interval over which to aggregate changes in glucose for retrospective correction now_time -- the time the loop is being run at effect_duration -- the length of time to calculate the retrospective glucose effect out to delta -- time interval between glucose values (mins) Output: Retrospective glucose effect information in format (retrospective_effect_dates, retrospective_effect_values) """ assert len(glucose_dates) == len(glucose_values),\ "expected input shapes to match" assert len(carb_effect_dates) == len(carb_effect_values),\ "expected input shapes to match" assert len(counteraction_starts) == len(counteraction_ends)\ == len(counteraction_values), "expected input shapes to match" if not glucose_dates or not carb_effect_dates or not counteraction_starts: return ([], []) (discrepancy_starts, discrepancy_values) = subtracting( counteraction_starts, counteraction_ends, counteraction_values, carb_effect_dates, [], carb_effect_values, delta ) retrospective_glucose_discrepancies_summed = combined_sums( discrepancy_starts, discrepancy_starts, discrepancy_values, retrospective_correction_grouping_interval * 1.01 ) # Our last change should be recent, otherwise clear the effects if (time_interval_since( now_time, retrospective_glucose_discrepancies_summed[1][-1]) > recency_interval * 60 ): return ([], []) discrepancy_time = max( 0, retrospective_correction_grouping_interval ) velocity = ( retrospective_glucose_discrepancies_summed[2][-1] / discrepancy_time ) return decay_effect( glucose_dates[-1], glucose_values[-1], velocity, effect_duration )
def plot_multiple_relative_graphs( dates, values, x_label=None, y_label=None, title=None, line_color=None, fill_color=None, file_name=None, grid=False): """ Plot an Loop-style effects graph, with the x-axis ticks being the relative time since the first value (ex: 4 hours) AND there being multiple lines on the same graph dates -- lists of dates to plot at (datetime) ex: [ [1:00, 2:00], [1:00, 2:00] ] values -- lists of integer values to plot ex: ex: [ [2, 4], [0, -20] ] Optional parameters: x_label -- the x-axis label y_label -- the y-axis label title -- the title of the graph line_color -- color of the line that is graphed fill_color -- the color of the fill under the graph (defaults to no fill) file_name -- name to save the plot as (if no name is specified, the graph is not saved) grid -- set to True to enable a grid on the graph line_style -- see pyplot documentation for the line style options scatter -- plot points as a scatter plot instead of a line """ assert len(dates) == len(values) font = { 'family': 'DejaVu Sans', 'weight': 'bold', 'size': 15 } plt.rc('font', **font) figure_size_inches = (15, 7) fig, ax = plt.subplots(figsize=figure_size_inches) coord_color = "#c0c0c0" ax.spines['bottom'].set_color(coord_color) ax.spines['top'].set_color(coord_color) ax.spines['left'].set_color(coord_color) ax.spines['right'].set_color(coord_color) ax.xaxis.label.set_color(coord_color) ax.tick_params(axis='x', colors=coord_color) ax.yaxis.label.set_color(coord_color) ax.tick_params(axis='y', colors=coord_color) ax.spines['right'].set_visible(False) ax.spines['left'].set_visible(False) relative_dates = [] # convert from exact dates to relative dates for date_list in dates: relative_date_list = [] for date in date_list: relative_date_list.append( time_interval_since(date, date_list[0]) / 3600 ) relative_dates.append(relative_date_list) x_ticks_duplicates = [date.hour - dates[0][0].hour for date in dates[0]] x_ticks = list(OrderedDict.fromkeys(x_ticks_duplicates)) labels = ["%d" % x1 for x1 in x_ticks] plt.xticks(x_ticks, labels) if x_label: ax.set_xlabel(x_label) if y_label: ax.set_ylabel(y_label) if title: plt.title(title, loc="left", fontweight='bold') for (date_list, value_list) in zip(dates, values): ax.plot( date_list, value_list, color=line_color or "#f09a37", lw=4, ls="-" ) if fill_color: plt.fill_between( relative_dates, values, color=fill_color or "#f09a37", alpha=0.5 ) if grid: plt.grid(grid) if file_name: plt.savefig(file_name + ".png") plt.show()
def counteraction_effects( dates, glucose_values, displays, provenances, effect_dates, effect_values ): """ Calculates a timeline of effect velocity (glucose/time) observed in glucose readings that counteract the specified effects. Arguments: dates -- list of datetime objects of dates of glucose values glucose_values -- list of glucose values (unit: mg/dL) displays -- list of display_only booleans provenances -- list of provenances (Strings) effect_dates -- list of datetime objects associated with a glucose effect effect_values -- list of values associated with a glucose effect Output: An array of velocities describing the change in glucose samples compared to the specified effects """ assert len(dates) == len(glucose_values) == len(displays)\ == len(provenances), "expected input shape to match" assert len(effect_dates) == len(effect_values),\ "expected input shape to match" if not dates or not effect_dates: return ([], [], []) effect_index = 0 start_glucose = glucose_values[0] start_date = dates[0] start_prov = provenances[0] start_display = displays[0] start_dates = [] end_dates = [] velocities = [] for i in range(1, len(dates)): # Find a valid change in glucose, requiring identical # provenance and no calibration glucose_change = glucose_values[i] - start_glucose time_interval = time_interval_since(dates[i], start_date) if time_interval <= 4 * 60: continue if (not start_prov == provenances[i] or start_display or displays[i] ): start_glucose = glucose_values[i] start_date = dates[i] start_prov = provenances[i] start_display = displays[i] continue start_effect_date = None start_effect_value = None end_effect_date = None end_effect_value = None for j in range(effect_index, len(effect_dates)): # if one of the start_effect properties doesn't exist and # the glucose effect at position "j" will happen after the # starting glucose date, then make start_effect equal to that # effect if (not start_effect_date and effect_dates[j] >= start_date ): start_effect_date = effect_dates[j] start_effect_value = effect_values[j] elif (not end_effect_date and effect_dates[j] >= dates[i] ): end_effect_date = effect_dates[j] end_effect_value = effect_values[j] break effect_index += 1 if end_effect_value is None: continue effect_change = end_effect_value - start_effect_value discrepancy = glucose_change - effect_change average_velocity = discrepancy / time_interval * 60 start_dates.append(start_date) end_dates.append(dates[i]) velocities.append(average_velocity) start_glucose = glucose_values[i] start_date = dates[i] start_prov = provenances[i] start_display = displays[i] assert len(start_dates) == len(end_dates) == len(velocities),\ "expected output shape to match" return (start_dates, end_dates, velocities)
def predict_glucose(starting_date, starting_glucose, momentum_dates=[], momentum_values=None, carb_effect_dates=[], carb_effect_values=None, insulin_effect_dates=[], insulin_effect_values=None, correction_effect_dates=[], correction_effect_values=None): """ Calculates a timeline of predicted glucose values from a variety of effects timelines. Each effect timeline: - Is given equal weight (exception: momentum effect timeline) - Can be of arbitrary size and start date - Should be in ascending order - Should have aligning dates with any overlapping timelines to ensure a smooth result Parameters: starting_date -- time of starting_glucose (datetime object) starting_glucose -- glucose value to use in predictions momentum_dates -- times of calculated momentums (datetime) momentum_values -- values (mg/dL) of momentums carb_effect_dates -- times of carb effects (datetime) carb_effect -- values (mg/dL) of effects from carbs insulin_effect_dates -- times of insulin effects (datetime) insulin_effect -- values (mg/dL) of effects from insulin correction_effect_dates -- times of retrospective effects (datetime) correction_effect -- values (mg/dL) retrospective glucose effects Output: Glucose predictions in form (prediction_times, prediction_glucose_values) """ if momentum_dates: assert len(momentum_dates) == len(momentum_values),\ "expected input shapes to match" if carb_effect_dates: assert len(carb_effect_dates) == len(carb_effect_values),\ "expected input shapes to match" if insulin_effect_dates: assert len(insulin_effect_dates) == len(insulin_effect_values),\ "expected input shapes to match" if correction_effect_dates: assert len(correction_effect_dates) == len(correction_effect_values),\ "expected input shapes to match" # if we didn't get any effect data, we won't predict the glucose if (not momentum_dates and not carb_effect_dates and not insulin_effect_dates and not correction_effect_dates): return ([], []) merged_dates = sorted( list( dict.fromkeys(momentum_dates + carb_effect_dates + insulin_effect_dates + correction_effect_dates))) merged_values = [0 for i in merged_dates] if carb_effect_dates: previous_effect_value = carb_effect_values[0] or 0 for i in range(0, len(carb_effect_dates)): value = carb_effect_values[i] list_index = merged_dates.index(carb_effect_dates[i]) merged_values[list_index] = (value - previous_effect_value) previous_effect_value = value if insulin_effect_dates: previous_effect_value = insulin_effect_values[0] or 0 for i in range(0, len(insulin_effect_dates)): value = insulin_effect_values[i] list_index = merged_dates.index(insulin_effect_dates[i]) merged_values[list_index] = (merged_values[list_index] + value - previous_effect_value) previous_effect_value = value if correction_effect_dates: previous_effect_value = correction_effect_values[0] or 0 for i in range(0, len(correction_effect_dates)): value = correction_effect_values[i] list_index = merged_dates.index(correction_effect_dates[i]) merged_values[list_index] = (merged_values[list_index] + value - previous_effect_value) previous_effect_value = value # Blend the momentum effect linearly into the summed effect list if len(momentum_dates) > 1: previous_effect_value = momentum_values[0] # The blend begins delta minutes after after the last glucose (1.0) # and ends at the last momentum point (0.0) # This assumes the first one occurs on/before the starting glucose blend_count = len(momentum_dates) - 2 time_delta = time_interval_since(momentum_dates[1], momentum_dates[0]) # The difference between the first momentum value # and the starting glucose value momentum_offset = time_interval_since(starting_date, momentum_dates[0]) blend_slope = 1 / blend_count blend_offset = (momentum_offset / time_delta * blend_slope) for i in range(0, len(momentum_dates)): value = momentum_values[i] date = momentum_dates[i] merge_index = merged_dates.index(date) effect_value_change = value - previous_effect_value split = min( 1, max(0, (len(momentum_dates) - i) / blend_count - blend_slope + blend_offset)) effect_blend = ((1 - split) * merged_values[merge_index]) momentum_blend = split * effect_value_change merged_values[merge_index] = effect_blend + momentum_blend previous_effect_value = value predicted_dates = [starting_date] predicted_values = [starting_glucose] for i in range(0, len(merged_dates)): if merged_dates[i] > starting_date: last_value = predicted_values[-1] predicted_dates.append(merged_dates[i]) predicted_values.append(last_value + merged_values[i]) assert len(predicted_dates) == len(predicted_values),\ "expected output shapes to match" return (predicted_dates, predicted_values)
def create_times(time): return abs(time_interval_since(time, first_time))
def hours(start_date, end_date): """ Find hours between two dates for the purposes of calculating basal delivery """ return abs(time_interval_since(end_date, start_date))/3600 # secs -> hrs
def dynamic_carbs_on_board_helper(carb_start, carb_value, absorption_dict, observed_timeline, at_date, default_absorption_time, delay, delta, carb_absorption_time=None): """ Find partial COB for a particular carb entry *dynamically* Arguments: carb_start -- time of carb entry (datetime objects) carb_value -- grams of carbs eaten absorption_dict -- list of absorption information (computed via map_) observed_timeline -- list of carb absorption info at various times (computed via map_) at_date -- date to calculate the glucose effect (datetime object) default_absorption_time -- absorption time to use for unspecified carb entries delay -- the time to delay the carb effect carb_absorption_time -- time carbs will take to absorb (mins) Output: Carbohydrate value (g) """ # We have to have absorption info for dynamic calculation if (at_date < carb_start - timedelta(minutes=delta) or not absorption_dict): return carb_math.carbs_on_board_helper(carb_start, carb_value, at_date, default_absorption_time, delay, carb_absorption_time) # Less than minimum observed; calc based on min absorption rate if observed_timeline and None in observed_timeline[0]: time = time_interval_since(at_date, carb_start) / 60 - delay estimated_date_duration = ( time_interval_since(absorption_dict[5], absorption_dict[4]) / 60 + absorption_dict[6]) return carb_math.linear_unabsorbed_carbs(absorption_dict[2], time, estimated_date_duration) if (not observed_timeline # no absorption was observed (empty list) or not observed_timeline[len(observed_timeline) - 1] or at_date > observed_timeline[len(observed_timeline) - 1][1]): # Predict absorption for remaining carbs, post-observation total = absorption_dict[3] # these are the still-unabsorbed carbs time = time_interval_since(at_date, absorption_dict[5]) / 60 absorption_time = absorption_dict[6] return carb_math.linear_unabsorbed_carbs(total, time, absorption_time) # There was observed absorption total = carb_value def partial_absorption(dict_): if dict_[1] > at_date: return 0 return dict_[2] for dict_ in observed_timeline: total -= partial_absorption(dict_) return max(total, 0)