Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ jobs:
pip install -e .
pip install -r requirements-dev.txt

# - name: Lint with ruff
# run: |
# ruff check .
# ruff format --check .
- name: Lint with ruff
run: |
ruff check .
ruff format --check .

- name: Test with pytest
run: |
Expand Down
44 changes: 27 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,27 @@ A significant focus of this project has been ensuring compatibility with the ori

This approach ensures that the Python implementation produces results consistent with the original R package.

## Unit Test Status
### Input & Output
The implementation maintains compatibility with the R version while following Python best practices. The metrics can be used as:

```Python
import iglu_python ias iglu

# With DataFrame input
result_df = iglu.cv_glu(data) # data should have 'id', 'time', and 'gl' columns
# Return DataFrame with "id' and column(s) with value(s)

# With Series input (some metrics require Series with DateTimeIndex)
result_float = iglu.cv_glu(glucose_series) # just glucose values
# returns a single float value

# Same with function that support list or ndarray
result_float = iglu.cv_glu(glucose_list) # list of glucose values
# returns a single float value

```

## IGLU-R Compatibility Test Status
The current version of IGLU-PYTHON is test-compatible with IGLU-R v4.2.2

Unless noted, IGLU-R test compatability is considered successful if it achieves precision of 0.001
Expand Down Expand Up @@ -69,25 +89,15 @@ Unless noted, IGLU-R test compatability is considered successful if it achieves
| process_data | Data Pre-Processor | ✅ |
| CGMS2DayByDay |Interpolate glucose input| ✅ |

### Input & Output
The implementation maintains compatibility with the R version while following Python best practices. The metrics can be used as:

```Python
import iglu_python ias iglu

# With DataFrame input
result_df = iglu.cv_glu(data) # data should have 'id', 'time', and 'gl' columns
# Return DataFrame with "id' and column(s) with value(s)
## Extended functionality
IGLU_PYTHON extends beyond the capabilities of the original IGLU-R package by offering enhanced functionality and improved user experience. We believe that combining these extended features with the proven reliability of IGLU-R creates a powerful synergy that benefits both the research community and wide software developers community.

# With Series input (some metrics require Series with DateTimeIndex)
result_float = iglu.cv_glu(glucose_series) # just glucose values
# returns a single float value

# Same with function that support list or ndarray
result_float = iglu.cv_glu(glucose_list) # list of glucose values
# returns a single float value

```
| Function | Description |
|-------------------|------------------------------------------|
| load_libre() | Load Timeseries from Libre device file (CGM reading converted into mg/dL)
| load_dexcom() | Load Timeseries from Dexcom device file (CGM reading converted into mg/dL)

# Installation

Expand Down
3 changes: 3 additions & 0 deletions iglu_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .cv_measures import cv_measures
from .ea1c import ea1c
from .episode_calculation import episode_calculation
from .extension.load_data import load_dexcom, load_libre
from .gmi import gmi
from .grade import grade
from .grade_eugly import grade_eugly
Expand Down Expand Up @@ -74,6 +75,8 @@
"iqr_glu",
"j_index",
"lbgi",
"load_dexcom",
"load_libre",
"mad_glu",
"mag",
"mage",
Expand Down
10 changes: 5 additions & 5 deletions iglu_python/above_percent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@


