use domain objects for webui scraping pump profiles

This commit is contained in:
James Woglom
2022-08-11 01:22:44 -04:00
parent 4da4d6e2a6
commit 6f15ef8857
5 changed files with 120 additions and 51 deletions
+43 -17
View File
@@ -1,3 +1,4 @@
from typing import List
import requests
import urllib
import datetime
@@ -7,6 +8,10 @@ import logging
from bs4 import BeautifulSoup
from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment
from tconnectsync.util import removesuffix, removeprefix
from tconnectsync.util.constants import MMOLL_TO_MGDL
from .common import base_headers, ApiException
logger = logging.getLogger(__name__)
@@ -92,12 +97,11 @@ class WebUIScraper:
settings_guid = settings_a.attrs['href'].split('?guid=')[1]
if serial_number:
devices[serial_number] = {
'name': device_name,
'model_number': model_number,
'status': status,
'guid': settings_guid
}
devices[serial_number] = Device(
name=device_name,
model_number=model_number,
status=status,
guid=settings_guid)
return devices
@@ -106,7 +110,7 @@ class WebUIScraper:
Note that pump_guid is NOT the serial number of the pump, and
should be obtained from my_devices()[str(serial_number)]['guid']
"""
def device_settings_from_guid(self, pump_guid):
def device_settings_from_guid(self, pump_guid: str) -> List[Profile]:
profiles = []
settings = {}
r = self.get('myaccount/DeviceSettings.aspx?guid=%s' % pump_guid)
@@ -124,12 +128,33 @@ class WebUIScraper:
return profiles, settings
def _parse_profile_tbl(self, tbl):
def _parse_profile_tbl(self, tbl) -> Profile:
profile = {}
profile["title"] = self.strip(tbl.select_one('.setting_title').text)
profile["active"] = bool(tbl.find(text='Active at the time of upload'))
profile["segments"] = []
def parse_basal_rate(rate) -> float:
return float(removesuffix(rate, ' u/hr'))
def parse_factor(ratio) -> int:
return parse_bg_mgdl(removeprefix(ratio, '1u:'))
def parse_ratio(ratio) -> float:
return float(removesuffix(removeprefix(ratio, '1u:'), ' g'))
def parse_bg_mgdl(bg) -> int:
if bg.endswith(' mg/dL'):
return float(removesuffix(bg, ' mg/dL'))
elif bg.endswith(' mmol/L'):
return float(removesuffix(bg, ' mmol/L')) * MMOLL_TO_MGDL
raise ValueError(bg)
def hours_to_mins(text) -> int:
hrmin = removesuffix(text, " hours")
hr, min = hrmin.split(":", 1)
return int(min) + int(hr)*60
for tr in tbl.select('tr'):
# Skip header rows
if tr.select_one('.setting_bg'):
@@ -149,19 +174,20 @@ class WebUIScraper:
t = "12:00 AM"
elif display_time == "Noon":
t = "12:00 PM"
segment = {
"display_time": display_time,
"time": t,
"basal_rate": self.strip(tds[1].text),
"correction_factor": self.strip(tds[2].text),
"carb_ratio": self.strip(tds[3].text),
"target_bg": self.strip(tds[4].text)
"basal_rate": parse_basal_rate(self.strip(tds[1].text)),
"correction_factor": parse_factor(self.strip(tds[2].text)),
"carb_ratio": parse_ratio(self.strip(tds[3].text)),
"target_bg_mgdl": parse_bg_mgdl(self.strip(tds[4].text))
}
profile["segments"].append(segment)
profile["segments"].append(ProfileSegment(**segment))
continue
if tr.find(text='Calculated Total Daily Basal'):
profile["calculated_total_daily_basal"] = self.strip(tds[1].text)
profile["calculated_total_daily_basal"] = float(removesuffix(self.strip(tds[1].text), " units"))
continue
# Last row
@@ -175,12 +201,12 @@ class WebUIScraper:
key = self.strip(key)
val = self.strip(val)
if key == 'Duration of Insulin':
profile["insulin_duration"] = val
profile["insulin_duration_min"] = hours_to_mins(val)
elif key == 'Carbohydrates':
profile["carbohydrates"] = val
profile["carbs_enabled"] = self.strip(val.lower()) == "on"
return profile
return Profile(**profile)
def _parse_settings_tbl(self, tbl):
outer_tr = tbl.select('tr')[2]
+27
View File
@@ -0,0 +1,27 @@
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Device:
name: str
model_number: str
status: str
guid: Optional[str]
@dataclass
class ProfileSegment:
display_time: str # Identical to time except written out as Midnight or Noon
time: str
basal_rate: float # _ u/hr
correction_factor: int # 1u: _ mg/dL
carb_ratio: float # 1u: _ g
target_bg_mgdl: int
@dataclass
class Profile:
title: str
active: bool
segments: List[ProfileSegment]
calculated_total_daily_basal: float # in units
insulin_duration_min: int
carbs_enabled: bool
+11
View File
@@ -15,3 +15,14 @@ def timeago(timestamp):
ret += '%d minutes' % (seconds//60)
return fmt % ret
# String methods only available in python 3.9+
def removesuffix(input_string, suffix):
if suffix and input_string.endswith(suffix):
return input_string[:-len(suffix)]
return input_string
def removeprefix(input_string, prefix):
if prefix and input_string.startswith(prefix):
return input_string[len(prefix):]
return input_string
+4
View File
@@ -0,0 +1,4 @@
# http://www.soc-bdr.org/rds/authors/unit_tables_conversions_and_genetic_dictionaries/conversion_glucose_mg_dl_to_mmol_l/index_en.html
MMOLL_TO_MGDL = 18.0182
MGDL_TO_MMOLL = 0.0555
+35 -34
View File
@@ -11,6 +11,7 @@ import requests_mock
from bs4 import BeautifulSoup
from tconnectsync.api.webui import WebUIScraper
from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment
from .fake import ControlIQApi
@@ -293,30 +294,30 @@ class TestWebUIScraper(unittest.TestCase):
devices = webui.my_devices()
self.assertDictEqual(devices, {
'100001': {
'100001': Device(**{
'name': 't:slim X2™ Insulin Pump',
'model_number': '001002717',
'status': 'Activated — Dec 30 2021',
'guid': '00000000-0000-0000-0000-000000000001'
},
'10000002': {
}),
'10000002': Device(**{
'name': 't:slim X2™ Insulin Pump',
'model_number': '001000354',
'status': 'Activated — Oct 26 2021',
'guid': '00000000-0000-0000-0000-000000000002'
},
'100003': {
}),
'100003': Device(**{
'name': 't:slim X2™ Insulin Pump',
'model_number': '001000096',
'status': 'Activated — Nov 20 2017',
'guid': '00000000-0000-0000-0000-000000000003'
},
'ABCDEFGH': {
}),
'ABCDEFGH': Device(**{
'name': 'OneTouch Verio IQ',
'model_number': 'VERIO IQ',
'status': 'Activated — Jan 17 2018',
'guid': None
}})
})})
PUMP_SETTINGS_HTML = """
<!DOCTYPE html
@@ -956,42 +957,42 @@ class TestWebUIScraper(unittest.TestCase):
text=self.PUMP_SETTINGS_HTML)
profiles, settings = webui.device_settings_from_guid('00000000-0000-0000-0000-000000000001')
self.assertListEqual(profiles, [{
self.assertListEqual(profiles, [Profile(**{
'title': 'A',
'active': True,
'segments': [{
'segments': [ProfileSegment(**{
'display_time': 'Midnight',
'time': '12:00 AM',
'basal_rate': '0.800 u/hr',
'correction_factor': '1u:30 mg/dL',
'carb_ratio': '1u:6.0 g',
'target_bg': '110 mg/dL'
}, {
'basal_rate': 0.800,
'correction_factor': 30.0,
'carb_ratio': 6.0,
'target_bg_mgdl': 110
}), ProfileSegment(**{
'display_time': '6:00 AM',
'time': '6:00 AM',
'basal_rate': '1.250 u/hr',
'correction_factor': '1u:30 mg/dL',
'carb_ratio': '1u:6.0 g',
'target_bg': '110 mg/dL'
}, {
'basal_rate': 1.250,
'correction_factor': 30,
'carb_ratio': 6.0,
'target_bg_mgdl': 110
}), ProfileSegment(**{
'display_time': '11:00 AM',
'time': '11:00 AM',
'basal_rate': '1.000 u/hr',
'correction_factor': '1u:30 mg/dL',
'carb_ratio': '1u:6.0 g',
'target_bg': '110 mg/dL'
}, {
'basal_rate': 1.000,
'correction_factor': 30,
'carb_ratio': 6.0,
'target_bg_mgdl': 110
}), ProfileSegment(**{
'display_time': 'Noon',
'time': '12:00 PM',
'basal_rate': '0.800 u/hr',
'correction_factor': '1u:30 mg/dL',
'carb_ratio': '1u:6.0 g',
'target_bg': '110 mg/dL'
}],
'calculated_total_daily_basal': '21.65 units',
'insulin_duration': '5:00 hours',
'carbohydrates': 'On'
}])
'basal_rate': 0.800,
'correction_factor': 30,
'carb_ratio': 6.0,
'target_bg_mgdl': 110
})],
'calculated_total_daily_basal': 21.65,
'insulin_duration_min': 5*60,
'carbs_enabled': True
})])
self.assertDictEqual(settings, {
'Alerts': {