import json
import os
from pathlib import Path
from urllib.parse import parse_qs
import pandas as pd
import requests
from IPython.display import Markdown, display
from jupyterhealth_client import Code, JupyterHealthClient
# Convenience function to display markdown
def md(s):
display(Markdown(s))
CGM = Code.BLOOD_GLUCOSE
pd.options.mode.chained_assignment = None
jh_client = JupyterHealthClient()
# Use "if not VOILA:" blocks to print debug/private output
# Otherwise all output is by default shown by VOILA
VOILA = os.environ.get("VOILA_REQUEST_URL")
query_string = os.environ.get('QUERY_STRING', '')
url_parameters = parse_qs(query_string)
# pick patient id, study id
def _fmt_fhir_name(res: dict):
name = res['name'][0]
given = " ".join(name['given'])
return f"{given} {name['family']}" #, id: {res['id']}"
if VOILA and "smart" in url_parameters:
# if this is a smart app launch (?smart=1 in URL), get patient from SMART token
# for demo purposes, assume the patient is only in one study
# study_id=None will get all CGM data for the patient, which doesn't change anything if they are in just one study
study_id = None
# read the SMART credentials and get the FHIR patient from the EHR
token_file = Path(os.environ["SMART_TOKEN_FILE"])
with token_file.open() as f:
token_info = json.load(f)
smart_config = token_info["smart_config"]
fhir_url = token_info["fhir_url"]
token_response = token_info["token"]
fhir_token = token_response["access_token"]
fhir = requests.Session()
fhir.headers = {"Authorization": f"Bearer {fhir_token}"}
r = fhir.get(fhir_url + token_response["profile"]["reference"])
if not r.ok:
print(r.text)
r.raise_for_status()
practitioner = r.json()
r = fhir.get(fhir_url + f"Patient/{token_response['patient']}")
if not r.ok:
print(r.text)
r.raise_for_status()
fhir_patient = r.json()
birth_date = fhir_patient['birthDate']
# now we have the FHIR patient record, lookup the corresponding JHE user
# that is the patient in JHE whose `External ID` is the fhir patient's uuid
patient_name = _fmt_fhir_name(fhir_patient)
patient = jh_client.get_patient_by_external_id(fhir_patient["id"])
patient_id = patient["id"]
md(f"""
**Practitioner**: {_fmt_fhir_name(practitioner)}
**Patient (via SMART)**: {patient_name}, DOB: {fhir_patient['birthDate']}, JupyterHealth id: {patient_id}, FHIR id: {fhir_patient['id']}
""")
else:
# not running via smart, hardcode a user to test with
fhir_patient = None
study_id = 30014
patient_id = 40077
practitioner = jh_client.get_user()
patient = jh_client.get_patient(patient_id)
birth_date = patient['birthDate']
patient_name = f"{patient["nameGiven"]} {patient["nameFamily"]}"
md(f"""
**Practitioner**: User/{practitioner["id"]} {practitioner["firstName"]} {practitioner["lastName"]} {practitioner["email"]}
**Patient (not via SMART)**: {patient_name}, DOB: {patient['birthDate']}, JupyterHealth Patient/{patient_id}
""")
df = jh_client.list_observations_df(
patient_id=patient_id, study_id=study_id, limit=10_000, code=CGM,
)
if not VOILA:
display(df)
import re
import sys
import traceback
from io import BytesIO
import ipywidgets as widgets
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from IPython.display import clear_output, display
from matplotlib.patches import Patch
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 1) LOAD DATA & select COLUMNS
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
dexcom_data = df
ts_col = "effective_time_frame_date_time"
glu_col = "blood_glucose_value"
# --- keep only what we need -------------------------------------
dexcom_data = dexcom_data[[ts_col, glu_col]].dropna()
dexcom_data = dexcom_data.rename(columns={ts_col: "Timestamp",
glu_col: "Glucose"})
# --- dtype conversions ------------------------------------------
dexcom_data["Timestamp"] = pd.to_datetime(dexcom_data["Timestamp"])
dexcom_data["Day"] = dexcom_data["Timestamp"].dt.day_name()
dexcom_data["Glucose"] = pd.to_numeric(
dexcom_data["Glucose"].astype(str).str.replace(",", ""),
errors="coerce"
)
dexcom_data = dexcom_data.dropna(subset=["Glucose"])
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 2) CONSTANTS & WEEKβLEVEL BARS
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
THRESHOLDS = {
"Very High (>250 mg/dL)": (250, 1_000),
"High (180β250 mg/dL)" : (180, 250),
"Target (70β180 mg/dL)" : (70, 180),
"Low (54β70 mg/dL)" : (54, 70),
"Very Low (<54 mg/dL)" : (0, 54),
}
CATEGORIES = list(THRESHOLDS.keys())[::-1]
COLORS = ["darkred", "red", "green", "orange", "darkorange"]
# -- weekβlevel %
total_week = len(dexcom_data)
week_pct = [
((dexcom_data["Glucose"].between(lo, hi - 1)).sum() / total_week) * 100
for _, (lo, hi) in THRESHOLDS.items()
][::-1]
fig_week, axw = plt.subplots(figsize=(6, 5))
axw.barh(CATEGORIES, week_pct, color=COLORS)
for y, p in zip(CATEGORIES, week_pct):
axw.text(p + 1, CATEGORIES.index(y), f"{p:.1f}%", va="center",
fontsize=11, fontweight="bold")
axw.set_xlabel("Percent of Time")
axw.set_title("WholeβWeek TimeβinβRange")
axw.set_xlim(0, 100)
axw.grid(axis="x", ls="--", alpha=0.5)
plt.tight_layout()
unique_dates = sorted(dexcom_data["Timestamp"].dt.date.unique())
date_labels = [f"{date} ({date.strftime('%A')})" for date in unique_dates]
# Dropdown options: only specific dates
selector_options = [(label, label) for label in date_labels]
# Dropdown widget
day_selector = widgets.Dropdown(
options = selector_options,
value = date_labels[0],
description = "Select:",
style = {"description_width": "initial"},
layout = widgets.Layout(width="300px")
)
out_day = widgets.Output(layout={"border":"1px solid #ccc", "padding":"4px"})
out_week = widgets.Output(layout={"border":"1px solid #ccc", "padding":"4px"})
with out_week:
display(fig_week); plt.close(fig_week)
def update_day_plot(change=None):
with out_day:
clear_output(wait=True)
d = re.search(r"^\d{4}-\d{2}-\d{2}", day_selector.value).group(0)
df = dexcom_data[dexcom_data["Timestamp"].dt.date.astype(str) == d]
if df.empty:
print(f"No data for {d}."); return
total = len(df)
df.loc[:, "Glucose"] = pd.to_numeric(df["Glucose"], errors="coerce").astype("Int64")
pct = [((df["Glucose"].between(lo, hi - 1)).sum()/total)*100
for _, (lo, hi) in THRESHOLDS.items()][::-1]
fig, ax = plt.subplots(figsize=(6, 5))
ax.barh(CATEGORIES, pct, color=COLORS)
for y, p in zip(CATEGORIES, pct):
ax.text(p + 1, CATEGORIES.index(y), f"{p:.1f}%", va="center",
fontsize=11, fontweight="bold")
ax.set_xlabel("Percent of Time")
ax.set_title(f"{d}: TimeβinβRange")
ax.set_xlim(0, 100)
ax.grid(axis="x", ls="--", alpha=0.5)
plt.tight_layout(); display(fig); plt.close(fig)
update_day_plot()
day_selector.observe(update_day_plot, names="value")
out_profile = widgets.Output(
layout={"border": "1px solid #ccc", "padding": "4px", "min_width": "70%"}
)
def update_agp_profile(change=None):
"""Redraw the hourly AGP line chart for the currentlyβselected day."""
with out_profile:
clear_output(wait=True)
try:
day = re.search(r"^\d{4}-\d{2}-\d{2}", day_selector.value).group(0)
df = dexcom_data[dexcom_data["Timestamp"].dt.date.astype(str) == day]
# day = re.search(r"\((\w+)\)", day_selector.value).group(1)
# df = dexcom_data[dexcom_data["Day"] == day]
if df.empty:
print(f"No data for {day}.")
return
# 5th / 25th / 50th / 75th / 95th percentiles per hour
percentiles = (
df.groupby("Hour")["Glucose"]
.quantile([0.05, 0.25, 0.50, 0.75, 0.95])
.unstack()
.rolling(window=2, center=True, min_periods=1)
.mean()
)
median = percentiles[0.50]
sns.set_theme(style="darkgrid")
fig, ax = plt.subplots(figsize=(12, 5), dpi=110)
# split the median series for colourβcoding
normal = median.where(median.between(70, 180))
above = median.where(median > 180)
below = median.where(median < 70)
ax.plot(normal.index, normal, color="green", lw=2, label="Median 70β180")
ax.plot(above.index, above, color="orange", lw=2, label="Median >180")
ax.plot(below.index, below, color="red", lw=2, label="Median <70")
ax.axhline(70, color="red", ls="--", label="70 mg/dL")
ax.axhline(180, color="green", ls="--", label="180 mg/dL")
ax.set_xlabel("Hour of Day")
ax.set_ylabel("Glucose (mg/dL)")
ax.set_ylim(0, 400)
ax.set_title(f"Glucose Profile βΒ {day}")
ax.legend(framealpha=0.9)
plt.tight_layout()
# ββ convert to PNG so Voila & notebook both show it ββ
buf = BytesIO()
fig.savefig(buf, format="png")
buf.seek(0)
img = widgets.Image(value=buf.read(), format="png")
display(img)
plt.close(fig)
except Exception:
import sys
import traceback
traceback.print_exception(*sys.exc_info())
dexcom_data['Hour'] = dexcom_data['Timestamp'].dt.hour + dexcom_data['Timestamp'].dt.minute / 60 # Hour
update_agp_profile(dexcom_data)
day_selector.observe(update_agp_profile, names="value")
# ---------------------------------------------------------------
# GRI output box (keep your existing out_gri definition)
# ---------------------------------------------------------------
out_gri = widgets.Output(layout={"border": "1px solid #ccc",
"padding": "4px", "min_width": "30%"})
# ---------------------------------------------------------------
# NEW callback βΒ wholeβdataset GRI (no day filtering)
# ---------------------------------------------------------------
def update_gri(change=None):
with out_gri:
clear_output(wait=True)
try:
# use the full DataFrame, not dayβfiltered
if dexcom_data.empty:
display(widgets.HTML("<b>No CGM data available.</b>"))
return
hypo_pct = (dexcom_data["Glucose"].astype(int) < 70).mean() * 100
hyper_pct = (dexcom_data["Glucose"].astype(int) > 180).mean() * 100
gri = 3 * hypo_pct + 1.6 * hyper_pct # simplified formula
# --------- draw zoned grid -------------------------
fig, ax = plt.subplots(figsize=(6, 5), dpi=110)
x = np.linspace(0, 40, 400)
y = np.linspace(0, 60, 400)
X, Y = np.meshgrid(x, y)
zone = np.zeros_like(X)
bounds = [(0,20),(20,40),(40,60),(60,80),(80,100)]
for i, (lo, hi) in enumerate(bounds):
mask = (3*X + 1.6*Y >= lo) & (3*X + 1.6*Y < hi)
zone[mask] = i+1
zone_cols = ['#d2fbd4','#f0fcb2','#fff4a3','#ffd0a1','#ff9d9d']
zone_labs = ['ZoneΒ A','ZoneΒ B','ZoneΒ C','ZoneΒ D','ZoneΒ E']
ax.contourf(X, Y, zone,
levels=[.5,1.5,2.5,3.5,4.5,5.5],
colors=zone_cols, alpha=.55)
# --------- patient point ---------------------------
ax.scatter(hypo_pct, hyper_pct, s=120, c="black",
edgecolors="white", zorder=10)
ax.text(hypo_pct + 0.8, hyper_pct,
f"GRI = {gri:.1f}", fontsize=10, weight="bold")
# --------- labels & legend -------------------------
start = dexcom_data["Timestamp"].min().date()
end = dexcom_data["Timestamp"].max().date()
ax.set(xlim=(0,40), ylim=(0,60),
xlabel="HypoglycemiaΒ ComponentΒ (%)",
ylabel="HyperglycemiaΒ ComponentΒ (%)",
title=f"GRI Grid βΒ {start}Β toΒ {end}")
ax.grid(True, ls="--", alpha=.4)
legend_patches = [Patch(color=zone_cols[i], label=zone_labs[i])
for i in range(5)]
ax.legend(handles=legend_patches, title="RiskΒ Zones",
loc="upper right")
# --------- render as PNG for Notebook & Voila ------
buf = BytesIO()
fig.tight_layout()
fig.savefig(buf, format="png")
buf.seek(0)
display(widgets.Image(value=buf.read(), format="png"))
plt.close(fig)
except Exception:
traceback.print_exception(*sys.exc_info())
# ---------------------------------------------------------------
# initial render + (optional) linkage
# ---------------------------------------------------------------
update_gri() # draw once
# You can still keep the observe so the panel refreshes when the user
# changes day, but it will always reβcompute on the *whole* dataset.
day_selector.observe(update_gri, names="value")
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# METRIC TABLE ADDβON
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# choose whether metrics are dayβspecific or whole dataset
DAY_SPECIFIC = False # β True = use selected day only
metrics_box = widgets.Output(layout={"border":"1px solid #ccc",
"padding":"4px", "min_width":"100%"})
def update_metrics(change=None):
with metrics_box:
clear_output(wait=True)
# Slice data: whole dataset or specific day
data = dexcom_data
if DAY_SPECIFIC:
data = data[data["Day"] == day_selector.value]
if data.empty:
display(widgets.HTML("<b>No CGM data available.</b>"))
return
# ----- calculations -------------------------------------------
# make sure glucose is numeric β coerce nonβnumbers to NaN
gluc = pd.to_numeric(data["Glucose"], errors="coerce")
first = data["Timestamp"].min().date()
last = data["Timestamp"].max().date()
period = (last - first).days + 1
expected_points = period * 288 # 5βmin readings/day
time_active_pct = len(gluc.dropna()) / expected_points * 100
mean_gluc = gluc.mean()
gmi = 3.31 + 0.02392 * mean_gluc # Arroyo formula
cv_pct = gluc.std() / mean_gluc * 100
# ----- HTML table (simple) ------------------------------
html = f"""
<style>
.metric-table {{font-family:Arial, sans-serif; border-collapse:collapse; width:100%;}}
.metric-table th {{background:#f0f0f0; text-align:left; padding:4px; font-size:14px}}
.metric-table td {{padding:4px 6px; font-size:14px}}
.metric-title {{font-weight:bold; font-size:15px}}
.metric-value {{text-align:right; font-weight:bold;}}
.shade {{background:#efefef}}
</style>
<table class="metric-table">
<tr><th colspan="2" class="metric-title">{patient_name}
<span style='float:right'>Period: {first}Β βΒ {last}</span></th></tr>
<tr><td colspan="2"><b>TimeΒ CGMΒ Active:</b> {time_active_pct:0.1f}%</td></tr>
<tr><th colspan="2" class="metric-title">GlucoseΒ Metrics</th></tr>
<tr>
<td>AverageΒ Glucose<br><span style="font-size:12px">GoalΒ <154Β mg/dL</span></td>
<td class="metric-value">{mean_gluc:0.0f} mg/dL</td>
</tr>
<tr class="shade">
<td>GlucoseΒ ManagementΒ IndicatorΒ (GMI)<br>
<span style="font-size:12px">GoalΒ <7%</span></td>
<td class="metric-value">{gmi:0.1f}%</td>
</tr>
<tr>
<td>GlucoseΒ Variability<br>
<span style="font-size:12px">GoalΒ β€36%</span></td>
<td class="metric-value">{cv_pct:0.1f}%</td>
</tr>
</table>
"""
display(widgets.HTML(html))
# first draw & hook
update_metrics()
day_selector.observe(update_metrics, names="value")
def add_glucose_zones(ax):
"""Shade standard Dexcom zones on an existing Axes."""
ax.axhspan(250, 400, color="#D7191C", alpha=0.09) # very high
ax.axhspan(180, 250, color="#FDAE61", alpha=0.09) # high
ax.axhspan(70, 180, color="#A6D96A", alpha=0.09) # target
ax.axhspan(54, 70, color="#ABD9E9", alpha=0.09) # low
ax.axhspan(0, 54, color="#2C7BB6", alpha=0.09) # very low
dexcom_data = dexcom_data.dropna(subset=["Glucose"])
dexcom_data["Hour"] = dexcom_data["Timestamp"].dt.hour + dexcom_data["Timestamp"].dt.minute / 60
dexcom_data["Date"] = dexcom_data["Timestamp"].dt.date
# Output container
out_subplots = widgets.Output(layout={"border": "1px solid #ccc", "padding": "4px", "min_width": "100%"})
import matplotlib.pyplot as plt
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
subplots_image = widgets.Image(format='png')
out_subplots = widgets.VBox([subplots_image],
layout={"border": "1px solid #ccc", "padding": "4px", "min_width": "100%"})
def draw_daily_subplots():
try:
cols = 4
dates = sorted(dexcom_data["Date"].unique())[:14]
rows = (len(dates) + cols - 1) // cols
fig, axes = plt.subplots(rows, cols, figsize=(cols * 5 / 2, rows * 3), sharey=True)
axes = axes.flatten()
grouped = dexcom_data.groupby("Date")
for i, date in enumerate(dates):
ax = axes[i]
day_data = grouped.get_group(date).sort_values("Hour")
add_glucose_zones(ax)
smoothed = day_data["Glucose"].rolling(window=5, center=True, min_periods=1).median()
ax.plot(day_data["Hour"], smoothed, color="green", label="Median Glucose")
weekday = date.strftime("%A")
ax.set_xlim(0, 24)
ax.set_xticks([12])
ax.set_xticklabels(["12 PM"])
ax.set_ylim(0, 300)
ax.set_yticks([54, 70, 180, 250, 400])
ax.set_yticklabels(["", "70", "180", "250", "400"])
ax.set_title(f"{weekday}\n{date}")
ax.axvline(x=12, color="gray", linestyle="--", linewidth=1, alpha=0.6)
ax.grid(True)
if i == 0:
ax.legend(fontsize=8, loc="upper right", frameon=False)
for j in range(len(dates), len(axes)):
axes[j].axis("off")
fig.suptitle("Daily CGM Profiles with Glucose Zones", fontsize=16, y=0.94)
plt.tight_layout()
plt.subplots_adjust(top=0.75, hspace=0.75, wspace=0)
# Render to buffer
buf = BytesIO()
canvas = FigureCanvas(fig)
canvas.print_png(buf)
buf.seek(0)
subplots_image.value = buf.read()
plt.close(fig)
except Exception as e:
subplots_image.value = f"Error rendering subplots: {str(e)}".encode()
draw_daily_subplots()
# # Make sure Timestamp is datetime and Hour exists
df = dexcom_data.copy()
df["Timestamp"] = pd.to_datetime(df["Timestamp"])
df["minute_of_day"] = df["Timestamp"].dt.hour * 60 + df["Timestamp"].dt.minute
df["time_bin"] = (df["minute_of_day"] // 5) * 5
df["Hour"] = df["minute_of_day"] / 60 # If not already present
# Group by 5-minute bins and calculate quantiles
agp_summary = df.groupby("time_bin")["Glucose"].quantile([0.05, 0.25, 0.5, 0.75, 0.95]).unstack()
agp_summary.columns = ["p5", "p25", "p50", "p75", "p95"]
agp_summary = agp_summary.reset_index()
agp_summary["hour"] = agp_summary["time_bin"] / 60
# Prepare the image widget container
agp_image = widgets.Image(format='png')
out_agp = widgets.VBox([agp_image], layout={"border": "1px solid #ccc", "padding": "4px", "min_width": "100%"})
def update_agp_graph(change=None):
try:
fig, ax = plt.subplots(figsize=(12, 6))
add_glucose_zones(ax)
ax.fill_between(agp_summary["hour"], agp_summary["p5"], agp_summary["p95"],
color="#D8E3E7", alpha=0.5, label="5β95% Range")
ax.fill_between(agp_summary["hour"], agp_summary["p25"], agp_summary["p75"],
color="#B0C4DE", alpha=0.6, label="25β75% Range")
ax.plot(agp_summary["hour"], agp_summary["p50"], color="green", linewidth=2, label="Median")
ax.set_title("Ambulatory Glucose Profile (AGP)")
ax.set_xlabel("Time of Day")
ax.set_ylabel("Glucose (mg/dL)")
ax.set_xticks([0, 6, 12, 18, 24])
ax.set_xticklabels(["12 AM", "6 AM", "12 PM", "6 PM", "12 AM"])
ax.set_xlim(0, 24)
ax.set_ylim(0, 300)
ax.set_yticks([54, 70, 180, 250, 350])
ax.grid(True)
ax.legend(loc="upper right", fontsize=10, frameon=True, framealpha=0.9)
plt.tight_layout()
buf = BytesIO()
canvas = FigureCanvas(fig)
canvas.print_png(buf)
buf.seek(0)
agp_image.value = buf.read()
plt.close(fig)
except Exception as e:
agp_image.value = f"Error rendering AGP: {str(e)}".encode()
# Run the plot initially
update_agp_graph()
title = widgets.HTML("<h2 style='margin:0;color:#2c3e50'>AGP Glucose Range Dashboard</h2>")
# Row 1
row1 = widgets.HBox([metrics_box, out_week, out_gri],
layout=widgets.Layout(gap="12px"))
metrics_box.layout = widgets.Layout(
flex="1 1 260px",
max_width="500px",
min_width="350px",
)
# Row 2
row2 = widgets.HBox(
[out_profile, out_day],
layout=widgets.Layout(gap="12px", align_items="stretch")
)
# Row 3
row3 = out_agp
# Row 4 β your daily subplots
row4 = out_subplots
# Final Dashboard Layout
dashboard = widgets.VBox(
[title, day_selector, row2, row1, row3, row4],
layout=widgets.Layout(width="100%", gap="10px")
)
display(dashboard)