def above_percent(
data: Union[pd.DataFrame, pd.Series, list,np.ndarray],
data: Union[pd.DataFrame, pd.Series, list, np.ndarray],
targets_above: List[int] = None,
) -> pd.DataFrame|dict[str:float]:
) -> pd.DataFrame | dict[str:float]:
"""
Calculate percentage of values above target thresholds.

Expand Down Expand Up @@ -61,12 +61,11 @@ def above_percent(
# Handle Series input
if targets_above is None:
targets_above = [140, 180, 250]
if isinstance(data, (pd.Series, list,np.ndarray)):
if isinstance(data, (pd.Series, list, np.ndarray)):
if isinstance(data, (list, np.ndarray)):
data = pd.Series(data)
return above_percent_single(data, targets_above)


# Handle DataFrame input
data = check_data_columns(data)
targets_above = [int(t) for t in targets_above]
Expand All @@ -83,9 +82,10 @@ def above_percent(

# Convert to DataFrame
df = pd.DataFrame(result)
df = df[['id'] + [col for col in df.columns if col != 'id']]
df = df[["id"] + [col for col in df.columns if col != "id"]]
return df


def above_percent_single(data: pd.Series, targets_above: List[int] = None) -> dict[str:float]:
"""
Calculate percentage of values above target thresholds for a single series/subject.
Expand Down
22 changes: 5 additions & 17 deletions iglu_python/active_percent.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def active_percent(
range_type: str = "automatic",
ndays: int = 14,
consistent_end_date: Optional[Union[str, datetime]] = None,
) -> pd.DataFrame|dict[str:float]:
) -> pd.DataFrame | dict[str:float]:
"""
Calculate percentage of time CGM was active.

Expand Down Expand Up @@ -86,21 +86,16 @@ def active_percent(
# Process each subject
for subject in data["id"].unique():
# Filter data for current subject and remove NA values
sub_data = (
data[data["id"] == subject]
.dropna(subset=["gl", "time"])
.sort_values("time")
)
sub_data = data[data["id"] == subject].dropna(subset=["gl", "time"]).sort_values("time")

timeseries = sub_data.set_index("time")["gl"]
active_percent_dict = active_percent_single(timeseries, dt0, tz, range_type, ndays, consistent_end_date)
active_percent_dict["id"] = subject
active_perc_data.append(active_percent_dict)


# Convert to DataFrame
df = pd.DataFrame(active_perc_data)
df = df[['id'] + [col for col in df.columns if col != 'id']]
df = df[["id"] + [col for col in df.columns if col != "id"]]
return df


Expand All @@ -127,9 +122,7 @@ def active_percent_single(
return {"active_percent": 0, "ndays": 0, "start_date": None, "end_date": None}

# Calculate time differences between consecutive measurements
time_diffs = np.array(
data.index.diff().total_seconds() / 60
) # Convert to minutes
time_diffs = np.array(data.index.diff().total_seconds() / 60) # Convert to minutes

# Automatically determine dt0 if not provided
if dt0 is None:
Expand All @@ -154,9 +147,7 @@ def active_percent_single(
ndays = (max_time - min_time).total_seconds() / (24 * 3600)

# Calculate active percentage
active_percent = (
(theoretical_gl_vals - missing_gl_vals) / theoretical_gl_vals
) * 100
active_percent = ((theoretical_gl_vals - missing_gl_vals) / theoretical_gl_vals) * 100
elif range_type == "manual":
# Handle consistent end date if provided
if consistent_end_date is not None:
Expand All @@ -178,6 +169,3 @@ def active_percent_single(
raise ValueError(f"Invalid range_type: {range_type}")

return {"active_percent": active_percent, "ndays": round(ndays, 1), "start_date": min_time, "end_date": max_time}



17 changes: 6 additions & 11 deletions iglu_python/adrr.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@

import numpy as np
import pandas as pd

from .utils import check_data_columns


def adrr(data: pd.DataFrame|pd.Series) -> pd.DataFrame|float:
def adrr(data: pd.DataFrame | pd.Series) -> pd.DataFrame | float:
"""
Calculate average daily risk range (ADRR)

