Skip to content

Commit fcaad50

Browse files
authored
Merge pull request #7 from staskh/extension_plots
Plots - daily and statistics
2 parents 5107db0 + c219fe3 commit fcaad50

6 files changed

Lines changed: 828 additions & 3 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,12 @@ IGLU_PYTHON extends beyond the capabilities of the original IGLU-R package by of
9696

9797
| Function | Description |
9898
|-------------------|------------------------------------------|
99+
| **LOAD DATA FROM DEVICE SPECIFIC FILE**
99100
| load_libre() | Load Timeseries from Libre device file (CGM reading converted into mg/dL)
100101
| load_dexcom() | Load Timeseries from Dexcom device file (CGM reading converted into mg/dL)
102+
| **PLOT/VISUALISE CGM **
103+
| plot_daily() | Plot daily Glucose values for each day |
104+
| plot_statistics() | Plot median + quantile daily statistics |
101105

102106
# Installation
103107

iglu_python/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .ea1c import ea1c
1111
from .episode_calculation import episode_calculation
1212
from .extension.load_data import load_dexcom, load_libre
13+
from .extension.plots import plot_daily, plot_statistics
1314
from .gmi import gmi
1415
from .grade import grade
1516
from .grade_eugly import grade_eugly
@@ -85,6 +86,8 @@
8586
"median_glu",
8687
"modd",
8788
"pgs",
89+
"plot_daily",
90+
"plot_statistics",
8891
"process_data",
8992
"quantile_glu",
9093
"range_glu",

