From ef87d18469b75ccea52e848ab5f0b711af82b449 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Wed, 1 Jul 2026 06:22:00 +0000 Subject: [PATCH] Fix event-processing correctness bugs found during test audit - process_basal / process_user_mode: use timedelta.total_seconds() instead of .seconds so durations spanning >=24h (and negative deltas) are correct. - process_cartridge: report cartridge fill from insulinVolume (v2Volume is 0 on real pumps); treat tubing primeSize -1 as 'not recorded'; format cannula primeSize with %.1f instead of %d. - process_bolus: no longer drop the extended portion of a combo bolus. The initial portion is emitted as before; the extended portion (LidBolexCompleted) is added as a separate treatment at its completion time. - check.py: return after a config ImportError instead of falling through to an unbound-name NameError. --- tconnectsync/check.py | 3 ++ .../sync/tandemsource/process_basal.py | 4 +- .../sync/tandemsource/process_bolus.py | 54 ++++++++++++------- .../sync/tandemsource/process_cartridge.py | 12 +++-- .../sync/tandemsource/process_user_mode.py | 12 ++--- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/tconnectsync/check.py b/tconnectsync/check.py index 281e417..5c1cced 100644 --- a/tconnectsync/check.py +++ b/tconnectsync/check.py @@ -61,6 +61,9 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True): except ImportError as e: log("Error: Unable to load config file. Please check your .env file or environment variables") log_err(e) + # Config never loaded; the names below are unbound, so stop here instead + # of crashing with a NameError. + return log(f"Using {TCONNECT_REGION=}") diff --git a/tconnectsync/sync/tandemsource/process_basal.py b/tconnectsync/sync/tandemsource/process_basal.py index 729eeb8..41b0b60 100644 --- a/tconnectsync/sync/tandemsource/process_basal.py +++ b/tconnectsync/sync/tandemsource/process_basal.py @@ -89,7 +89,7 @@ class ProcessBasal: return None return NightscoutEntry.basal( value = value, - duration_mins = duration.seconds / 60, + duration_mins = duration.total_seconds() / 60, created_at = start.format(), reason = ', '.join(bitmask_to_list(event.changetype)), pump_event_id = "%s" % event.seqNum @@ -101,7 +101,7 @@ class ProcessBasal: return None return NightscoutEntry.basal( value = value, - duration_mins = duration.seconds / 60, + duration_mins = duration.total_seconds() / 60, created_at = start.format(), reason = ', '.join(bitmask_to_list(event.commandedRateSource)), pump_event_id = "%s" % event.seqNum diff --git a/tconnectsync/sync/tandemsource/process_bolus.py b/tconnectsync/sync/tandemsource/process_bolus.py index f36bd96..6d9c65c 100644 --- a/tconnectsync/sync/tandemsource/process_bolus.py +++ b/tconnectsync/sync/tandemsource/process_bolus.py @@ -40,33 +40,36 @@ class ProcessBolus: last_upload_time = arrow.get(last_upload["created_at"]) logger.info("Last Nightscout bolus upload: %s" % last_upload_time) - # TODO EXTENDED BOLUSES - bolusCompletedEvents = [] + # Correlate a bolus's request/completion messages by bolusid. bolusEventsForId = {} for event in sorted(events, key=lambda x: x.eventTimestamp): - if event.bolusid not in bolusEventsForId.keys(): - bolusEventsForId[event.bolusid] = {} - - bolusEventsForId[event.bolusid][type(event)] = event - - if type(event) == eventtypes.LidBolusCompleted: - if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time: - if self.pretend: - logger.info("Skipping bolusCompletedEvent not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end)) - continue - - bolusCompletedEvents.append(event) - - bolusCompletedEvents.sort(key=lambda e: e.eventTimestamp) + bolusEventsForId.setdefault(event.bolusid, {})[type(event)] = event + # Emit one Nightscout treatment per completion event, each at its own time: + # - LidBolusCompleted -> the standard / "now" bolus (carbs, bg, notes) + # - LidBolexCompleted -> the extended portion of a combo bolus (added + # separately, insulin only, so its later delivery is not dropped). + completions = [] + for event in sorted(events, key=lambda x: x.eventTimestamp): + if type(event) not in (eventtypes.LidBolusCompleted, eventtypes.LidBolexCompleted): + continue + if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time: + if self.pretend: + logger.info("Skipping bolus completion not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end)) + continue + completions.append(event) + completions.sort(key=lambda e: e.eventTimestamp) ns_entries = [] - for bolusCompleted in bolusCompletedEvents: - m = bolusEventsForId[bolusCompleted.bolusid] + for event in completions: + if type(event) == eventtypes.LidBolexCompleted: + ns_entries.append(self.bolex_to_nsentry(event)) + continue + m = bolusEventsForId[event.bolusid] ns_entries.append(self.bolus_to_nsentry( - bolusCompleted, + event, bolusRequested1 = m.get(eventtypes.LidBolusRequestedMsg1), bolusRequested2 = m.get(eventtypes.LidBolusRequestedMsg2), bolusRequested3 = m.get(eventtypes.LidBolusRequestedMsg3), @@ -116,3 +119,16 @@ class ProcessBolus: pump_event_id = ",".join(seq_nums) ) + def bolex_to_nsentry(self, bolexCompleted: "BaseEvent") -> Optional[dict]: + # The extended portion of a combo bolus, added as its own treatment at + # the time it finished delivering. Insulin only; carbs/bg belong to the + # initial LidBolusCompleted entry and must not be double-counted here. + return NightscoutEntry.bolus( + bolus = insulin_float_round(bolexCompleted.insulindelivered), + carbs = None, + created_at = bolexCompleted.eventTimestamp.format(), + notes = "Extended Bolus", + bg = None, + pump_event_id = "%s" % bolexCompleted.seqNum + ) + diff --git a/tconnectsync/sync/tandemsource/process_cartridge.py b/tconnectsync/sync/tandemsource/process_cartridge.py index ae9c79e..ada4945 100644 --- a/tconnectsync/sync/tandemsource/process_cartridge.py +++ b/tconnectsync/sync/tandemsource/process_cartridge.py @@ -85,22 +85,28 @@ class ProcessCartridge: return count def cart_to_nsentry(self, cartFilled: "BaseEvent") -> Optional[dict]: + # insulinVolume is populated on t:slim X2 / Mobi; v2Volume is a legacy fallback. + volume = cartFilled.insulinvolume or cartFilled.v2Volume return NightscoutEntry.sitechange( created_at = cartFilled.eventTimestamp.format(), - reason = "Cartridge Filled" + (" (%du filled)" % round(cartFilled.v2Volume) if cartFilled.v2Volume else ""), + reason = "Cartridge Filled" + (" (%du filled)" % round(volume) if volume else ""), pump_event_id = "%s" % cartFilled.seqNum ) def cannula_to_nsentry(self, cannulaFilled: "BaseEvent") -> Optional[dict]: + # primeSize is fractional (e.g. 0.3u); format with one decimal, not %d. + primed = cannulaFilled.primesize if cannulaFilled.primesize and cannulaFilled.primesize > 0 else None return NightscoutEntry.sitechange( created_at = cannulaFilled.eventTimestamp.format(), - reason = "Cannula Filled" + (" (%du primed)" % round(cannulaFilled.primesize, 2) if cannulaFilled.primesize else ""), + reason = "Cannula Filled" + (" (%.1fu primed)" % primed if primed else ""), pump_event_id = "%s" % cannulaFilled.seqNum ) def tubing_to_nsentry(self, tubingFilled: "BaseEvent") -> Optional[dict]: + # primeSize is -1 (sentinel, "not recorded") on real tubing fills; only show a real prime volume. + primed = tubingFilled.primesize if tubingFilled.primesize and tubingFilled.primesize > 0 else None return NightscoutEntry.sitechange( created_at = tubingFilled.eventTimestamp.format(), - reason = "Tubing Filled" + (" (%du primed)" % round(tubingFilled.primesize) if tubingFilled.primesize else ""), + reason = "Tubing Filled" + (" (%du primed)" % round(primed) if primed else ""), pump_event_id = "%s" % tubingFilled.seqNum ) diff --git a/tconnectsync/sync/tandemsource/process_user_mode.py b/tconnectsync/sync/tandemsource/process_user_mode.py index 41515ff..e7d2c54 100644 --- a/tconnectsync/sync/tandemsource/process_user_mode.py +++ b/tconnectsync/sync/tandemsource/process_user_mode.py @@ -157,7 +157,7 @@ class ProcessUserMode: elif start.activesleepschedule: reason = "Sleep (Scheduled)" - duration_mins = (stop.eventTimestamp - start.eventTimestamp).seconds / 60 + duration_mins = (stop.eventTimestamp - start.eventTimestamp).total_seconds() / 60 return NightscoutEntry.activity( created_at=start.eventTimestamp.format(), reason=reason, @@ -172,7 +172,7 @@ class ProcessUserMode: elif start.activesleepscheduleRaw: reason = "Sleep (Scheduled)" - duration_mins = (time_end - start.eventTimestamp).seconds / 60 + duration_mins = (time_end - start.eventTimestamp).total_seconds() / 60 return NightscoutEntry.activity( created_at=start.eventTimestamp.format(), reason=reason + " - " + NOT_ENDED if reason else NOT_ENDED, @@ -191,7 +191,7 @@ class ProcessUserMode: if stop.exercisestoppedbytimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal: reason += " (Stopped by timer)" - duration_mins = (stop.eventTimestamp - start.eventTimestamp).seconds / 60 + duration_mins = (stop.eventTimestamp - start.eventTimestamp).total_seconds() / 60 return NightscoutEntry.activity( created_at=start.eventTimestamp.format(), reason=reason, @@ -204,7 +204,7 @@ class ProcessUserMode: if start.exercisechoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed: reason = "Exercise (Timed)" - duration_mins = (time_end - start.eventTimestamp).seconds / 60 + duration_mins = (time_end - start.eventTimestamp).total_seconds() / 60 return NightscoutEntry.activity( created_at=start.eventTimestamp.format(), reason=reason + " - " + NOT_ENDED, @@ -220,7 +220,7 @@ class ProcessUserMode: else: self.nightscout.delete_entry('treatments/%s' % sleep_last_upload["_id"]) - duration_mins = (event.eventTimestamp - arrow.get(sleep_last_upload["created_at"])).seconds / 60 + duration_mins = (event.eventTimestamp - arrow.get(sleep_last_upload["created_at"])).total_seconds() / 60 return NightscoutEntry.activity( created_at=sleep_last_upload["created_at"], reason=sleep_last_upload["reason"].replace(" - %s" % NOT_ENDED, ""), @@ -240,7 +240,7 @@ class ProcessUserMode: if event.exercisestoppedbytimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal: reason += " (Stopped by timer)" - duration_mins = (event.eventTimestamp - arrow.get(exercise_last_upload["created_at"])).seconds / 60 + duration_mins = (event.eventTimestamp - arrow.get(exercise_last_upload["created_at"])).total_seconds() / 60 return NightscoutEntry.activity( created_at=exercise_last_upload["created_at"], reason=reason,