Skip to article frontmatterSkip to article content
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Β &lt;154Β mg/dL</span></td>
             <td class="metric-value">{mean_gluc:0.0f}&nbsp;mg/dL</td>
          </tr>
          <tr class="shade">
             <td>GlucoseΒ ManagementΒ IndicatorΒ (GMI)<br>
                 <span style="font-size:12px">GoalΒ &lt;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)