Expand Down Expand Up @@ -52,7 +51,6 @@ def adrr(data: pd.DataFrame|pd.Series) -> pd.DataFrame|float:
>>> iglu.adrr(data)
"""


# Validate input
if isinstance(data, pd.Series):
if not isinstance(data.index, pd.DatetimeIndex):
Expand All @@ -61,15 +59,13 @@ def adrr(data: pd.DataFrame|pd.Series) -> pd.DataFrame|float:

data = check_data_columns(data)

data.set_index("time", inplace=True,drop=True)
out = data.groupby("id").agg(
ADRR = ("gl", lambda x: adrr_single(x))
).reset_index()
data.set_index("time", inplace=True, drop=True)
out = data.groupby("id").agg(ADRR=("gl", lambda x: adrr_single(x))).reset_index()

return out


def adrr_single(data: pd.DataFrame|pd.Series) -> float:
def adrr_single(data: pd.DataFrame | pd.Series) -> float:
"""Internal function to calculate ADRR for a single subject or timeseries of glucose values"""

if isinstance(data, pd.Series):
Expand All @@ -85,11 +81,10 @@ def adrr_single(data: pd.DataFrame|pd.Series) -> float:
return np.nan

# Group by date and calculate daily risk for each day
daily_risks = data_filtered.groupby(data_filtered.index.date).apply(
lambda x: _calculate_daily_risk(x)
)
daily_risks = data_filtered.groupby(data_filtered.index.date).apply(lambda x: _calculate_daily_risk(x))
return daily_risks.mean()


def _calculate_daily_risk(gl: pd.Series) -> float:
"""Calculate daily risk range for a single day and subject"""

Expand Down
30 changes: 12 additions & 18 deletions iglu_python/auc.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import numpy as np
import pandas as pd

Expand Down Expand Up @@ -60,7 +59,7 @@ def auc(data: pd.DataFrame, tz: str = "") -> pd.DataFrame:
if not isinstance(data.index, pd.DatetimeIndex):
raise ValueError("Series must have a DatetimeIndex")

auc = auc_single(data,tz=tz)
auc = auc_single(data, tz=tz)
return auc

# Check data format and convert time to datetime
Expand All @@ -70,46 +69,41 @@ def auc(data: pd.DataFrame, tz: str = "") -> pd.DataFrame:
result = []
for subject in data["id"].unique():
subject_data = data[data["id"] == subject]
hourly_auc = auc_single(subject_data,tz=tz)
hourly_auc = auc_single(subject_data, tz=tz)
result.append({"id": subject, "hourly_auc": hourly_auc})

# Convert to DataFrame
return pd.DataFrame(result)

def auc_single(subject_data: pd.DataFrame|pd.Series,tz:str = "") -> float:

def auc_single(subject_data: pd.DataFrame | pd.Series, tz: str = "") -> float:
"""Calculate AUC for a single subject"""
# Get interpolated data using CGMS2DayByDay
gd2d, actual_dates, dt0 = CGMS2DayByDay(subject_data, tz=tz)

# Convert gd2d to DataFrame
input_data = gd2d_to_df(gd2d, actual_dates, dt0)
if is_iglu_r_compatible():
input_data['day'] = input_data['time'].dt.floor('d')
input_data['gl_next'] = input_data['gl'].shift(-1)
input_data["day"] = input_data["time"].dt.floor("d")
input_data["gl_next"] = input_data["gl"].shift(-1)
each_day_area = input_data.groupby("day").apply(
lambda x: np.nansum(
(dt0/60)*(x["gl"].values + x["gl_next"].values) / 2
),
include_groups=False
lambda x: np.nansum((dt0 / 60) * (x["gl"].values + x["gl_next"].values) / 2), include_groups=False
)
# calculate number of not nan trapezoids in total (number of not nan gl and gl_next)
n_trapezoids = (~np.isnan(input_data["gl"]) & ~np.isnan(input_data["gl_next"])).sum()
hours = dt0/60 * n_trapezoids
hours = dt0 / 60 * n_trapezoids
daily_area = each_day_area.sum()
hourly_avg = daily_area/hours
hourly_avg = daily_area / hours
return hourly_avg
else:
# Add hour column by rounding time to nearest hour
input_data['hour'] = input_data['time'].dt.floor('h')
input_data["hour"] = input_data["time"].dt.floor("h")

input_data['gl_next'] = input_data['gl'].shift(-1)
input_data["gl_next"] = input_data["gl"].shift(-1)

# Calculate AUC for each hour using trapezoidal rule (mg*min/dL)
hourly_auc = input_data.groupby("hour").apply(
lambda x: np.nansum(
(dt0/60)*(x["gl"].values + x["gl_next"].values) / 2
),
include_groups=False
lambda x: np.nansum((dt0 / 60) * (x["gl"].values + x["gl_next"].values) / 2), include_groups=False
)
# 0 mean no data in this hour, replace with nan
hourly_auc = hourly_auc.replace(0, np.nan)
Expand Down
11 changes: 5 additions & 6 deletions iglu_python/below_percent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@


def below_percent(
data: Union[pd.DataFrame, pd.Series, list,np.ndarray], targets_below: List[int] = None
) -> pd.DataFrame|dict[str:float]:
data: Union[pd.DataFrame, pd.Series, list, np.ndarray], targets_below: List[int] = None
) -> pd.DataFrame | dict[str:float]:
"""
Calculate percentage of values below target thresholds.

Expand Down Expand Up @@ -60,12 +60,11 @@ def below_percent(
# Handle Series input
if targets_below is None:
targets_below = [54, 70]
if isinstance(data, (pd.Series, list,np.ndarray)):
if isinstance(data, (pd.Series, list, np.ndarray)):
if isinstance(data, (list, np.ndarray)):
data = pd.Series(data)
return below_percent_single(data, targets_below)


# Handle DataFrame input
data = check_data_columns(data)

Expand All @@ -82,9 +81,10 @@ def below_percent(

# Convert to DataFrame
df = pd.DataFrame(result)
df = df[['id'] + [col for col in df.columns if col != 'id']]
df = df[["id"] + [col for col in df.columns if col != "id"]]
return df


def below_percent_single(data: pd.Series, targets_below: List[int] = None) -> dict[str:float]:
"""
Calculate percentage of values below target thresholds for a single series/subject.
Expand All @@ -106,4 +106,3 @@ def below_percent_single(data: pd.Series, targets_below: List[int] = None) -> di
percentages[f"below_{target}"] = (below_count / total_readings) * 100

return percentages

Loading
Loading