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.
This commit is contained in:
James Woglom
2026-07-01 06:22:00 +00:00
parent 9d8ab283a0
commit ef87d18469
5 changed files with 55 additions and 30 deletions
+3
View File
@@ -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=}")
@@ -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
+32 -16
View File
@@ -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.setdefault(event.bolusid, {})[type(event)] = event
bolusEventsForId[event.bolusid][type(event)] = event
if type(event) == eventtypes.LidBolusCompleted:
# 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 bolusCompletedEvent not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
logger.info("Skipping bolus completion not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
completions.append(event)
bolusCompletedEvents.append(event)
bolusCompletedEvents.sort(key=lambda e: e.eventTimestamp)
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
)
@@ -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
)
@@ -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,