diff --git a/tconnectsync/sync/tandemsource/process_cgm_reading.py b/tconnectsync/sync/tandemsource/process_cgm_reading.py index e3520e1..6552f10 100644 --- a/tconnectsync/sync/tandemsource/process_cgm_reading.py +++ b/tconnectsync/sync/tandemsource/process_cgm_reading.py @@ -5,17 +5,69 @@ from ...features import DEFAULT_FEATURES from ... import features from ... import secret from ...eventparser.raw_event import TANDEM_EPOCH +from ...eventparser import events as eventtypes from ...parser.nightscout import NightscoutEntry -from typing import Iterable, List, Optional, TYPE_CHECKING +from typing import Iterable, List, Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from ...api import TConnectApi from ...nightscout import NightscoutApi from ...eventparser.raw_event import BaseEvent +# The four CGM-reading event types share the glucoseValueStatus / +# currentGlucoseDisplayValue fields determine_glucose_value() reads. +CgmReadingEvent = Union[ + eventtypes.LidCgmDataG7, + eventtypes.LidCgmDataGxb, + eventtypes.LidCgmDataFsl2, + eventtypes.LidCgmDataFsl3, +] logger = logging.getLogger(__name__) +# Mirrors the Tandem Source frontend (CgmBuilder.determineGlucoseValue): out-of-range +# and special readings are reported as sentinel values rather than the raw display value. +GLUCOSE_LIMIT_LOW = 40 +GLUCOSE_LIMIT_HIGH = 400 +GLUCOSE_VALUE_LOW = 39 +GLUCOSE_VALUE_HIGH = 401 + +def _resolve_glucose_value(display_value, status, precise, high, low): + if status == high: + return GLUCOSE_VALUE_HIGH + if status == low: + return GLUCOSE_VALUE_LOW + if status == precise: + if display_value < GLUCOSE_LIMIT_LOW: + return GLUCOSE_VALUE_LOW + if display_value > GLUCOSE_LIMIT_HIGH: + return GLUCOSE_VALUE_HIGH + return display_value + +# Each sensor is handled separately: the glucoseValueStatus enums are NOT assumed +# to be consistent across sensor types (e.g. G6 names its members differently), so +# every branch resolves against that sensor's own enum members. +def determine_glucose_value(event: CgmReadingEvent) -> int: + display_value = event.currentglucosedisplayvalue + status = event.glucosevaluestatus + + if isinstance(event, eventtypes.LidCgmDataG7): + e = eventtypes.LidCgmDataG7.GlucosevaluestatusEnum + return _resolve_glucose_value(display_value, status, e.PreciseValue, e.SpecialHigh, e.SpecialLow) + if isinstance(event, eventtypes.LidCgmDataGxb): + e = eventtypes.LidCgmDataGxb.GlucosevaluestatusEnum + return _resolve_glucose_value(display_value, status, + e.CurrentglucosedisplayvalueContainsTheGlucoseReading, + e.TheGlucoseReadingIsHigh, e.TheGlucoseReadingIsLow) + if isinstance(event, eventtypes.LidCgmDataFsl3): + e = eventtypes.LidCgmDataFsl3.GlucosevaluestatusEnum + return _resolve_glucose_value(display_value, status, e.PreciseValue, e.SpecialHigh, e.SpecialLow) + if isinstance(event, eventtypes.LidCgmDataFsl2): + e = eventtypes.LidCgmDataFsl2.GlucosevaluestatusEnum + return _resolve_glucose_value(display_value, status, e.PreciseValue, e.SpecialHigh, e.SpecialLow) + + return display_value + class ProcessCGMReading: def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES, timezone: Optional[str] = None) -> None: self.tconnect = tconnect @@ -72,7 +124,7 @@ class ProcessCGMReading: def to_nsentry(self, event: "BaseEvent") -> Optional[dict]: return NightscoutEntry.entry( - sgv = event.currentglucosedisplayvalue, + sgv = determine_glucose_value(event), created_at = self.timestamp_for(event).format(), pump_event_id = "%s" % event.seqNum, ) diff --git a/tests/sync/tandemsource/test_process.py b/tests/sync/tandemsource/test_process.py index c8aa096..afa30be 100644 --- a/tests/sync/tandemsource/test_process.py +++ b/tests/sync/tandemsource/test_process.py @@ -5,7 +5,7 @@ import arrow from tconnectsync.sync.tandemsource.process import ProcessTimeRange from tconnectsync.eventparser import events as eventtypes -from tconnectsync.eventparser.generic import Event +from tconnectsync.eventparser.generic import Event, Events from ...api.fake import TConnectApi from ...nightscout_fake import NightscoutApi @@ -146,5 +146,77 @@ class TestProcessTimeRangeBasalDuration(unittest.TestCase): self.assertEqual(basal_2['duration'], 5.0) +BASAL_JSON_1 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 279, "sequenceGroup": 0, "sequenceNumber": 417004, "pumpDateTime": "2026-05-07T00:01:11", "eventProperties": {"commandedRateSource": 3, "reservedA2": 3, "spareA3": 0, "commandedRate": 1000, "profileBasalRate": 1000, "algorithmRate": 1000, "tempRate": 65535}, "estimatedDateTime": "2026-05-07T00:01:11Z"} +BASAL_JSON_2 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 279, "sequenceGroup": 0, "sequenceNumber": 417029, "pumpDateTime": "2026-05-07T00:06:12", "eventProperties": {"commandedRateSource": 3, "reservedA2": 0, "spareA3": 0, "commandedRate": 1000, "profileBasalRate": 1000, "algorithmRate": 1000, "tempRate": 65535}, "estimatedDateTime": "2026-05-07T00:06:12Z"} + + +class TestProcessTimeRangeJsonBasal(unittest.TestCase): + """Basal delivery events processed via real pump-logs JSON (production path).""" + maxDiff = None + + def setUp(self): + self.tconnect = TConnectApi() + self.tconnect._tandemsource = FakeTandemSourceApi() + self.nightscout = NightscoutApi() + self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None + + self.secret = build_secrets( + FETCH_ALL_EVENT_TYPES=False + ) + + self.tconnectDevice = { + 'deviceId': 'test', + 'maxDateWithEvents': '2026-05-07T00:20:00-04:00' + } + + self.process = ProcessTimeRange( + self.tconnect, + self.nightscout, + self.tconnectDevice, + pretend=False, + secret=self.secret + ) + + self.tconnect._tandemsource.events = list(Events([dict(BASAL_JSON_1), dict(BASAL_JSON_2)])) + + def test_basal_events_via_json(self): + time_start = arrow.get('2026-05-07T00:00:00-04:00') + time_end = arrow.get('2026-05-07T00:11:12-04:00') + + count, last_seqnum = self.process.process(time_start, time_end) + + self.assertEqual(count, 2) + self.assertEqual(last_seqnum, 417029) + + treatments = self.nightscout.uploaded_entries['treatments'] + self.assertEqual(len(treatments), 2) + + self.assertDictEqual(treatments[0], { + "eventType": "Temp Basal", + "reason": "Algorithm", + "duration": 5.016666666666667, + "absolute": 1.0, + "rate": 1.0, + "created_at": "2026-05-07 00:01:11-04:00", + "carbs": None, + "insulin": None, + "enteredBy": "Pump (tconnectsync)", + "pump_event_id": "417004" + }) + + self.assertDictEqual(treatments[1], { + "eventType": "Temp Basal", + "reason": "Algorithm", + "duration": None, + "absolute": 1.0, + "rate": 1.0, + "created_at": "2026-05-07 00:06:12-04:00", + "carbs": None, + "insulin": None, + "enteredBy": "Pump (tconnectsync)", + "pump_event_id": "417029" + }) + + if __name__ == '__main__': unittest.main() diff --git a/tests/sync/tandemsource/test_process_cgm_reading.py b/tests/sync/tandemsource/test_process_cgm_reading.py index bf04231..a561cc1 100644 --- a/tests/sync/tandemsource/test_process_cgm_reading.py +++ b/tests/sync/tandemsource/test_process_cgm_reading.py @@ -422,9 +422,12 @@ class TestProcessCGMReadingFSL3(unittest.TestCase): # Real LID_CGM_DATA_G7 pump-logs JSON events captured from a live Mobi account # (deviceAssignmentId redacted). These exercise the production path # (Events -> ProcessCGMReading) rather than the binary decoder. -G7_JSON_1 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 484329, "pumpDateTime": "2026-05-26T00:04:12", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -3, "algorithmState": 32, "rssi": -82, "currentGlucoseDisplayValue": 347, "egvTimeStamp": 580608249, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-26T00:04:12Z"} -G7_JSON_2 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 484338, "pumpDateTime": "2026-05-26T00:09:12", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -4, "algorithmState": 32, "rssi": -84, "currentGlucoseDisplayValue": 345, "egvTimeStamp": 580608548, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-26T00:09:12Z"} -G7_JSON_3 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 484351, "pumpDateTime": "2026-05-26T00:14:12", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -4, "algorithmState": 32, "rssi": -78, "currentGlucoseDisplayValue": 341, "egvTimeStamp": 580608849, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-26T00:14:12Z"} +G7_JSON_1 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 416999, "pumpDateTime": "2026-05-07T00:01:04", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": 8, "algorithmState": 32, "rssi": -59, "currentGlucoseDisplayValue": 249, "egvTimeStamp": 578966461, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-07T00:01:04Z"} +G7_JSON_2 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 417212, "pumpDateTime": "2026-05-07T01:06:04", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -12, "algorithmState": 32, "rssi": -57, "currentGlucoseDisplayValue": 193, "egvTimeStamp": 578970361, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-07T01:06:04Z"} +G7_JSON_3 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 417300, "pumpDateTime": "2026-05-07T01:51:04", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -10, "algorithmState": 32, "rssi": -51, "currentGlucoseDisplayValue": 118, "egvTimeStamp": 578973061, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-07T01:51:04Z"} +G7_JSON_4 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 440616, "pumpDateTime": "2026-05-13T19:46:30", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": 5, "algorithmState": 32, "rssi": -67, "currentGlucoseDisplayValue": 321, "egvTimeStamp": 579555987, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-13T19:46:30Z"} +G7_JSON_5 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 450283, "pumpDateTime": "2026-05-16T15:34:52", "eventProperties": {"glucoseValueStatus": 2, "cgmDataType": [0], "rate": -5, "algorithmState": 32, "rssi": -52, "currentGlucoseDisplayValue": 38, "egvTimeStamp": 579800089, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-16T15:34:52Z"} +G7_JSON_6 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 483972, "pumpDateTime": "2026-05-25T22:14:11", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": 15, "algorithmState": 32, "rssi": -79, "currentGlucoseDisplayValue": 361, "egvTimeStamp": 580601648, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-25T22:14:11Z"} class TestProcessCGMReadingG7Json(unittest.TestCase): @@ -440,28 +443,48 @@ class TestProcessCGMReadingG7Json(unittest.TestCase): def test_single_g7_json_reading(self): events = list(Events([dict(G7_JSON_1)])) self.assertEqual(type(events[0]), eventtypes.LidCgmDataG7) - self.assertEqual(events[0].currentglucosedisplayvalue, 347) - self.assertEqual(events[0].egvTimestamp, 580608249) + self.assertEqual(events[0].egvTimestamp, 578966461) p = self.process.process(events, time_start=None, time_end=None) self.assertEqual(len(p), 1) - self.assertEqual(p[0]['sgv'], 347) - # created_at is derived from egvTimeStamp (3s before pumpDateTime here) - self.assertEqual(p[0]['dateString'], '2026-05-26T00:04:09-0400') - self.assertEqual(p[0]['pump_event_id'], '484329') + self.assertEqual(p[0]['sgv'], 249) + self.assertEqual(p[0]['dateString'], '2026-05-07T00:01:01-0400') + self.assertEqual(p[0]['pump_event_id'], '416999') - def test_multiple_g7_json_readings(self): - events = list(Events([dict(G7_JSON_1), dict(G7_JSON_2), dict(G7_JSON_3)])) + def test_diverse_glucose_range(self): + events = list(Events([ + dict(G7_JSON_1), dict(G7_JSON_2), dict(G7_JSON_3), + dict(G7_JSON_4), dict(G7_JSON_5), dict(G7_JSON_6) + ])) p = self.process.process(events, time_start=None, time_end=None) - self.assertEqual([e['sgv'] for e in p], [347, 345, 341]) - self.assertEqual([e['pump_event_id'] for e in p], ['484329', '484338', '484351']) + # The SpecialLow reading (raw 38) is reported as the LOW sentinel 39. + self.assertEqual([e['sgv'] for e in p], [249, 193, 118, 321, 39, 361]) + self.assertEqual([e['dateString'] for e in p], [ + '2026-05-07T00:01:01-0400', + '2026-05-07T01:06:01-0400', + '2026-05-07T01:51:01-0400', + '2026-05-13T19:46:27-0400', + '2026-05-16T15:34:49-0400', + '2026-05-25T22:14:08-0400', + ]) + self.assertEqual([e['pump_event_id'] for e in p], [ + '416999', '417212', '417300', '440616', '450283', '483972' + ]) + + def test_special_low_reading(self): + # glucoseValueStatus SpecialLow reports the LOW sentinel (39), not the + # raw below-range display value (38). + events = list(Events([dict(G7_JSON_5)])) + p = self.process.process(events, time_start=None, time_end=None) + self.assertEqual(len(p), 1) + self.assertEqual(p[0]['sgv'], 39) def test_skips_readings_at_or_before_last_upload(self): # Only readings strictly after the last Nightscout upload are returned. - self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: {'dateString': '2026-05-26T00:09:08-0400'} + self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: {'dateString': '2026-05-07T01:06:01-0400'} events = list(Events([dict(G7_JSON_1), dict(G7_JSON_2), dict(G7_JSON_3)])) p = self.process.process(events, time_start=None, time_end=None) - self.assertEqual([e['sgv'] for e in p], [341]) + self.assertEqual([e['sgv'] for e in p], [118]) if __name__ == '__main__':