mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-24 03:34:12 -05:00
Report CGM out-of-range readings as LOW/HIGH sentinels
The bff pump-logs endpoint gives glucoseValueStatus + a raw display value; a below/above-range reading (e.g. status SpecialLow with displayValue 38) is a boundary indicator, not a measurement. Mirror the Tandem Source frontend (CgmBuilder.determineGlucoseValue): map SpecialLow/precise<40 -> 39 and SpecialHigh/precise>400 -> 401. Each sensor (G7/G6/FSL2/FSL3) is resolved against its own glucoseValueStatus enum members rather than assuming the enums are consistent across sensor types. Also widen the real-JSON CGM tests to span glucose 38..361 (incl. the LOW sentinel) and add a ProcessTimeRange basal JSON integration test.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__':
|
||||
|
||||
Reference in New Issue
Block a user