iglu_python/extension/plots.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
This module implements various plots for the iglu_python package.
3+
"""
4+
5+
import matplotlib.pyplot as plt
6+
import numpy as np
7+
import pandas as pd
8+
9+
10+
def plot_daily(cgm_timeseries: pd.Series, lower: int = 70, upper: int = 140) -> plt.Figure:
11+
"""
12+
Plot daily Glucose values for each day separately
13+
14+
15+
16+
Args:
17+
- cgm_timeseries: pd.Series
18+
- lower: int, default=70, Lower bound used for hypoglycemia cutoff, in mg/dL
19+
- upper: int, default=140, Upper bound used for hyperglycemia cutoff, in mg/dL
20+
21+
Returns:
22+
plt.Figure object
23+
"""
24+
# divide cgm_timeseries into list of daily series
25+
cgm_daily_group = cgm_timeseries.resample("D")
26+
cgm_timeseries_daily = {day: cgm_daily_group.get_group(day) for day in cgm_daily_group.groups}
27+
28+
# plot each day separately
29+
# Create one figure with subplots for each day
30+
num_days = len(cgm_timeseries_daily)
31+
fig, axes = plt.subplots(num_days, 1, figsize=(12, 3 * num_days))
32+
33+
# If only one day, axes will be a single object, not an array
34+
if num_days == 1:
35+
axes = [axes]
36+
37+
for i, (day, cgm_one_day) in enumerate(cgm_timeseries_daily.items()):
38+
# Convert datetime index to time-only for x-axis display
39+
axes[i].plot(cgm_one_day.index, cgm_one_day.values)
40+
axes[i].set_title(f"Day: {day.strftime('%Y-%m-%d')}")
41+
axes[i].set_ylabel("Glucose (mg/dL)")
42+
axes[i].set_ylim(0, max(np.nanmax(cgm_one_day.values), 300))
43+
44+
# Fill area above upper limit and plot it in orange
45+
upper_array = [upper] * len(cgm_one_day.values)
46+
area_over_upper = [
47+
cgm_one_day.values[i] if cgm_one_day.values[i] > upper else upper for i in range(len(cgm_one_day.values))
48+
]
49+
axes[i].fill_between(cgm_one_day.index, area_over_upper, upper_array, alpha=0.3, color="orange")
50+
axes[i].axhline(y=upper, color="orange", linestyle="--", alpha=0.7, label=f"Hyper threshold ({upper} mg/dL)")
51+
52+
# Fill area below lower limit and plot it in blue
53+
lower_array = [lower] * len(cgm_one_day.values)
54+
area_below_lower = [
55+
cgm_one_day.values[i] if cgm_one_day.values[i] < lower else lower for i in range(len(cgm_one_day.values))
56+
]
57+
axes[i].fill_between(cgm_one_day.index, lower_array, area_below_lower, alpha=0.3, color="blue")
58+
axes[i].axhline(y=lower, color="blue", linestyle="--", alpha=0.7, label=f"Hypo threshold ({lower} mg/dL)")
59+
60+
# on horisontal axis, show only time in hours
61+
axes[i].set_xlabel("Time (hours)")
62+
time_range = pd.date_range(start=day, periods=24, freq="1h")
63+
axes[i].set_xticks(time_range) # Show every hour from 0 to 24
64+
axes[i].set_xticklabels([f"{h.hour}" for h in time_range]) # Format as HH:00
65+
axes[i].grid(True, alpha=0.3, linestyle="--")
66+
axes[i].legend()
67+
68+
fig.tight_layout()
69+
return fig
70+
71+
72+
def plot_statistics(cgm_timeseries: pd.Series, lower: int = 70, upper: int = 140) -> plt.Figure:
73+
"""
74+
Plot statistical representation of daily trends
75+
in the single 24h timeline, this will plot mean sample trends, 10%, +25% and 75% and 90% quantiles
76+
"""
77+
# check if cgm_timeseries is a pandas series
78+
if not isinstance(cgm_timeseries, pd.Series):
79+
raise AttributeError("cgm_timeseries must be a pandas series")
80+
81+
# check if cgm_timeseries is not a datetime index
82+
if not isinstance(cgm_timeseries.index, pd.DatetimeIndex):
83+
raise AttributeError("cgm_timeseries must have a datetime index")
84+
85+
# check if cgm_timeseries is not empty
86+
if len(cgm_timeseries) < 16:
87+
raise ValueError("cgm_timeseries is too short to plot statistics")
88+
89+
# get sampling frequency
90+
time_diffs = cgm_timeseries.index.to_series().diff()
91+
dt0 = int(time_diffs.mode().iloc[0].total_seconds() / 60)
92+
93+
# Create time grid
94+
start_time = cgm_timeseries.index.min().floor("D")
95+
end_time = cgm_timeseries.index.max().ceil("D")
96+
time_grid = pd.date_range(start=start_time, end=end_time, freq=f"{dt0}min")
97+
# remove the last time point
98+
time_grid = time_grid[:-1]
99+
100+
# interpolate
101+
cgm_timeseries_interpolated = np.interp(
102+
(time_grid - start_time).total_seconds() / 60,
103+
(cgm_timeseries.index - start_time).total_seconds() / 60,
104+
cgm_timeseries.values,
105+
left=np.nan,
106+
right=np.nan,
107+
)
108+
109+
# reorganise as 2d array with rows as timepoints and columns as days
110+
# Reshape to days
111+
n_days = (end_time - start_time).days
112+
n_points_per_day = 24 * 60 // dt0
113+
cgm_timeseries_2d = cgm_timeseries_interpolated.reshape(n_days, n_points_per_day)
114+
115+
# one day time grid
116+
time_grid_one_day = time_grid[0:n_points_per_day]
117+
# get mean sample trends
118+
mean_sample_trends = np.nanmean(cgm_timeseries_2d, axis=0)
119+
120+
# get 10%, +25% and 75% and 90% quantiles
121+
quantiles = np.nanpercentile(cgm_timeseries_2d, [10, 25, 75, 90], axis=0)
122+
123+
# create figure and axes
124+
fig, ax = plt.subplots(figsize=(12, 6))
125+
126+
# plot mean sample trends
127+
ax.plot(time_grid_one_day, mean_sample_trends, color="orange", alpha=1, linewidth=3, label="Mean sample trends")
128+
129+
# plot quantiles
130+
ax.fill_between(time_grid_one_day, quantiles[0], quantiles[1], alpha=0.25, color="blue", label="10% quantile")
131+
ax.fill_between(time_grid_one_day, quantiles[1], mean_sample_trends, alpha=0.50, color="blue", label="25% quantile")
132+
ax.fill_between(time_grid_one_day, mean_sample_trends, quantiles[2], alpha=0.50, color="blue", label="75% quantile")
133+
ax.fill_between(time_grid_one_day, quantiles[2], quantiles[3], alpha=0.25, color="blue", label="90% quantile")
134+
135+
ax.axhline(y=upper, color="orange", linestyle="--", alpha=0.7, label=f"Hyper threshold ({upper} mg/dL)")
136+
ax.axhline(y=lower, color="green", linestyle="--", alpha=0.7, label=f"Hypo threshold ({lower} mg/dL)")
137+
138+
ax.set_ylim(min(30, np.nanmin(cgm_timeseries.values)), max(np.nanmax(cgm_timeseries.values), 300))
139+
ax.set_xlabel("Time (hours)")
140+
time_grid_one_day = pd.date_range(start=start_time, periods=24, freq="1h")
141+
ax.set_xticks(time_grid_one_day) # Show every hour from 0 to 24
142+
ax.set_xticklabels([f"{h.hour}" for h in time_grid_one_day]) # Format as HH:00
143+
ax.grid(True, alpha=0.3, linestyle="--")
144+
ax.legend()
145+
fig.tight_layout()
146+
147+
# plot the results
148+
return fig

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "iglu_python"
7-
version = "0.2.4"
7+
version = "0.2.5"
88
description = "Python implementation of the iglu package for continuous glucose monitoring data analysis"
99
readme = "README.md"
1010
requires-python = ">=3.11"
@@ -27,7 +27,8 @@ dependencies = [
2727
"numpy>=2.2.6",
2828
"pandas>=2.2.3",
2929
"tzlocal>=5.3.1",
30-
"openpyxl >= 3.1.5"
30+
"openpyxl >= 3.1.5",
31+
"matplotlib >= 3.10.0"
3132
]
3233

3334
[project.urls]

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pandas >= 2.2.3
22
numpy >= 2.2.6
33
tzlocal >= 5.3.1
4-
openpyxl >= 3.1.5
4+
openpyxl >= 3.1.5
5+
matplotlib >= 3.10.0

0 commit comments

Comments
 (0)