Compare commits

..
9 Commits
23 changed files with 1071 additions and 124 deletions
+1
View File
@@ -8,3 +8,4 @@ build
*.egg-info
.env
tconnectsync-check-output.log
ignore_*
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata]
name = tconnectsync
version = 0.7.2
version = 0.8.0
author = James Woglom
author_email = j@wogloms.net
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
+1
View File
@@ -105,6 +105,7 @@ class ControlIQApi:
"""
Returns detailed basal event information and reasons for delivery suspension.
End-date inclusive: Returns data from 00:00 on start date to 23:59 on end date.
"""
def therapy_timeline(self, start=None, end=None):
startDate = parse_date(start)
+5
View File
@@ -71,6 +71,9 @@ class WS2Api:
Basal data does NOT appear for the specified time range if using Control-IQ.
The ControlIQ API endpoints must be used for basal data instead.
However, all other fields are still accessed via this endpoint.
This has its own built-in retry logic because Tandem's frontend serving
the API returns 500s when its backend times out.
"""
def therapy_timeline_csv(self, start=None, end=None, tries=0):
startDate = parse_date(start)
@@ -124,6 +127,8 @@ class WS2Api:
- "manual"
- "previous"
- "alarm"
End-date inclusive: Returns data from 00:00 on start date to 23:59 on end date.
{"BasalSuspension":[{"EventDateTime":"/Date(EPOCH_MS-0000)/", "SuspendReason": "reason"}]}
"""
View File
+25
View File
@@ -0,0 +1,25 @@
from dataclasses import dataclass, asdict
@dataclass
class Bolus:
description: str
complete: str # "1" / "0"
completion: str
request_time: str # _datetime_parse timestamp
completion_time: str # _datetime_parse timestamp
insulin: str
requested_insulin: str
carbs: str
bg: str # potentially ""
user_override: str
extended_bolus: str # "1" / "0"
bolex_completion_time: str
bolex_start_time: str
def to_dict(self):
return asdict(self)
@property
def is_extended_bolus(self):
return self.extended_bolus == "1"
+367
View File
@@ -0,0 +1,367 @@
import arrow
from tconnectsync.domain.bolus import Bolus
from ..secret import TIMEZONE_NAME
def _datetime_parse(date):
# consistent format with ws2 endpoint
return arrow.get(date, tzinfo=TIMEZONE_NAME).format("YYYY-MM-DD HH:mm:ssZZ")
class TherapyEvent:
type = None
eventDateTime = None
sourceRecId = None
def parse(self, json):
self.type = json['type']
self.eventDateTime = json['eventDateTime']
self.sourceRecId = json['sourceRecId']
self.rawJson = json
class CGMTherapyEvent(TherapyEvent):
eventID = None
egv = None
"""
{
"eventDateTime": "2022-07-21T00:00:08",
"eventID": 256,
"requestDateTime": "0001-01-01T00:00:00",
"type": "CGM",
"description": "EGV",
"sourceRecId": 0,
"eventTypeId": 0,
"deviceType": "t:slim X2 Insulin Pump",
"serialNumber": "xxx",
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0,
"egv": {
"estimatedGlucoseValue": 174,
"hypo": 0,
"belowTarget": 0,
"withinTarget": 1,
"aboveTarget": 0,
"hyper": 0
}
},
"""
@classmethod
def parse(self, json):
TherapyEvent.parse(self, json)
self.eventID = json['eventID']
self.egv = json['egv']['estimatedGlucoseValue']
return self
class BolusTherapyEvent(TherapyEvent):
bolusRequestOptions = None
REQUEST_AUTOMATIC = "Automatic Bolus/Correction"
REQUEST_STANDARD = "Standard"
bolusType = None
TYPE_AUTOMATIC = "Automatic Correction"
TYPE_CARB = "Carb"
carbSize = None
correctionBolusSize = None
foodBolusSize = None
insulinDelivered = None
insulinRequested = None
completionDateTime = None
completionStatus = None
STATUS_COMPLETED = "Completed"
eventHistoryReportDetails = None
standardPercent = None
sourceRecId = None
@classmethod
def parse(_, json):
self = BolusTherapyEvent()
TherapyEvent.parse(self, json)
self.description = json.get("description")
self.complete = json.get("standard", {}).get("bolusIsComplete")
self.completion = json.get("standard", {}).get("completionStatusDesc")
self.request_time = json.get("requestDateTime")
self.completion_time = json.get("standard", {}).get("insulinDelivered", {}).get("completionDateTime")
# TODO: separate extended vs standard bolus into separate fields
self.insulin = json.get("standard", {}).get("insulinDelivered", {}).get("value")
self.requested_insulin = json.get("standard", {}).get("insulinRequested")
self.carbs = json.get("carbSize")
self.bg = json.get("bg")
self.user_override = json.get("userOverride")
self.extended_bolus = json.get("bolusRequestOptions") == "Extended"
if self.extended_bolus and self.complete:
# TODO(https://github.com/jwoglom/tconnectsync/issues/19): read more extended bolus info
self.complete = json.get("bolex", {}).get("extendedBolusIsComplete")
self.completion = json.get("bolex", {}).get("completionStatusDesc")
self.bolex_completion_time = json.get("bolex", {}).get("insulinDelivered", {}).get("completionDateTime")
self.bolex_start_time = json.get("bolex", {}).get("bolexStartDateTime")
else:
self.bolex_completion_time = ""
self.bolex_start_time = ""
return self
def to_bolus(self):
return Bolus(
description=self.description,
complete="1" if self.complete else "0",
completion=self.completion or "",
request_time=_datetime_parse(self.request_time),
completion_time=_datetime_parse(self.completion_time),
insulin=str(self.insulin),
requested_insulin=str(self.requested_insulin),
carbs=str(self.carbs or "0"), # Nightscout expects non-empty carbs
bg=str(self.bg or ""),
user_override=str(self.user_override),
extended_bolus="1" if self.extended_bolus else "0",
bolex_completion_time=_datetime_parse(self.bolex_completion_time) if self.bolex_completion_time else "",
bolex_start_time=_datetime_parse(self.bolex_start_time) if self.bolex_start_time else ""
)
"""
Correction:
{
"actualTotalBolusRequested": 2.9,
"bg": 254,
"bolusRequestOptions": "Automatic Bolus/Correction",
"bolusType": "Automatic Correction",
"carbSize": 0,
"correctionBolusSize": 2.9,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T11:53:08",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:0 - Target BG 110",
"eventHistoryReportEventDesc": "Correction Bolus",
"foodBolusSize": 0,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "572946",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-07-21T11:53:08",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T11:55:24",
"value": 2.9
},
"foodDelivered": 0,
"correctionDelivered": 2.9,
"insulinRequested": 2.9,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3361,
"bolusCompletionId": 3361
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Automatic Bolus/Correction",
"sourceRecId": 1171791787,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
},
Standard:
{
"actualTotalBolusRequested": 4.17,
"bolusRequestOptions": "Standard",
"bolusType": "Carb",
"carbSize": 25,
"correctionBolusSize": 0,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T12:27:36",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
"eventHistoryReportEventDesc": "Food Bolus",
"foodBolusSize": 4.17,
"iob": 2.62,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "573042",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-07-21T12:27:36",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T12:29:21",
"value": 4.17
},
"foodDelivered": 4.17,
"correctionDelivered": 0,
"insulinRequested": 4.17,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3362,
"bolusCompletionId": 3362
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Standard",
"sourceRecId": 1171853319,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
},
Extended bolus incomplete:
{
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"iob": 0,
"completionStatusId": 0,
"extendedBolusIsComplete": 0,
"insulinRequested": 0,
"bolexCompletionId": 0
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
Extended bolus (complete):
{
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:35:03",
"value": 0.2
},
"iob": 5.7,
"completionStatusId": 3.0,
"completionStatusDesc": "Completed",
"extendedBolusIsComplete": 1,
"insulinRequested": 0.2,
"bolexCompletionId": 16757133
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
"""
+22 -12
View File
@@ -1,19 +1,26 @@
import sys
import datetime
import requests
import hashlib
import time
import urllib.parse
import arrow
from urllib.parse import urljoin
from .api.common import ApiException
from .parser.nightscout import ENTERED_BY
# try:
# from .secret import NS_URL, NS_SECRET
# except Exception:
# print('Unable to import Nightscout secrets from secret.py')
# sys.exit(1)
def format_datetime(date):
return arrow.get(date).strftime('%Y-%m-%d %H:%M:%S')
def time_range(field_name, start_time, end_time):
arg = ''
if start_time:
arg += '&find[%s][$gte]=%s' % (field_name, format_datetime(start_time))
if end_time:
arg += '&find[%s][$lte]=%s' % (field_name, format_datetime(end_time))
return arg
class NightscoutApi:
def __init__(self, url, secret):
@@ -48,8 +55,9 @@ class NightscoutApi:
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout put response: %s" % r.text)
def last_uploaded_entry(self, eventType):
latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + '&ts=' + str(time.time())), headers={
def last_uploaded_entry(self, eventType, time_start=None, time_end=None):
dateFilter = time_range('created_at', time_start, time_end)
latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
@@ -60,8 +68,9 @@ class NightscoutApi:
return j[0]
return None
def last_uploaded_bg_entry(self):
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + '&ts=' + str(time.time())), headers={
def last_uploaded_bg_entry(self, time_start=None, time_end=None):
dateFilter = time_range('dateString', time_start, time_end)
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
@@ -72,8 +81,9 @@ class NightscoutApi:
return j[0]
return None
def last_uploaded_activity(self, activityType):
latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + '&ts=' + str(time.time())), headers={
def last_uploaded_activity(self, activityType, time_start=None, time_end=None):
dateFilter = time_range('created_at', time_start, time_end)
latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
+19
View File
@@ -0,0 +1,19 @@
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent
from tconnectsync.parser.tconnect import TConnectEntry
import logging
logger = logging.getLogger(__name__)
def split_therapy_events(ciqTherapyEvents):
bolusEvents = []
cgmEvents = []
for e in ciqTherapyEvents['event']:
event = TConnectEntry.parse_therapy_event(e)
if type(event) == BolusTherapyEvent:
bolusEvents.append(event)
elif type(event) == CGMTherapyEvent:
cgmEvents.append(event)
logger.debug("split_therapy_events: %d bolus, %d CGM" % (len(bolusEvents), len(cgmEvents)))
return bolusEvents, cgmEvents
+1 -1
View File
@@ -48,7 +48,7 @@ class NightscoutEntry:
}
if bg:
if bg_type not in (NightscoutEntry.SENSOR, NightscoutEntry.FINGER):
raise InvalidBolusTypeException
raise InvalidBolusTypeException("bg_type: %s (%s)" % (bg_type, data))
data.update({
"glucose": str(bg),
+20 -4
View File
@@ -1,6 +1,9 @@
from os import stat
import sys
import arrow
from tconnectsync.domain.bolus import Bolus
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent
try:
from ..secret import TIMEZONE_NAME
@@ -105,7 +108,7 @@ class TConnectEntry:
complete = is_complete(data["ExtendedBolusIsComplete"]) or is_complete(data["BolusIsComplete"])
extended_bolus = ("extended" in data["Description"].lower())
return {
return Bolus(**{
"description": data["Description"],
"complete": "1" if complete else "",
"completion": data["CompletionStatusDesc"] if not extended_bolus else data["BolexCompletionStatusDesc"],
@@ -120,7 +123,7 @@ class TConnectEntry:
# Note: completion time can be empty if the extended bolus is in progress
"bolex_completion_time": TConnectEntry._datetime_parse(data["BolexCompletionDateTime"]).format() if data["BolexCompletionDateTime"] and complete and extended_bolus else None,
"bolex_start_time": TConnectEntry._datetime_parse(data["BolexStartDateTime"]).format() if data["BolexStartDateTime"] and complete and extended_bolus else None,
}
})
@staticmethod
def parse_reading_entry(data):
@@ -189,7 +192,16 @@ class TConnectEntry:
"time": time.format(),
"event_type": TConnectEntry.BASALSUSPENSION_EVENTS[data["SuspendReason"]]
}
# Parses an entry from controliq.therapy_events() and returns a TherapyEvent
@staticmethod
def parse_therapy_event(data):
if data["type"] == "Bolus":
return BolusTherapyEvent.parse(data)
elif data["type"] == "CGM":
return CGMTherapyEvent.parse(data)
raise UnknownTherapyEventException(data)
class UnknownCIQActivityEventException(Exception):
def __init__(self, data):
@@ -197,4 +209,8 @@ class UnknownCIQActivityEventException(Exception):
class UnknownBasalSuspensionEventException(Exception):
def __init__(self, data):
super().__init__("Unknown basal suspension event type: %s" % data)
super().__init__("Unknown basal suspension event type: %s" % data)
class UnknownTherapyEventException(Exception):
def __init__(self, data):
super().__init__("Unknown therapy event type: " % data)
+90 -28
View File
@@ -3,6 +3,8 @@ import datetime
import arrow
import time
from tconnectsync.parser.ciq_therapy_events import split_therapy_events
from .util import timeago
from .api.common import ApiException
from .sync.basal import (
@@ -29,6 +31,7 @@ from .sync.pump_events import (
)
from .parser.tconnect import TConnectEntry
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS
from tconnectsync.sync import basal
logger = logging.getLogger(__name__)
@@ -50,33 +53,78 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
ciqTherapyTimelineData = None
else:
raise e
csvReadingData = None
csvIobData = None
csvBasalData = None
csvBolusData = None
logger.info("Downloading t:connect CSV data")
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
ciqBolusData = None
ciqReadingData = None
if BOLUS in features:
logger.info("Downloading t:connect therapy_events")
ciqTherapyEventsData = tconnect.controliq.therapy_events(time_start, time_end)
ciqBolusData, ciqReadingData = split_therapy_events(ciqTherapyEventsData)
readingData = csvdata["readingData"]
iobData = csvdata["iobData"]
csvBasalData = csvdata["basalData"]
bolusData = csvdata["bolusData"]
if ciqReadingData and len(ciqReadingData) > 0:
lastReading = ciqReadingData[-1].eventDateTime
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(ciqReadingData[-1])
logger.info("Last CGM reading from t:connect CIQ: %s (%s)" % (lastReading, timeago(lastReading)))
else:
logger.warning("No last CGM reading is able to be determined from CIQ")
if ciqBolusData and len(ciqBolusData) > 0:
lastBolus = ciqBolusData[-1].eventDateTime
lastReading = TConnectEntry._datetime_parse(lastBolus)
logger.debug(ciqBolusData[-1].to_bolus())
logger.info("Last bolus from t:connect CIQ: %s (%s)" % (lastBolus, timeago(lastBolus)))
bolusFallingBack = (BOLUS in features and not ciqBolusData)
ciqFallingBack = (CGM in features and not ciqReadingData)
if bolusFallingBack or \
ciqFallingBack or \
BOLUS_BG in features or \
IOB in features:
logger.warn("Downloading t:connect CSV data")
logger.warn("<!!> This data source is unreliable and may prevent timely synchronization")
if bolusFallingBack:
logger.warn("Falling back on WS2 data source because BOLUS is an enabled feature and CIQ bolus data was empty!!")
if ciqFallingBack:
logger.warn("Falling back on WS2 data source because CGM is an enabled feature and CIQ cgm data was empty!!")
if BOLUS_BG in features:
logger.warn("<!!> Falling back on WS2 data source because BOLUS_BG is an enabled feature. " +
"Please consider disabling this feature to improve synchronization reliability.")
if IOB in features:
logger.warn("<!!> Falling back on WS2 data source because IOB is an enabled feature. " +
"Please consider disabling this feature to improve synchronization reliability.")
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
csvReadingData = csvdata["readingData"]
csvIobData = csvdata["iobData"]
csvBasalData = csvdata["basalData"]
csvBolusData = csvdata["bolusData"]
if csvReadingData and len(csvReadingData) > 0:
lastReading = csvReadingData[-1]['EventDateTime'] if 'EventDateTime' in csvReadingData[-1] else 0
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(csvReadingData[-1])
logger.info("Last CGM reading from t:connect CSV: %s (%s)" % (lastReading, timeago(lastReading)))
else:
logger.warning("No last CGM reading is able to be determined from CSV")
if readingData and len(readingData) > 0:
lastReading = readingData[-1]['EventDateTime'] if 'EventDateTime' in readingData[-1] else 0
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(readingData[-1])
logger.info("Last CGM reading from t:connect: %s (%s)" % (lastReading, timeago(lastReading)))
else:
logger.warning("No last CGM reading is able to be determined")
added = 0
cgmData = None
if CGM in features or BOLUS_BG in features:
logger.debug("Processing CGM events")
cgmData = process_cgm_events(readingData)
if CGM in features:
logger.debug("Writing CGM events")
added += ns_write_cgm_events(nightscout, cgmData, pretend)
if csvReadingData:
cgmData = None
if CGM in features or BOLUS_BG in features:
logger.debug("Processing CGM events")
cgmData = process_cgm_events(csvReadingData)
if CGM in features:
logger.debug("Writing CGM events")
added += ns_write_cgm_events(nightscout, cgmData, pretend, time_start=time_start, time_end=time_end)
if BASAL in features:
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
@@ -85,8 +133,11 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
add_csv_basal_events(basalEvents, csvBasalData)
else:
logger.debug("No CSV basal data found")
if basalEvents and len(basalEvents) > 0:
logger.info("Last basal event from CIQ: %s" % basalEvents[-1])
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend)
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend, time_start=time_start, time_end=time_end)
if PUMP_EVENTS in features:
pumpEvents = process_ciq_activity_events(ciqTherapyTimelineData)
@@ -99,15 +150,26 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
pumpEvents += bsPumpEvents
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend)
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
if BOLUS in features:
bolusEvents = process_bolus_events(bolusData)
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features))
bolusEvents = []
if ciqBolusData:
logger.info("Processing ciqBolusData (%d entries)" % len(ciqBolusData))
bolusEvents = process_bolus_events(ciqBolusData, source="ciq")
if IOB in features:
iobEvents = process_iob_events(iobData)
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
if csvBolusData and not bolusEvents:
logger.warn("Falling back on non-CIQ csvBolusData")
bolusEvents = process_bolus_events(csvBolusData, source="csv")
logger.debug("ciq bolusEvents: %s" % bolusEvents)
logger.info("finalized bolusEvents: %s" % bolusEvents)
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features), time_start=time_start, time_end=time_end)
if csvIobData:
if IOB in features:
iobEvents = process_iob_events(csvIobData)
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
logger.info("Wrote %d events to Nightscout this process cycle" % added)
return added
+3 -3
View File
@@ -104,9 +104,9 @@ def add_csv_basal_events(basalEvents, data):
"""
Given processed basal data, adds basal events to Nightscout.
"""
def ns_write_basal_events(nightscout, basalEvents, pretend=False):
def ns_write_basal_events(nightscout, basalEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_basal_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BASAL_EVENTTYPE)
last_upload = nightscout.last_uploaded_entry(BASAL_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
@@ -121,7 +121,7 @@ def ns_write_basal_events(nightscout, basalEvents, pretend=False):
for event in basalEvents:
if last_upload_time and arrow.get(event["time"]) < last_upload_time:
if pretend:
logger.info("Skipping basal event before last upload time: %s" % event)
logger.info("Skipping basal event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
recent_needs_update = False
+30 -23
View File
@@ -1,5 +1,6 @@
import arrow
import logging
from tconnectsync.domain.bolus import Bolus
from tconnectsync.sync.cgm import find_event_at
@@ -15,26 +16,32 @@ logger = logging.getLogger(__name__)
"""
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_bolus_events(bolusdata, cgmEvents=None):
def process_bolus_events(bolusdata, cgmEvents=None, source=""):
bolusEvents = []
for b in bolusdata:
parsed = TConnectEntry.parse_bolus_entry(b)
if parsed["completion"] != "Completed":
if parsed["insulin"] and float(parsed["insulin"]) > 0:
parsed = None
if source == "ciq":
parsed = b.to_bolus()
else:
parsed = TConnectEntry.parse_bolus_entry(b)
assert type(parsed) == Bolus
if parsed.completion != "Completed":
if parsed.insulin and float(parsed.insulin) > 0:
# Count non-completed bolus if any insulin was delivered (vs. the amount of insulin requested)
parsed["description"] += " (%s: requested %s units)" % (parsed["completion"], parsed["requested_insulin"])
parsed.description += " (%s: requested %s units)" % (parsed.completion, parsed.requested_insulin)
else:
logger.warning("Skipping non-completed bolus data (was a bolus in progress?): %s parsed: %s" % (b, parsed))
logger.warning("Skipping non-completed %s bolus data (was a bolus in progress?): %s parsed: %s" % (source, b, parsed))
continue
if parsed["bg"] and cgmEvents:
requested_at = parsed["request_time"] if not parsed["extended_bolus"] else parsed["bolex_start_time"]
parsed["bg_type"] = guess_bolus_bg_type(parsed["bg"], requested_at, cgmEvents)
if parsed.bg and cgmEvents:
requested_at = parsed.request_time if not parsed.extended_bolus else parsed.bolex_start_time
parsed.bg_type = guess_bolus_bg_type(parsed.bg, requested_at, cgmEvents)
bolusEvents.append(parsed)
bolusEvents.sort(key=lambda event: arrow.get(event["request_time"] if not event["extended_bolus"] else event["bolex_start_time"]))
bolusEvents.sort(key=lambda event: arrow.get(event.request_time if not event.is_extended_bolus else event.bolex_start_time))
return bolusEvents
@@ -57,9 +64,9 @@ def guess_bolus_bg_type(bg, created_at, cgmEvents):
"""
Given processed bolus data, adds bolus events to Nightscout.
"""
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=False, reading_events=None):
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=False, reading_events=None, time_start=None, time_end=None):
logger.debug("ns_write_bolus_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE)
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
@@ -72,27 +79,27 @@ def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=Fal
add_count = 0
for event in bolusEvents:
created_at = event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"]
created_at = event.completion_time if not event.is_extended_bolus else event.bolex_start_time
if last_upload_time and arrow.get(created_at) <= last_upload_time:
if pretend:
logger.info("Skipping basal event before last upload time: %s" % event)
logger.info("Skipping basal event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
if include_bg and event["bg"]:
if include_bg and event.bg:
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
bolus=event.insulin,
carbs=event.carbs,
created_at=created_at,
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else ""),
bg=event["bg"],
bg_type=event["bg_type"]
notes="{}{}{}".format(event.description, " (Override)" if event.user_override == "1" else "", " (Extended)" if event.extended_bolus == "1" else ""),
bg=event.bg,
bg_type=event.bg_type
)
else:
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
bolus=event.insulin,
carbs=event.carbs,
created_at=created_at,
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else "")
notes="{}{}{}".format(event.description, " (Override)" if event.user_override == "1" else "", " (Extended)" if event.extended_bolus == "1" else "")
)
add_count += 1
+3 -3
View File
@@ -39,9 +39,9 @@ def find_event_at(cgmEvents, find_time):
"""
Given processed CGM data, adds reading entries to Nightscout.
"""
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False):
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_cgm_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_bg_entry()
last_upload = nightscout.last_uploaded_bg_entry(time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["dateString"])
@@ -52,7 +52,7 @@ def ns_write_cgm_events(nightscout, cgmEvents, pretend=False):
created_at = event["time"]
if last_upload_time and arrow.get(created_at) <= last_upload_time:
if pretend:
logger.info("Skipping CGM event before last upload time: %s" % event)
logger.info("Skipping CGM event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
entry = NightscoutEntry.entry(
+2 -2
View File
@@ -24,9 +24,9 @@ def process_iob_events(iobdata):
"""
Given processed IOB data, creates a single Nightscout activity definition to store IOB.
"""
def ns_write_iob_events(nightscout, iobEvents, pretend=False):
def ns_write_iob_events(nightscout, iobEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_iob_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_activity(IOB_ACTIVITYTYPE)
last_upload = nightscout.last_uploaded_activity(IOB_ACTIVITYTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
+34 -22
View File
@@ -49,7 +49,7 @@ def process_basalsuspension_events(data):
"""
Given processed pump event data (of various types), write them to Nightscout
"""
def ns_write_pump_events(nightscout, pumpEvents, pretend=False):
def ns_write_pump_events(nightscout, pumpEvents, pretend=False, time_start=None, time_end=None):
count = 0
siteChangeEvents = []
@@ -80,17 +80,17 @@ def ns_write_pump_events(nightscout, pumpEvents, pretend=False):
logger.debug("sleepEvents: %s" % sleepEvents)
logger.debug("activityEvents: %s" % activityEvents)
count += ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=pretend)
count += ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=pretend)
count += ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=pretend)
count += ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=pretend)
count += ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=pretend)
count += ns_write_activity_events(nightscout, activityEvents, pretend=pretend)
count += ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_activity_events(nightscout, activityEvents, pretend=pretend, time_start=time_start, time_end=time_end)
return count
def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False):
def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
siteChangeEvents,
@@ -99,9 +99,11 @@ def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False)
reason=event["event_type"]
),
SITECHANGE_EVENTTYPE,
pretend=pretend)
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False):
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
emptyCartEvents,
@@ -110,9 +112,11 @@ def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False):
reason=event["event_type"]
),
BASALSUSPENSION_EVENTTYPE,
pretend=pretend)
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False):
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
userSuspendedEvents,
@@ -121,9 +125,11 @@ def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=Fals
reason=event["event_type"]
),
BASALSUSPENSION_EVENTTYPE,
pretend=pretend)
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False):
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
exerciseEvents,
@@ -134,9 +140,11 @@ def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False)
event_type=EXERCISE_EVENTTYPE
),
EXERCISE_EVENTTYPE,
pretend=pretend)
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False):
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
sleepEvents,
@@ -147,9 +155,11 @@ def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False):
event_type=SLEEP_EVENTTYPE
),
SLEEP_EVENTTYPE,
pretend=pretend)
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_activity_events(nightscout, activityEvents, pretend=False):
def ns_write_activity_events(nightscout, activityEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
activityEvents,
@@ -159,15 +169,17 @@ def ns_write_activity_events(nightscout, activityEvents, pretend=False):
duration=event["duration_mins"]
),
ACTIVITY_EVENTTYPE,
pretend=pretend)
pretend=pretend,
time_start=time_start,
time_end=time_end)
def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, pretend=False):
def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, pretend=False, time_start=None, time_end=None):
if len(events) == 0:
logger.debug("No %s events to process" % eventType)
return 0
logger.debug("ns_write_pump_events: querying for last %s" % eventType)
last_upload = nightscout.last_uploaded_entry(eventType)
last_upload = nightscout.last_uploaded_entry(eventType, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
@@ -192,7 +204,7 @@ def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, prete
if skip:
if pretend:
logger.info("Skipping %s event before last upload time: %s" % (eventType, event))
logger.info("Skipping %s pump event before last upload time: %s (time range: %s - %s)" % (eventType, event, time_start, time_end))
continue
entry = buildNsEventFunc(event)
View File
+367
View File
@@ -0,0 +1,367 @@
import dataclasses
import unittest
from tconnectsync.domain.bolus import Bolus
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent
class TestCGMTherapyEvent(unittest.TestCase):
maxDiff = None
sampleJson = {
"eventDateTime": "2022-07-21T00:00:08",
"eventID": 256,
"requestDateTime": "0001-01-01T00:00:00",
"type": "CGM",
"description": "EGV",
"sourceRecId": 0,
"eventTypeId": 0,
"deviceType": "t:slim X2 Insulin Pump",
"serialNumber": "xxx",
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0,
"egv": {
"estimatedGlucoseValue": 174,
"hypo": 0,
"belowTarget": 0,
"withinTarget": 1,
"aboveTarget": 0,
"hyper": 0
}
}
def test_parse_cgm(self):
e = CGMTherapyEvent.parse(self.sampleJson)
self.assertEqual(e.type, "CGM")
self.assertEqual(e.eventDateTime, "2022-07-21T00:00:08")
self.assertEqual(e.sourceRecId, 0)
self.assertEqual(e.eventID, 256)
self.assertEqual(e.egv, 174)
class TestBolusTherapyEvent(unittest.TestCase):
maxDiff = None
standardJson = {
"actualTotalBolusRequested": 4.17,
"bolusRequestOptions": "Standard",
"bolusType": "Carb",
"carbSize": 25,
"correctionBolusSize": 0,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T12:27:36",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
"eventHistoryReportEventDesc": "Food Bolus",
"foodBolusSize": 4.17,
"iob": 2.62,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "573042",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-07-21T12:27:36",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T12:29:21",
"value": 4.17
},
"foodDelivered": 4.17,
"correctionDelivered": 0,
"insulinRequested": 4.17,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3362,
"bolusCompletionId": 3362
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Standard",
"sourceRecId": 1171853319,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_standard_to_bolus(self):
e = BolusTherapyEvent.parse(self.standardJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Standard",
complete="1",
completion="Completed",
request_time="2022-07-21 12:27:36-04:00",
completion_time="2022-07-21 12:29:21-04:00",
insulin="4.17",
requested_insulin="4.17",
carbs="25",
bg="",
user_override="0",
extended_bolus="0",
bolex_completion_time="",
bolex_start_time=""
)))
correctionJson = {
"actualTotalBolusRequested": 2.9,
"bg": 254,
"bolusRequestOptions": "Automatic Bolus/Correction",
"bolusType": "Automatic Correction",
"carbSize": 0,
"correctionBolusSize": 2.9,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T11:53:08",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:0 - Target BG 110",
"eventHistoryReportEventDesc": "Correction Bolus",
"foodBolusSize": 0,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "572946",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-07-21T11:53:08",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T11:55:24",
"value": 2.9
},
"foodDelivered": 0,
"correctionDelivered": 2.9,
"insulinRequested": 2.9,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3361,
"bolusCompletionId": 3361
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Automatic Bolus/Correction",
"sourceRecId": 1171791787,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_correction_to_bolus(self):
e = BolusTherapyEvent.parse(self.correctionJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Automatic Bolus/Correction",
complete="1",
completion="Completed",
request_time="2022-07-21 11:53:08-04:00",
completion_time="2022-07-21 11:55:24-04:00",
insulin="2.9",
requested_insulin="2.9",
carbs="0",
bg="254",
user_override="0",
extended_bolus="0",
bolex_completion_time="",
bolex_start_time=""
)))
extendedBolusIncompleteJson = {
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"iob": 0,
"completionStatusId": 0,
"extendedBolusIsComplete": 0,
"insulinRequested": 0,
"bolexCompletionId": 0
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_extended_bolus_incomplete_to_bolus(self):
e = BolusTherapyEvent.parse(self.extendedBolusIncompleteJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Extended 50.00%/0.00",
complete="0",
completion="",
request_time="2022-08-09 23:19:15-04:00",
completion_time="2022-08-09 23:20:04-04:00",
insulin="0.2",
requested_insulin="0.2",
carbs="0",
bg="131",
user_override="1",
extended_bolus="1",
bolex_completion_time="",
bolex_start_time="2022-08-09 23:20:04-04:00"
)))
extendedBolusJson = {
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:35:03",
"value": 0.2
},
"iob": 5.7,
"completionStatusId": 3.0,
"completionStatusDesc": "Completed",
"extendedBolusIsComplete": 1,
"insulinRequested": 0.2,
"bolexCompletionId": 16757133
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_extended_bolus_complete_to_bolus(self):
e = BolusTherapyEvent.parse(self.extendedBolusJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Extended 50.00%/0.00",
complete="1",
completion="Completed",
request_time="2022-08-09 23:19:15-04:00",
completion_time="2022-08-09 23:20:04-04:00",
insulin="0.2",
requested_insulin="0.2",
carbs="0",
bg="131",
user_override="1",
extended_bolus="1",
bolex_completion_time="2022-08-09 23:35:03-04:00",
bolex_start_time="2022-08-09 23:20:04-04:00"
)))
BOLUS_FULL_EXAMPLES = [
TestBolusTherapyEvent.standardJson,
TestBolusTherapyEvent.correctionJson,
TestBolusTherapyEvent.extendedBolusJson
]
+1 -1
View File
@@ -20,7 +20,7 @@ class NightscoutApi(tconnectsync.nightscout.NightscoutApi):
def put_entry(self, ns_format, entity):
self.put_entries[entity].append(ns_format)
def last_uploaded_entry(self, eventType):
def last_uploaded_entry(self, eventType, start_date=None, end_date=None):
raise NotImplementedError
def last_uploaded_activity(self, activityType):
+11 -10
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.domain.bolus import Bolus
from tconnectsync.parser.tconnect import TConnectEntry, UnknownBasalSuspensionEventException, UnknownCIQActivityEventException
class TestTConnectEntryBasal(unittest.TestCase):
@@ -177,7 +178,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std_correction(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdCorrection),
{
Bolus(**{
"description": "Standard/Correction",
"complete": "1",
"completion": "Completed",
@@ -191,7 +192,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
entryStd = {
"Type": "Bolus",
@@ -239,7 +240,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStd),
{
Bolus(**{
"description": "Standard",
"complete": "1",
"completion": "Completed",
@@ -253,7 +254,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
entryStdAutomatic = {
"Type": "Bolus",
@@ -301,7 +302,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std_automatic(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdAutomatic),
{
Bolus(**{
"description": "Automatic Bolus/Correction",
"complete": "1",
"completion": "Completed",
@@ -315,7 +316,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
entryStdIncompleteZero = {
"Type": "Bolus",
@@ -363,7 +364,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std_incomplete_zero(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdIncompleteZero),
{
Bolus(**{
"description": "Standard",
"complete": "",
"completion": "User Aborted",
@@ -377,7 +378,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
entryStdIncompletePartial = {
"Type": "Bolus",
@@ -425,7 +426,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std_incomplete_partial(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdIncompletePartial),
{
Bolus(**{
"description": "Standard/Correction",
"complete": "",
"completion": "Terminated by Alarm",
@@ -439,7 +440,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
class TestTConnectEntryReading(unittest.TestCase):
entry1 = {
+3 -3
View File
@@ -52,7 +52,7 @@ class TestBolusSync(unittest.TestCase):
self.assertEqual(len(bolusEvents), len(bolusData))
def set_bg_type(entry, type):
entry["bg_type"] = type
entry.bg_type = type
return entry
# Expect FINGER for bolus entries with a BG because there's no matching event with the same BG
@@ -111,7 +111,7 @@ class TestBolusSync(unittest.TestCase):
self.assertEqual(len(bolusEvents), len(bolusData))
def set_bg_type(entry, type):
entry["bg_type"] = type
entry.bg_type = type
return entry
expected = [
@@ -145,7 +145,7 @@ class TestBolusSync(unittest.TestCase):
]
for e in partialEntries:
e["description"] += " (%s: requested %s units)" % (e["completion"], e["requested_insulin"])
e.description += " (%s: requested %s units)" % (e.completion, e.requested_insulin)
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(d) for d in stdData
+65 -11
View File
@@ -8,6 +8,7 @@ import copy
from tconnectsync.process import process_time_range
from tconnectsync.parser.nightscout import EXERCISE_EVENTTYPE, IOB_ACTIVITYTYPE, SLEEP_EVENTTYPE, NightscoutEntry
from tconnectsync.features import BASAL, BOLUS, IOB, PUMP_EVENTS
from tests.domain.test_therapy_event import BOLUS_FULL_EXAMPLES, TestCGMTherapyEvent
from .api.fake import TConnectApi
from .nightscout_fake import NightscoutApi
@@ -20,6 +21,11 @@ class TestProcessTimeRange(unittest.TestCase):
def stub_therapy_timeline(self, time_start, time_end):
return copy.deepcopy(TestBasalSync.base)
def stub_ciq_therapy_events(self, time_start, time_end):
return {
"event": []
}
def stub_therapy_timeline_csv(self, time_start, time_end):
return {
@@ -32,10 +38,10 @@ class TestProcessTimeRange(unittest.TestCase):
def stub_ws2_basalsuspension(self, time_start, time_end):
return {"BasalSuspension": []}
def stub_last_uploaded_entry(self, event_type):
def stub_last_uploaded_entry(self, event_type, **kwargs):
return None
def stub_last_uploaded_activity(self, activity_type):
def stub_last_uploaded_activity(self, activity_type, **kwargs):
return None
"""No data in Nightscout. Uploads all basal data from tconnect."""
@@ -53,6 +59,7 @@ class TestProcessTimeRange(unittest.TestCase):
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
@@ -88,6 +95,7 @@ class TestProcessTimeRange(unittest.TestCase):
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
@@ -116,11 +124,12 @@ class TestProcessTimeRange(unittest.TestCase):
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type):
def fake_last_uploaded_entry(event_type, **kwargs):
if event_type == "Temp Basal":
return {
"created_at": "2021-03-16 00:20:21-04:00",
@@ -159,11 +168,12 @@ class TestProcessTimeRange(unittest.TestCase):
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type):
def fake_last_uploaded_entry(event_type, **kwargs):
if event_type == "Temp Basal":
return {
"created_at": "2021-03-16 00:20:21-04:00",
@@ -193,8 +203,8 @@ class TestProcessTimeRange(unittest.TestCase):
})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Uploads all bolus data from tconnect."""
def test_new_ciq_bolus_data(self):
"""No data in Nightscout. Uploads all bolus data from tconnect via the WS2 API."""
def test_new_ciq_bolus_data_from_ws2(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
@@ -202,6 +212,7 @@ class TestProcessTimeRange(unittest.TestCase):
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
bolusData = TestBolusSync.get_example_csv_bolus_events()
def fake_therapy_timeline_csv(time_start, time_end):
@@ -231,6 +242,46 @@ class TestProcessTimeRange(unittest.TestCase):
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Uploads all bolus data from tconnect via CIQ therapy_events."""
def test_new_ciq_bolus_data_from_ciq_therapy_events(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2022, 8, 9, 12, 0)
end = datetime.datetime(2021, 8, 10, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
def fake_ciq_therapy_events(time_start, time_end):
return {
"event": BOLUS_FULL_EXAMPLES
}
tconnect.controliq.therapy_events = fake_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), len(BOLUS_FULL_EXAMPLES))
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
# BGs are excluded because BG is not enabled in features for this test
NightscoutEntry.bolus(2.9, 0, "2022-07-21 11:55:24-04:00", notes="Automatic Bolus/Correction"),
NightscoutEntry.bolus(4.17, 25, "2022-07-21 12:29:21-04:00", notes="Standard"),
# NOTE: the extended bolus is 0.2+0.2 but we currently only surface standard
# BUG: inconsistency: we use the completion timestamp as the event timestamp for standard boluses,
# but the standard strand time for extended
NightscoutEntry.bolus(0.2, 0, "2022-08-09 23:20:04-04:00", notes="Extended 50.00%/0.00 (Override) (Extended)"),
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Nothing should be updated in Nightscout without the BOLUS feature."""
def test_bolus_data_not_updated_without_feature(self):
tconnect = TConnectApi()
@@ -271,6 +322,7 @@ class TestProcessTimeRange(unittest.TestCase):
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
iobData = TestIOBSync.get_example_csv_iob_events()
def fake_therapy_timeline_csv(time_start, time_end):
@@ -307,6 +359,7 @@ class TestProcessTimeRange(unittest.TestCase):
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
iobData = TestIOBSync.get_example_csv_iob_events()
def fake_therapy_timeline_csv(time_start, time_end):
@@ -354,7 +407,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
def fake_last_uploaded_activity(activityType):
def fake_last_uploaded_activity(activityType, **kwargs):
if activityType == IOB_ACTIVITYTYPE:
return iobData[0]
return self.stub_last_uploaded_activity(activityType)
@@ -454,6 +507,7 @@ class TestProcessTimeRange(unittest.TestCase):
}
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
@@ -506,7 +560,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type):
def fake_last_uploaded_entry(event_type, **kwargs):
if event_type == "Sleep":
return {
"created_at": "2021-05-02 14:46:40-04:00",
@@ -519,7 +573,7 @@ class TestProcessTimeRange(unittest.TestCase):
"duration": 20,
"_id": "exercise"
}
return self.stub_last_uploaded_entry()
return self.stub_last_uploaded_entry(**kwargs)
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
@@ -628,7 +682,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type):
def fake_last_uploaded_entry(event_type, **kwargs):
if event_type == "Site Change":
return {
"created_at": "2021-12-04 16:18:10-05:00"
@@ -637,7 +691,7 @@ class TestProcessTimeRange(unittest.TestCase):
return {
"created_at": "2021-12-04 16:07:32-05:00"
}
return self.stub_last_uploaded_entry()
return self.stub_last_uploaded_entry(**kwargs)
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity