Skip to content

API Reference — Plotting

The plotting layer generates static visualizations of CGM data.

Module-level plot functions

cgmpy.plotting.agp

Module for ambulatory glucose profile (AGP) plots.

This module contains functions to generate ambulatory profiles: - Standard AGP - AGP by day of week - Helper functions for percentile calculation

plot_agp

plot_agp(data: DataFrame, smoothing_window: int = 15, ax: Axes | None = None) -> Figure

Builds the enhanced Ambulatory Glucose Profile (AGP).

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
smoothing_window int

Smoothing window in minutes (default 15).

15
ax Axes | None

Optional axis to draw into. If None a new figure is created.

None

Returns:

Type Description
Figure

The matplotlib Figure containing the plot.

Source code in cgmpy/plotting/agp.py
def plot_agp(data: pd.DataFrame, smoothing_window: int = 15, ax: Axes | None = None) -> Figure:
    """Builds the enhanced Ambulatory Glucose Profile (AGP).

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        smoothing_window: Smoothing window in minutes (default 15).
        ax: Optional axis to draw into. If ``None`` a new figure is created.

    Returns:
        The matplotlib ``Figure`` containing the plot.
    """
    data_copy = data.copy()
    data_copy["time_decimal"] = (
        data_copy["time"].dt.hour + data_copy["time"].dt.minute / 60.0
    ).round(2)

    percentiles = data_copy.groupby("time_decimal")["glucose"].agg(
        [
            lambda x: np.percentile(x, 5),
            lambda x: np.percentile(x, 25),
            lambda x: np.percentile(x, 50),
            lambda x: np.percentile(x, 75),
            lambda x: np.percentile(x, 95),
        ]
    )

    percentiles.columns = [0.05, 0.25, 0.5, 0.75, 0.95]

    for col in percentiles.columns:
        percentiles[col] = (
            percentiles[col].rolling(window=smoothing_window, center=True, min_periods=1).mean()
        )

    percentiles = percentiles.sort_index()

    fig, ax, created = resolve_axes(ax, figsize=(14, 8))

    add_glucose_zones(ax)

    _plot_percentiles(ax, percentiles)

    _configure_agp_plot(ax, "Ambulatory Glucose Profile (AGP)")

    if created:
        fig.tight_layout()
    return fig

generate_week_agp

generate_week_agp(data: DataFrame, smoothing_window: int = 15, combined: bool = True) -> Figure

Builds the Ambulatory Glucose Profile (AGP) by day of week.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
smoothing_window int

Smoothing window in minutes (default 15).

15
combined bool

If True, displays all days in a single chart. If False, displays a subplot for each day.

True

Returns:

Type Description
Figure

The matplotlib Figure containing the plot.

Source code in cgmpy/plotting/agp.py
def generate_week_agp(
    data: pd.DataFrame, smoothing_window: int = 15, combined: bool = True
) -> Figure:
    """Builds the Ambulatory Glucose Profile (AGP) by day of week.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        smoothing_window: Smoothing window in minutes (default 15).
        combined: If True, displays all days in a single chart.
                 If False, displays a subplot for each day.

    Returns:
        The matplotlib ``Figure`` containing the plot.
    """
    data_copy = data.copy()
    data_copy["time_decimal"] = (
        data_copy["time"].dt.hour + data_copy["time"].dt.minute / 60.0
    ).round(2)
    data_copy["weekday"] = data_copy["time"].dt.day_name()

    if combined:
        return _plot_combined_week_agp(data_copy, smoothing_window)
    return _plot_separate_week_agp(data_copy, smoothing_window)

cgmpy.plotting.daily_plots

Module for daily glucose data plots.

This module contains functions to generate charts related to daily patterns: - Specific day plots - Overlapping multiple days - Boxplots by day of week - Daily variation analysis

Every public function accepts an optional ax and returns the resulting Figure without calling plt.show() — display is left to the caller (e.g. the :class:~cgmpy.analysis.core.GlucoseAnalysis facade), which makes the plots composable inside dashboards.

day_graph

day_graph(data: DataFrame, date: str | None = None, ax: Axes | None = None) -> Figure | None

Builds the glucose chart for a specific day.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
date str | None

Optional date in 'YYYY-MM-DD' format. If not provided, the first day of the DataFrame is used.

None
ax Axes | None

Optional axis to draw into. If None a new figure is created.

None

Returns:

Type Description
Figure | None

The matplotlib Figure, or None if the date has no data.

Source code in cgmpy/plotting/daily_plots.py
def day_graph(data: pd.DataFrame, date: str | None = None, ax: Axes | None = None) -> Figure | None:
    """Builds the glucose chart for a specific day.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        date: Optional date in 'YYYY-MM-DD' format.
              If not provided, the first day of the DataFrame is used.
        ax: Optional axis to draw into. If ``None`` a new figure is created.

    Returns:
        The matplotlib ``Figure``, or ``None`` if the date has no data.
    """
    if date is None:
        date = data["time"].dt.date.min()
    else:
        date = pd.to_datetime(date).date()

    day_data = data[data["time"].dt.date == date].copy()

    if day_data.empty:
        logger.info("No data for date %s", date)
        return None

    day_data["hours"] = day_data["time"].dt.hour + day_data["time"].dt.minute / 60.0

    sns.set_style("whitegrid")
    sns.set_context("notebook", font_scale=1.1)

    fig, ax, created = resolve_axes(ax, figsize=(16, 9))

    add_glucose_zones(ax)

    ax.plot(
        day_data["hours"],
        day_data["glucose"],
        label="Glucose",
        color="#3366CC",
        linewidth=2,
        marker="o",
        markersize=4,
    )

    _add_reference_lines(ax)

    _configure_daily_plot(ax, f"Glucose Levels - {date}")

    if created:
        fig.tight_layout()
    return fig

plot_overlapping_days

plot_overlapping_days(data: DataFrame, ax: Axes | None = None) -> Figure

Builds a chart with the glucose profiles of multiple overlapping days.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
ax Axes | None

Optional axis to draw into. If None a new figure is created.

None

Returns:

Type Description
Figure

The matplotlib Figure containing the plot.

Source code in cgmpy/plotting/daily_plots.py
def plot_overlapping_days(data: pd.DataFrame, ax: Axes | None = None) -> Figure:
    """Builds a chart with the glucose profiles of multiple overlapping days.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        ax: Optional axis to draw into. If ``None`` a new figure is created.

    Returns:
        The matplotlib ``Figure`` containing the plot.
    """
    data_copy = data.copy()
    data_copy["time_decimal"] = data_copy["time"].dt.hour + data_copy["time"].dt.minute / 60.0
    data_copy["date"] = data_copy["time"].dt.date

    fig, ax, created = resolve_axes(ax, figsize=(12, 8))

    mean_profile = (
        data_copy.groupby("time_decimal")["glucose"]
        .mean()
        .rolling(window=15, center=True, min_periods=1)
        .mean()
    )

    dates = data_copy["date"].unique()
    for d in dates:
        day_data = data_copy[data_copy["date"] == d]
        ax.plot(
            day_data["time_decimal"],
            day_data["glucose"],
            color="gray",
            alpha=0.2,
            linewidth=1,
        )

    ax.plot(
        mean_profile.index,
        mean_profile.values,
        color="black",
        linewidth=2,
        label="Mean profile",
    )

    _configure_overlapping_plot(ax)

    if created:
        fig.tight_layout()
    return fig

plot_week_boxplots

plot_week_boxplots(data: DataFrame, ax: Axes | None = None) -> Figure

Builds a boxplot chart of the glucose distribution by day of the week.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
ax Axes | None

Optional axis to draw into. If None a new figure is created.

None

Returns:

Type Description
Figure

The matplotlib Figure containing the plot.

Source code in cgmpy/plotting/daily_plots.py
def plot_week_boxplots(data: pd.DataFrame, ax: Axes | None = None) -> Figure:
    """Builds a boxplot chart of the glucose distribution by day of the week.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        ax: Optional axis to draw into. If ``None`` a new figure is created.

    Returns:
        The matplotlib ``Figure`` containing the plot.
    """
    data_copy = data.copy()
    data_copy["weekday"] = data_copy["time"].dt.day_name()
    data_copy["date"] = data_copy["time"].dt.date

    day_order = [
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
        "Sunday",
    ]

    unique_days = data_copy.groupby("weekday")["date"].nunique()

    labels = [f"{day}\n(n={unique_days.get(day, 0)} days)" for day in day_order]

    fig, ax, created = resolve_axes(ax, figsize=(12, 8))

    ax.axhspan(GLUCOSE_AXIS_MIN, TARGET_LOW, color="#ffcccb", alpha=0.2, label="Hypoglycemia")
    ax.axhspan(TARGET_LOW, TARGET_HIGH, color="#90ee90", alpha=0.2, label="Target range")
    ax.axhspan(TARGET_HIGH, GLUCOSE_AXIS_MAX, color="#ffcccb", alpha=0.2, label="Hyperglycemia")

    sns.boxplot(
        x="weekday",
        y="glucose",
        data=data_copy,
        order=day_order,
        whis=1.5,
        medianprops={"color": "red", "linewidth": 1.5},
        flierprops={"marker": "o", "markerfacecolor": "gray", "markersize": 4},
        ax=ax,
    )

    ax.axhline(y=TARGET_LOW, color="red", linestyle="--", linewidth=1)
    ax.axhline(y=TARGET_HIGH, color="red", linestyle="--", linewidth=1)

    ax.set_title("Glucose Distribution by Day of Week", fontsize=14, pad=20)
    ax.set_xlabel("Day of Week", fontsize=12)
    ax.set_ylabel("Glucose Level (mg/dL)", fontsize=12)

    ax.set_xticks(range(len(day_order)))
    ax.set_xticklabels(labels, rotation=45, ha="right")
    ax.set_ylim(GLUCOSE_AXIS_MIN, GLUCOSE_AXIS_MAX)

    ax.legend(title="Ranges", bbox_to_anchor=(1.05, 1), loc="upper left")

    if created:
        fig.tight_layout()
    return fig

plot_daily_variations

plot_daily_variations(data: DataFrame, ax: Axes | None = None) -> Figure

Builds a chart of the average daily variations with confidence bands.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
ax Axes | None

Optional axis to draw into. If None a new figure is created.

None

Returns:

Type Description
Figure

The matplotlib Figure containing the plot.

Source code in cgmpy/plotting/daily_plots.py
def plot_daily_variations(data: pd.DataFrame, ax: Axes | None = None) -> Figure:
    """Builds a chart of the average daily variations with confidence bands.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        ax: Optional axis to draw into. If ``None`` a new figure is created.

    Returns:
        The matplotlib ``Figure`` containing the plot.
    """
    data_copy = data.copy()
    data_copy["time_decimal"] = data_copy["time"].dt.hour + data_copy["time"].dt.minute / 60.0

    hourly_stats = (
        data_copy.groupby("time_decimal")["glucose"]
        .agg(
            [
                "mean",
                "std",
                "count",
                lambda x: np.percentile(x, 25),
                lambda x: np.percentile(x, 75),
            ]
        )
        .reset_index()
    )

    hourly_stats.columns = ["time_decimal", "mean", "std", "count", "p25", "p75"]

    window_size = 15
    for col in ["mean", "std", "p25", "p75"]:
        hourly_stats[col] = (
            hourly_stats[col].rolling(window=window_size, center=True, min_periods=1).mean()
        )

    fig, ax, created = resolve_axes(ax, figsize=(14, 8))

    add_glucose_zones(ax)

    ax.plot(
        hourly_stats["time_decimal"],
        hourly_stats["mean"],
        color="blue",
        linewidth=2,
        label="Mean",
    )

    ax.fill_between(
        hourly_stats["time_decimal"],
        hourly_stats["mean"] - hourly_stats["std"],
        hourly_stats["mean"] + hourly_stats["std"],
        alpha=0.3,
        color="blue",
        label="± 1 SD",
    )

    ax.fill_between(
        hourly_stats["time_decimal"],
        hourly_stats["p25"],
        hourly_stats["p75"],
        alpha=0.2,
        color="green",
        label="Interquartile range",
    )

    ax.set_xlabel("Time of Day", fontsize=12)
    ax.set_ylabel("Glucose Level (mg/dL)", fontsize=12)
    ax.set_title("Average Daily Glucose Variations", fontsize=14)

    ax.set_xticks(range(0, 25, 3))
    ax.set_xticklabels([f"{h:02d}:00" for h in range(0, 25, 3)])
    ax.set_xlim(0, 24)
    ax.set_ylim(GLUCOSE_AXIS_MIN, GLUCOSE_AXIS_MAX)

    ax.axhline(y=TARGET_LOW, color="red", linestyle="--", linewidth=1, alpha=0.7)
    ax.axhline(y=TARGET_HIGH, color="red", linestyle="--", linewidth=1, alpha=0.7)

    ax.grid(True, alpha=0.3)
    ax.legend()

    if created:
        fig.tight_layout()
    return fig

cgmpy.plotting.statistical_plots

Module for statistical glucose data plots.

This module contains functions to generate statistical charts: - Distribution histograms - Time in range charts - Correlation charts - Statistical distribution analysis

histogram

histogram(data: DataFrame, bin_width: int = 10, ax: Axes | None = None) -> Figure

Builds the glucose histogram with fixed intervals.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
bin_width int

Width of each interval in mg/dL (default 10).

10
ax Axes | None

Optional axis to draw into. If None a new figure is created.

None

Returns:

Type Description
Figure

The matplotlib Figure containing the plot.

Source code in cgmpy/plotting/statistical_plots.py
def histogram(data: pd.DataFrame, bin_width: int = 10, ax: Axes | None = None) -> Figure:
    """Builds the glucose histogram with fixed intervals.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        bin_width: Width of each interval in mg/dL (default 10).
        ax: Optional axis to draw into. If ``None`` a new figure is created.

    Returns:
        The matplotlib ``Figure`` containing the plot.
    """
    bins = range(GLUCOSE_AXIS_MIN, GLUCOSE_HIST_MAX + bin_width, bin_width)

    fig, ax, created = resolve_axes(ax, figsize=(12, 8))

    ax.hist(data["glucose"], bins=bins, edgecolor="black", alpha=0.7)

    ax.axvspan(GLUCOSE_AXIS_MIN, TARGET_LOW, color="#ffcccb", alpha=0.3, label="Hypoglycemia")
    ax.axvspan(TARGET_LOW, TARGET_HIGH, color="#90ee90", alpha=0.3, label="Target range")
    ax.axvspan(TARGET_HIGH, GLUCOSE_AXIS_MAX, color="#ffcccb", alpha=0.3, label="Hyperglycemia")

    ax.set_xlabel("Glucose Level (mg/dL)", fontsize=12)
    ax.set_ylabel("Frequency", fontsize=12)
    ax.set_title(f"Glucose Histogram ({bin_width} mg/dL bins)", fontsize=14)
    ax.legend()
    ax.grid(True, alpha=0.3)

    if created:
        fig.tight_layout()
    return fig

plot_time_in_range

plot_time_in_range(data: DataFrame, pregnancy: bool = False, tir_pregnancy: float = 0, tbr63: float = 0, tar140: float = 0, tir: float = 0, tbr70: float = 0, tbr55: float = 0, tar180: float = 0, tar250: float = 0) -> Figure

Generates a pie chart of time in range.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
pregnancy bool

If True, uses pregnancy-specific ranges.

False
tir_pregnancy float

TIR for pregnancy (63-140 mg/dL).

0
tbr63 float

TBR below 63 mg/dL.

0
tar140 float

TAR above 140 mg/dL.

0
tir float

Standard TIR (70-180 mg/dL).

0
tbr70 float

TBR Level 1 (55-70 mg/dL).

0
tbr55 float

TBR Level 2 (< 55 mg/dL).

0
tar180 float

TAR Level 1 (180-250 mg/dL).

0
tar250 float

TAR Level 2 (> 250 mg/dL).

0
Source code in cgmpy/plotting/statistical_plots.py
def plot_time_in_range(
    data: pd.DataFrame,
    pregnancy: bool = False,
    tir_pregnancy: float = 0,
    tbr63: float = 0,
    tar140: float = 0,
    tir: float = 0,
    tbr70: float = 0,
    tbr55: float = 0,
    tar180: float = 0,
    tar250: float = 0,
) -> Figure:
    """Generates a pie chart of time in range.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        pregnancy: If True, uses pregnancy-specific ranges.
        tir_pregnancy: TIR for pregnancy (63-140 mg/dL).
        tbr63: TBR below 63 mg/dL.
        tar140: TAR above 140 mg/dL.
        tir: Standard TIR (70-180 mg/dL).
        tbr70: TBR Level 1 (55-70 mg/dL).
        tbr55: TBR Level 2 (< 55 mg/dL).
        tar180: TAR Level 1 (180-250 mg/dL).
        tar250: TAR Level 2 (> 250 mg/dL).
    """
    if pregnancy:
        labels = [
            "TIR Pregnancy\n(63-140 mg/dL)",
            "TBR\n(< 63 mg/dL)",
            "TAR\n(> 140 mg/dL)",
        ]
        sizes = [tir_pregnancy, tbr63, tar140]
        colors = ["#90ee90", "#ffcccb", "#ffa500"]
        title = "Time in Range - Pregnancy"
    else:
        labels = [
            "TIR\n(70-180 mg/dL)",
            "TBR Level 1\n(55-70 mg/dL)",
            "TBR Level 2\n(< 55 mg/dL)",
            "TAR Level 1\n(180-250 mg/dL)",
            "TAR Level 2\n(> 250 mg/dL)",
        ]
        sizes = [tir, tbr70, tbr55, tar180, tar250]
        colors = ["#90ee90", "#ffeb9c", "#ffcccb", "#ffa500", "#ff6666"]
        title = "Time in Range - Standard"

    non_zero_data = [
        (label, size, color)
        for label, size, color in zip(labels, sizes, colors, strict=False)
        if size > 0
    ]
    if non_zero_data:
        labels, sizes, colors = map(list, zip(*non_zero_data, strict=False))

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 8))

    wedges, texts, autotexts = ax1.pie(
        sizes,
        labels=labels,
        colors=colors,
        autopct="%1.1f%%",
        startangle=90,
        textprops={"fontsize": 10},
    )

    ax1.set_title(title, fontsize=14, fontweight="bold")

    y_pos = np.arange(len(labels))
    bars = ax2.barh(y_pos, sizes, color=colors, alpha=0.7)

    ax2.set_yticks(y_pos)
    ax2.set_yticklabels(labels, fontsize=10)
    ax2.set_xlabel("Percentage (%)", fontsize=12)
    ax2.set_title("Detailed Distribution", fontsize=14, fontweight="bold")

    for _i, (bar, size) in enumerate(zip(bars, sizes, strict=False)):
        ax2.text(
            bar.get_width() + 0.5,
            bar.get_y() + bar.get_height() / 2,
            f"{size:.1f}%",
            ha="left",
            va="center",
            fontsize=10,
        )

    ax2.grid(True, alpha=0.3, axis="x")

    fig.tight_layout()
    return fig

plot_distribution_comparison

plot_distribution_comparison(data: DataFrame, target_ranges: list[tuple] | None = None, tir_val: float = 0, tbr_val: float = 0, tar_val: float = 0, gmi_val: float = 0) -> Figure

Compares the current distribution with target ranges.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
target_ranges list[tuple] | None

List of tuples (min, max, label, color) to compare.

None
tir_val float

Time in Range value.

0
tbr_val float

Time Below Range value (< 70 mg/dL).

0
tar_val float

Time Above Range value (> 180 mg/dL).

0
gmi_val float

Glucose Management Index value.

0
Source code in cgmpy/plotting/statistical_plots.py
def plot_distribution_comparison(
    data: pd.DataFrame,
    target_ranges: list[tuple] | None = None,
    tir_val: float = 0,
    tbr_val: float = 0,
    tar_val: float = 0,
    gmi_val: float = 0,
) -> Figure:
    """Compares the current distribution with target ranges.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        target_ranges: List of tuples (min, max, label, color) to compare.
        tir_val: Time in Range value.
        tbr_val: Time Below Range value (< 70 mg/dL).
        tar_val: Time Above Range value (> 180 mg/dL).
        gmi_val: Glucose Management Index value.
    """
    if target_ranges is None:
        target_ranges = [
            (70, 180, "Target Range", "#90ee90"),
            (0, 70, "Hypoglycemia", "#ffcccb"),
            (180, 400, "Hyperglycemia", "#ffa500"),
        ]

    fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 12))

    ax1.hist(
        data["glucose"],
        bins=50,
        density=True,
        alpha=0.7,
        color="skyblue",
        edgecolor="black",
    )

    for min_val, max_val, label, color in target_ranges:
        ax1.axvspan(min_val, max_val, alpha=0.3, color=color, label=label)

    ax1.set_xlabel("Glucose (mg/dL)")
    ax1.set_ylabel("Density")
    ax1.set_title("Glucose Distribution")
    ax1.legend()
    ax1.grid(True, alpha=0.3)

    ax2.boxplot(
        data["glucose"],
        vert=True,
        patch_artist=True,
        boxprops={"facecolor": "lightblue"},
    )
    ax2.set_ylabel("Glucose (mg/dL)")
    ax2.set_title("Glucose Box Plot")
    ax2.grid(True, alpha=0.3)

    from scipy import stats

    stats.probplot(data["glucose"], dist="norm", plot=ax3)
    ax3.set_title("Q-Q Plot (Normality)")
    ax3.grid(True, alpha=0.3)

    ax4.axis("off")
    stats_text = _generate_statistics_text(data["glucose"], tir_val, tbr_val, tar_val, gmi_val)
    ax4.text(
        0.1,
        0.9,
        stats_text,
        transform=ax4.transAxes,
        fontsize=11,
        verticalalignment="top",
        fontfamily="monospace",
        bbox={"boxstyle": "round", "facecolor": "lightgray", "alpha": 0.8},
    )

    fig.tight_layout()
    return fig

plot_correlation_matrix

plot_correlation_matrix(data: DataFrame, time_segments: list[str] | None = None, ax: Axes | None = None) -> Figure

Builds a correlation matrix between different time segments.

Parameters:

Name Type Description Default
data DataFrame

Glucose DataFrame with time and glucose columns.

required
time_segments list[str] | None

List of time segments to analyze.

None
ax Axes | None

Optional axis to draw into. If None a new figure is created.

None

Returns:

Type Description
Figure

The matplotlib Figure containing the plot.

Source code in cgmpy/plotting/statistical_plots.py
def plot_correlation_matrix(
    data: pd.DataFrame,
    time_segments: list[str] | None = None,
    ax: Axes | None = None,
) -> Figure:
    """Builds a correlation matrix between different time segments.

    Args:
        data: Glucose DataFrame with ``time`` and ``glucose`` columns.
        time_segments: List of time segments to analyze.
        ax: Optional axis to draw into. If ``None`` a new figure is created.

    Returns:
        The matplotlib ``Figure`` containing the plot.
    """
    if time_segments is None:
        time_segments = ["00:00-06:00", "06:00-12:00", "12:00-18:00", "18:00-24:00"]

    data_copy = data.copy()
    data_copy["hour"] = data_copy["time"].dt.hour
    data_copy["date"] = data_copy["time"].dt.date

    segment_data = {}

    for segment in time_segments:
        start_hour, end_hour = segment.split("-")
        start_h = int(start_hour.split(":")[0])
        end_h = int(end_hour.split(":")[0])

        if end_h == 0:
            end_h = 24

        if start_h < end_h:
            mask = (data_copy["hour"] >= start_h) & (data_copy["hour"] < end_h)
        else:
            mask = (data_copy["hour"] >= start_h) | (data_copy["hour"] < end_h)

        segment_glucose = data_copy[mask].groupby("date")["glucose"].mean()
        segment_data[segment] = segment_glucose

    correlation_df = pd.DataFrame(segment_data)
    correlation_matrix = correlation_df.corr()

    fig, ax, created = resolve_axes(ax, figsize=(10, 8))

    sns.heatmap(
        correlation_matrix,
        annot=True,
        cmap="coolwarm",
        center=0,
        square=True,
        fmt=".3f",
        cbar_kws={"shrink": 0.8},
        ax=ax,
    )

    ax.set_title(
        "Correlation Matrix between Time Segments",
        fontsize=14,
        fontweight="bold",
    )
    if created:
        fig.tight_layout()
    return fig

High-level facade

cgmpy.analysis.core.GlucoseAnalysis

Full CGM analysis by composition.

Wraps a :class:GlucoseData instance and provides access to all metrics, plots, and reports via delegation to pure functions.

Parameters:

Name Type Description Default
data GlucoseData | str | DataFrame

A :class:GlucoseData instance, or a file path / DataFrame (backward-compatible shorthand that wraps in GlucoseData).

required
Source code in cgmpy/analysis/core.py
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
class GlucoseAnalysis:
    """Full CGM analysis by composition.

    Wraps a :class:`GlucoseData` instance and provides access to all metrics,
    plots, and reports via delegation to pure functions.

    Args:
        data: A :class:`GlucoseData` instance, or a file path / DataFrame
            (backward-compatible shorthand that wraps in ``GlucoseData``).
    """

    def __init__(
        self,
        data: GlucoseData | str | pd.DataFrame,
        date_col: str = "time",
        glucose_col: str = "glucose",
        delimiter: str | None = None,
        header: int = 0,
        start_date: str | datetime.datetime | None = None,
        end_date: str | datetime.datetime | None = None,
        log: bool = False,
        target_type: str = "diabetes",
    ):
        if isinstance(data, GlucoseData):
            self._data = data
        else:
            self._data = GlucoseData(
                data_source=data,
                date_col=date_col,
                glucose_col=glucose_col,
                delimiter=delimiter,
                header=header,
                start_date=start_date,
                end_date=end_date,
                log=log,
                target_type=target_type,
            )

        self._cache: dict[str, Any] = {}

    # ── Cache ────────────────────────────────────────────────────────────

    def _cached(self, key: str, func: Callable) -> Any:
        if key not in self._cache:
            self._cache[key] = func()
        return self._cache[key]

    def clear_cache(self) -> None:
        """Clear all cached computed values."""
        self._cache = {}

    # ── Properties ──────────────────────────────────────────────────────

    @property
    def glucose(self) -> pd.Series:
        return self._data.glucose

    @property
    def timestamps(self) -> pd.Series:
        return self._data.timestamps

    @property
    def targets(self) -> GlucoseTargets:
        return self._data.targets

    @targets.setter
    def targets(self, value: GlucoseTargets) -> None:
        self._data.targets = value
        self.clear_cache()  # metrics depend on targets

    @property
    def data(self) -> pd.DataFrame:
        """Raw glucose DataFrame (passthrough to :attr:`GlucoseData.data`)."""
        return self._data.data

    # ── Data passthrough ─────────────────────────────────────────────────

    def info(self, include_disconnections: bool = False) -> dict[str, Any]:
        return self._data.info(include_disconnections)

    def get_data_quality_metrics(self) -> dict[str, Any]:
        return self._data.get_data_quality_metrics()

    def get_logs(self) -> dict[str, Any]:
        return self._data.get_logs()

    def to_parquet(self, file_path: str, compression: str = "snappy", sort: bool = True):
        self._data.to_parquet(file_path, compression, sort)

    def to_csv(self, file_path: str, separator: str = ",", include_index: bool = False):
        self._data.to_csv(file_path, separator, include_index)

    def to_excel(self, file_path: str, sheet_name: str = "glucose_data"):
        self._data.to_excel(file_path, sheet_name)

    def get_raw_data(self) -> pd.DataFrame:
        return self._data.get_raw_data()

    def get_glucose_values(self) -> pd.Series:
        return self._data.get_glucose_values()

    def get_timestamps(self) -> pd.Series:
        return self._data.get_timestamps()

    def get_time_differences(self) -> pd.Series:
        return self._data.get_time_differences()

    def get_typical_interval(self) -> float:
        return self._data.get_typical_interval()

    def filter_by_date_range(
        self,
        start: str | datetime.datetime,
        end: str | datetime.datetime,
    ) -> "GlucoseAnalysis":
        return GlucoseAnalysis(self._data.filter_by_date_range(start, end))

    def filter_by_glucose_range(
        self,
        min_g: float,
        max_g: float,
    ) -> "GlucoseAnalysis":
        return GlucoseAnalysis(self._data.filter_by_glucose_range(min_g, max_g))

    # ── Basic metrics ────────────────────────────────────────────────────

    def mean(self) -> float:
        from ..metrics.basic import mean

        return self._cached("_mean", lambda: mean(self._data.glucose))

    def median(self) -> float:
        from ..metrics.basic import median

        return self._cached("_median", lambda: median(self._data.glucose))

    def sd(self) -> float:
        from ..metrics.basic import sd

        return self._cached("_sd", lambda: sd(self._data.glucose))

    def cv(self) -> float:
        from ..metrics.basic import cv

        return cv(self._data.glucose)

    def gmi(self) -> float:
        from ..metrics.basic import gmi

        return gmi(self.mean())

    def percentile(self, p: float) -> float:
        from ..metrics.basic import percentile

        return percentile(self._data.glucose, p)

    def distribution_analysis(self) -> dict[str, Any]:
        return {
            "mean": self.mean(),
            "median": self.median(),
            "std": self.sd(),
            "cv": self.cv(),
            "skewness": self._data.glucose.skew(),
            "kurtosis": self._data.glucose.kurtosis(),
            "percentiles": {
                "p5": self.percentile(5),
                "p25": self.percentile(25),
                "p50": self.percentile(50),
                "p75": self.percentile(75),
                "p95": self.percentile(95),
                "IQR": self.percentile(75) - self.percentile(25),
            },
        }

    def calculate_all_metrics(self) -> dict[str, float]:
        return {
            "GMI": self.gmi(),
            "Mean": self.mean(),
            "Median": self.median(),
            "Std": self.sd(),
            "CV": self.cv(),
            "P5": self.percentile(5),
            "P25": self.percentile(25),
            "P75": self.percentile(75),
            "P95": self.percentile(95),
            "Skewness": self._data.glucose.skew(),
            "Kurtosis": self._data.glucose.kurtosis(),
        }

    def __str__(self) -> str:
        stats = self.calculate_all_metrics()
        return (
            "BASIC GLUCOSE METRICS\n"
            "\n"
            "CENTRAL STATISTICS:\n"
            f"  - GMI (HbA1c est.):       {stats['GMI']:.1f}%\n"
            f"  - Mean:                  {stats['Mean']:.1f} mg/dL\n"
            f"  - Median:                {stats['Median']:.1f} mg/dL\n"
            f"  - Std Dev:               {stats['Std']:.1f} mg/dL\n"
            f"  - CV:                    {stats['CV']:.1f}%\n"
            "\n"
            "PERCENTILES:\n"
            f"  - P5:                    {stats['P5']:.1f} mg/dL\n"
            f"  - P25:                   {stats['P25']:.1f} mg/dL\n"
            f"  - P75:                   {stats['P75']:.1f} mg/dL\n"
            f"  - P95:                   {stats['P95']:.1f} mg/dL\n"
            "\n"
            "DISTRIBUTION SHAPE:\n"
            f"  - Skewness:              {stats['Skewness']:.2f}\n"
            f"  - Kurtosis:              {stats['Kurtosis']:.2f}\n"
        )

    # ── Time in range ───────────────────────────────────────────────────

    def _tir_pure(self, low: float, high: float) -> float:
        from ..metrics.time_in_range import tir

        return tir(self._data.glucose, low, high)

    def _tar_pure(self, threshold: float) -> float:
        from ..metrics.time_in_range import tar

        return tar(self._data.glucose, threshold)

    def _tbr_pure(self, threshold: float) -> float:
        from ..metrics.time_in_range import tbr

        return tbr(self._data.glucose, threshold)

    def _data_completeness_pure(self, interval_minutes: float = 5) -> float:
        from ..metrics.time_in_range import data_completeness

        return data_completeness(self._data.glucose, self._data.timestamps, interval_minutes)

    @property
    def _current_targets(self) -> GlucoseTargets:
        return self._data.targets

    def TIR(self, targets: GlucoseTargets | None = None) -> float:
        if targets is None:
            targets = self._current_targets
        return self._cached(
            f"_tir_{targets.name}", lambda: self._tir_pure(targets.target_low, targets.target_high)
        )

    def TIR_tight(self) -> float:
        return self._tir_pure(70, 140)

    def TIR_pregnancy(self) -> float:
        return self._tir_pure(63, 140)

    def TAR140(self) -> float:
        return self._tar_pure(140)

    def TAR180(self) -> float:
        return self._cached("_tar180", lambda: self._tar_pure(180))

    def TAR250(self) -> float:
        return self._tar_pure(250)

    def TBR70(self) -> float:
        return self._cached("_tbr70", lambda: self._tbr_pure(70))

    def TBR63(self) -> float:
        return self._tbr_pure(63)

    def TBR55(self) -> float:
        return self._tbr_pure(55)

    def TBR_very_low(self) -> float:
        return self._tbr_pure(self._current_targets.hypo_level2)

    def TBR(self, threshold: float) -> float:
        return self._tbr_pure(threshold)

    def TAR(self, threshold: float) -> float:
        return self._tar_pure(threshold)

    def TAR_total(self) -> float:
        return self._tar_pure(self._current_targets.target_high)

    def TBR_total(self) -> float:
        return self._tbr_pure(self._current_targets.target_low)

    def data_completeness(self, expected_interval_minutes: float | None = None) -> int:
        if expected_interval_minutes is None:
            expected_interval_minutes = self._data.get_typical_interval()
        return self._cached(
            "_completeness", lambda: int(self._data_completeness_pure(expected_interval_minutes))
        )

    def time_statistics(self) -> dict[str, Any]:
        t = self._current_targets
        return {
            "target_name": t.name,
            "%Data": self.data_completeness(),
            "TIR": self.TIR(),
            "TBR_L1": self._tir_pure(t.hypo_level2, t.hypo_level1),
            "TBR_L2": self._tbr_pure(t.hypo_level2),
            "TAR_L1": self._tir_pure(t.target_high + 1, t.hyper_level2),
            "TAR_L2": self._tar_pure(t.hyper_level2),
            "TBR_total": self._tbr_pure(t.target_low),
            "TAR_total": self._tar_pure(t.target_high),
        }

    def time_range_summary(self) -> dict[str, Any]:
        t = self._current_targets
        return {
            "data_completeness": self.data_completeness(),
            "current_targets": {
                "name": t.name,
                "TIR": self.TIR(),
                "TBR_total": self._tbr_pure(t.target_low),
                "TBR_L1": self._tir_pure(t.hypo_level2, t.hypo_level1),
                "TBR_L2": self._tbr_pure(t.hypo_level2),
                "TAR_total": self._tar_pure(t.target_high),
                "TAR_L1": self._tir_pure(t.target_high + 1, t.hyper_level2),
                "TAR_L2": self._tar_pure(t.hyper_level2),
            },
            "standard_ranges": {
                "TIR": self._tir_pure(70, 180),
                "TAR180": self._tir_pure(181, 250),
                "TAR250": self._tar_pure(250),
                "TBR70": self._tir_pure(54, 70),
                "TBR54": self._tbr_pure(54),
            },
            "pregnancy_ranges": {
                "TIR_pregnancy": self._tir_pure(63, 140),
                "TBR63_L1": self._tir_pure(55, 63),
                "TBR55": self._tbr_pure(55),
                "TAR140_L1": self._tir_pure(141, 180),
                "TAR140_total": self._tar_pure(140),
            },
        }

    # ── Variability: SD metrics ─────────────────────────────────────────

    def sd_total(self) -> dict:
        from ..metrics.variability.sd import mean_global, sd_global

        return {"sd": sd_global(self._data.glucose), "mean": mean_global(self._data.glucose)}

    def sd_within_day(self, min_count_threshold: float = 0.5) -> dict:
        from ..metrics.variability.sd import sd_within_day

        return sd_within_day(self._data.glucose, self._data.timestamps, min_count_threshold)

    def sdw(self, min_count_threshold: float = 0.5) -> float:
        from ..metrics.variability.sd import sdw

        return sdw(self._data.glucose, self._data.timestamps, min_count_threshold)

    def sd_within_day_segment(self, start_time: str, duration_hours: int) -> dict:
        segment_data = self._get_segment_data(start_time, duration_hours)
        if segment_data.empty:
            return {"sd": 0.0, "mean": 0.0}
        daily_stats = segment_data.groupby(segment_data["time"].dt.date)["glucose"].agg(
            ["std", "mean"]
        )
        return {
            "sd": daily_stats["std"].mean() if not daily_stats.empty else 0.0,
            "mean": daily_stats["mean"].mean() if not daily_stats.empty else 0.0,
        }

    def sd_between_timepoints(
        self,
        min_count_threshold: float = 0.5,
        filter_outliers: bool = True,
        group_by_intervals: bool = False,
        interval_minutes: int = 5,
    ) -> dict[str, Any]:
        from ..metrics.variability.sd import sd_between_timepoints

        return sd_between_timepoints(
            self._data.glucose,
            self._data.timestamps,
            min_count_threshold,
            filter_outliers,
            group_by_intervals,
            interval_minutes,
        )

    def sd_between_timepoints_segment(self, start_time: str, duration_hours: int) -> dict:
        segment_data = self._get_segment_data(start_time, duration_hours)
        time_avg = segment_data.groupby(segment_data["time"].dt.strftime("%H:%M"))["glucose"].mean()
        return {
            "sd": time_avg.std() if not time_avg.empty else 0.0,
            "mean": time_avg.mean() if not time_avg.empty else 0.0,
        }

    def sd_within_series(self, hours: int = 1) -> dict:
        from ..metrics.variability.sd import sd_within_series

        return sd_within_series(self._data.glucose, self._data.timestamps, hours)

    def sd_daily_mean(self, min_count_threshold: float = 0.5) -> dict:
        from ..metrics.variability.sd import sd_daily_mean

        return sd_daily_mean(self._data.glucose, self._data.timestamps, min_count_threshold)

    def sd_same_timepoint(
        self,
        min_count_threshold: float = 0.5,
        filter_outliers: bool = True,
        group_by_intervals: bool = False,
        interval_minutes: int = 5,
    ) -> dict:
        from ..metrics.variability.sd import sd_same_timepoint

        return sd_same_timepoint(
            self._data.glucose,
            self._data.timestamps,
            min_count_threshold,
            filter_outliers,
            group_by_intervals,
            interval_minutes,
        )

    def sd_same_timepoint_adjusted(self) -> dict:
        from ..metrics.variability.sd import sd_same_timepoint_adjusted

        return sd_same_timepoint_adjusted(self._data.glucose, self._data.timestamps)

    def sd_interaction(self) -> dict:
        from ..metrics.variability.sd import sd_interaction

        return sd_interaction(self._data.glucose, self._data.timestamps)

    def sd_segment(self, start_time: str, duration_hours: int) -> dict:
        from ..metrics.variability.sd import sd_segment

        return sd_segment(self._data.glucose, self._data.timestamps, start_time, duration_hours)

    def _get_segment_data(self, start_time: str, duration_hours: int) -> pd.DataFrame:
        from ..metrics.variability.sd import _get_segment_mask

        mask = _get_segment_mask(self._data.timestamps, start_time, duration_hours)
        return self._data.data[mask].copy()

    def calculate_all_sd_metrics(self) -> dict:
        return {
            "SDT": self.sd_total()["sd"],
            "SDw": self.sd_within_day()["sd"],
            "SDhh:mm": self.sd_between_timepoints()["sd"],
            "SDNight": self.sd_segment("00:00", 8)["sd"],
            "Day": self.sd_segment("08:00", 8)["sd"],
            "SDEvening": self.sd_segment("16:00", 8)["sd"],
            "SDws_1h": self.sd_within_series(hours=1)["sd"],
            "SDws_6h": self.sd_within_series(hours=6)["sd"],
            "SDws_24h": self.sd_within_series(hours=24)["sd"],
            "SDdm": self.sd_daily_mean()["sd"],
            "SDbhh:mm": self.sd_same_timepoint()["sd"],
            "SDbhh:mm_dm": self.sd_same_timepoint_adjusted()["sd"],
            "SDI": self.sd_interaction()["sd"],
        }

    def calculate_all_cv_metrics(self) -> dict:
        return {
            "CVT": self.sd_total()["sd"] / self.sd_total()["mean"] * 100,
            "CVw": self.sd_within_day()["sd"] / self.sd_within_day()["mean"] * 100,
            "CVhh:mm": self.sd_between_timepoints()["sd"]
            / self.sd_between_timepoints()["mean"]
            * 100,
            "CVNight": self.sd_segment("00:00", 8)["sd"]
            / self.sd_segment("00:00", 8)["mean"]
            * 100,
            "CVDay": self.sd_segment("08:00", 8)["sd"] / self.sd_segment("08:00", 8)["mean"] * 100,
            "CVEvening": self.sd_segment("16:00", 8)["sd"]
            / self.sd_segment("16:00", 8)["mean"]
            * 100,
            "CVSDws_1h": self.sd_within_series(hours=1)["sd"]
            / self.sd_within_series(hours=1)["mean"]
            * 100,
            "CVSDws_6h": self.sd_within_series(hours=6)["sd"]
            / self.sd_within_series(hours=6)["mean"]
            * 100,
            "CVSDws_24h": self.sd_within_series(hours=24)["sd"]
            / self.sd_within_series(hours=24)["mean"]
            * 100,
            "CVdm": self.sd_daily_mean()["sd"] / self.sd_daily_mean()["mean"] * 100,
            "CVbhh:mm": self.sd_same_timepoint()["sd"] / self.sd_same_timepoint()["mean"] * 100,
            "CVbhh:mm_dm": self.sd_same_timepoint_adjusted()["sd"]
            / self.sd_same_timepoint_adjusted()["mean"]
            * 100,
            "CVSDI": self.sd_interaction()["sd"] / self.sd_interaction()["mean"] * 100,
        }

    # ── Variability: MAGE ───────────────────────────────────────────────

    def MAGE(self, threshold: float | None = None) -> float:
        from ..metrics.variability.mage import mage_simple

        if threshold is None:
            threshold = self.sd()
        return mage_simple(self._data.glucose, threshold)

    def MAGE_Baghurst(
        self,
        threshold: float | None = None,
        threshold_sd: int | None = None,
        approach: int = 1,
        plot: bool = False,
    ) -> dict:
        from ..metrics.variability.mage import (
            mage_baghurst,
            mage_baghurst_direct_elimination,
            mage_baghurst_simplified,
            mage_baghurst_smoothing,
        )

        if threshold is None:
            if threshold_sd is not None:
                threshold = threshold_sd * self.sd()
            else:
                threshold = self.sd()

        glucose_vals = self._data.glucose

        # Guard: short-circuit small / constant datasets
        if len(glucose_vals) < 9 or self.sd() == 0:
            return {
                "MAGE+": 0.0,
                "MAGE-": 0.0,
                "MAGE_avg": 0.0,
                "SD_used": round(float(self.sd()), 2),
                "threshold": round(float(threshold), 2),
                "num_excursions": 0,
                "turning_points": [],
            }

        if not plot:
            fn = {
                1: mage_baghurst_smoothing,
                2: mage_baghurst_direct_elimination,
                3: mage_baghurst_simplified,
            }.get(approach, mage_baghurst)
            return fn(glucose_vals, threshold)

        # Plot path: collect turning points from all 3 approaches, then plot
        from ..plotting.mage_excursions import plot_mage_excursions

        all_results: dict[int, dict] = {}
        for a, fn in [
            (1, mage_baghurst_smoothing),
            (2, mage_baghurst_direct_elimination),
            (3, mage_baghurst_simplified),
        ]:
            all_results[a] = fn(glucose_vals, threshold)

        turning_points = {a: r.get("turning_points", []) for a, r in all_results.items()}

        plot_mage_excursions(
            glucose=glucose_vals.values,
            times=self._data.timestamps.values,
            turning_points_approaches=turning_points,
            threshold=threshold,
            threshold_sd=threshold_sd or 1,
            mean_glucose=self.mean(),
        )

        return all_results.get(approach, all_results[1])

    # ── Variability: MODD, CONGA, Lability ─────────────────────────────

    def MODD(self, days: int = 1) -> dict:
        from ..metrics.variability.modd import modd

        return modd(self._data.glucose, self._data.timestamps, days)

    def CONGA(self, hours: int = 4, max_gap_minutes: float | None = None) -> dict:
        from ..metrics.variability.conga import conga

        return conga(self._data.glucose, self._data.timestamps, hours, max_gap_minutes)

    def Lability_index(self, interval: int = 1, period: str = "week") -> dict:
        from ..metrics.variability.lability import lability_index

        return lability_index(self._data.glucose, self._data.timestamps, interval, period)

    # ── Variability: Risk ──────────────────────────────────────────────

    def M_Value(self, reference_glucose: int = 90) -> float:
        from ..metrics.variability.risk import m_value

        return m_value(self._data.glucose, reference_glucose)

    def j_index(self) -> float:
        from ..metrics.variability.risk import j_index

        return j_index(self.mean(), self.sd())

    def GRADE(self, unit: str = "mg/dL") -> dict:
        from ..metrics.variability.risk import grade

        return grade(self._data.glucose, unit)

    def LBGI(self) -> float:
        from ..metrics.variability.risk import lbgi

        return lbgi(self._data.glucose)

    def HBGI(self) -> float:
        from ..metrics.variability.risk import hbgi

        return hbgi(self._data.glucose)

    def GRI(self, pregnancy: bool = False) -> dict:
        from ..metrics.variability.risk import gri

        return gri(self._data.glucose, pregnancy)

    def ADRR(self) -> dict:
        from ..metrics.variability.risk import adrr

        return adrr(self._data.glucose, self._data.timestamps)

    # ── Variability: Composite ──────────────────────────────────────────

    def calculate_variability_metrics(self) -> dict:
        try:
            metrics: dict[str, Any] = {
                "data_completeness": self.data_completeness(),
                "Mean": self.mean(),
                "Median": self.median(),
                "Std": self.sd(),
                "CV": self.cv(),
                "GMI": self.gmi(),
                "TIR": self.TIR(),
                "TIR_tight": self.TIR_tight(),
                "TIR_pregnancy": self.TIR_pregnancy(),
                "TAR180": self.TAR180(),
                "TAR250": self.TAR250(),
                "TAR140": self.TAR140(),
                "TBR70": self.TBR70(),
                "TBR63": self.TBR63(),
                "TBR55": self.TBR55(),
                "Skewness": float(self._data.glucose.skew()),
                "Kurtosis": float(self._data.glucose.kurtosis()),
            }

            sd = {
                "SDT": self.sd_total().get("sd"),
                "SDW": self.sd_within_day().get("sd"),
                "SD_timepoints": self.sd_between_timepoints().get("sd"),
                "SD_night": self.sd_segment("00:00", 8).get("sd"),
                "SD_day": self.sd_segment("08:00", 8).get("sd"),
                "SD_evening": self.sd_segment("16:00", 8).get("sd"),
                "SD_1h": self.sd_within_series(hours=1).get("sd"),
                "SD_6h": self.sd_within_series(hours=6).get("sd"),
                "SD_24h": self.sd_within_series(hours=24).get("sd"),
                "SD_daily_mean": self.sd_daily_mean().get("sd"),
                "SD_same_timepoint": self.sd_same_timepoint().get("sd"),
                "SD_same_timepoint_adj": self.sd_same_timepoint_adjusted().get("sd"),
                "SD_interaction": self.sd_interaction().get("sd"),
            }
            metrics.update(sd)

            conga = {
                "CONGA1": self.CONGA(hours=1).get("value"),
                "CONGA2": self.CONGA(hours=2).get("value"),
                "CONGA4": self.CONGA(hours=4).get("value"),
                "CONGA6": self.CONGA(hours=6).get("value"),
                "CONGA24": self.CONGA(hours=24).get("value"),
            }
            metrics.update(conga)

            try:
                mage_results = self.MAGE_Baghurst()
                metrics.update(
                    {
                        "mage_plus": mage_results.get("MAGE+"),
                        "mage_minus": mage_results.get("MAGE-"),
                        "mage_avg": mage_results.get("MAGE_avg"),
                        "mage_sd": mage_results.get("SD_used"),
                        "mage_threshold": mage_results.get("threshold"),
                        "mage_excursions": mage_results.get("num_excursions"),
                    }
                )
            except Exception as exc:
                logger.warning("Could not compute MAGE metrics: %s", exc)

            try:
                modd_result = self.MODD()
                metrics.update(
                    {
                        "modd": modd_result.get("value"),
                        "modd_sd": modd_result.get("std"),
                    }
                )
            except Exception as exc:
                logger.warning("Could not compute MODD metrics: %s", exc)

            try:
                lgbi = self.LBGI()
                hbgi = self.HBGI()
                adrr = self.ADRR()
                gri = self.GRI()
                gri_pregnancy = self.GRI(pregnancy=True)
                grade = self.GRADE()
                m_value = self.M_Value()
                j_index = self.j_index()

                risk = {
                    "LBGI": lgbi,
                    "HBGI": hbgi,
                    "ADRR": adrr.get("adrr") if isinstance(adrr, dict) else adrr,
                    "GRI": gri.get("GRI") if isinstance(gri, dict) else gri,
                    "GRI_high": gri.get("derived_metrics", {}).get("hyper_component", 0),
                    "GRI_low": gri.get("derived_metrics", {}).get("hypo_component", 0),
                    "GRI_pregnancy": gri_pregnancy.get("GRI")
                    if isinstance(gri_pregnancy, dict)
                    else gri_pregnancy,
                    "GRI_pregnancy_high": gri_pregnancy.get("derived_metrics", {}).get(
                        "hyper_component", 0
                    ),
                    "GRI_pregnancy_low": gri_pregnancy.get("derived_metrics", {}).get(
                        "hypo_component", 0
                    ),
                    "GRADE": grade.get("grade_score") if isinstance(grade, dict) else grade,
                    "M_Value": m_value if not isinstance(m_value, dict) else m_value.get("M_Value"),  # type: ignore[attr-defined]
                    "J_Index": j_index,
                }

                metrics.update(risk)

            except Exception as exc:
                logger.warning("Could not compute risk metrics: %s", exc)

            return metrics

        except Exception as exc:
            logger.warning("Could not assemble variability metrics: %s", exc)
            return {"error": str(exc), "message": "Error calculating metrics"}

    # ── Plots ────────────────────────────────────────────────────────────

    @staticmethod
    def _show(show: bool) -> None:
        """Display the current figure when ``show`` is True.

        Plotting functions no longer call ``plt.show()`` themselves so they
        stay composable; the facade is the single place that triggers display.
        """
        if show:
            import matplotlib.pyplot as plt

            plt.show()

    def plot_agp(self, smoothing_window: int = 15, ax=None, show: bool = True):
        from ..plotting.agp import plot_agp as _plot_agp

        fig = _plot_agp(self._data.data, smoothing_window, ax=ax)
        if ax is None:
            self._show(show)
        return fig

    def plot_daily(self, date: str | None = None, ax=None, show: bool = True):
        from ..plotting.daily_plots import day_graph

        fig = day_graph(self._data.data, date, ax=ax)
        if ax is None and fig is not None:
            self._show(show)
        return fig

    def plot_overlapping_days(self, ax=None, show: bool = True):
        from ..plotting.daily_plots import plot_overlapping_days

        fig = plot_overlapping_days(self._data.data, ax=ax)
        if ax is None:
            self._show(show)
        return fig

    def plot_week_boxplots(self, ax=None, show: bool = True):
        from ..plotting.daily_plots import plot_week_boxplots

        fig = plot_week_boxplots(self._data.data, ax=ax)
        if ax is None:
            self._show(show)
        return fig

    def generate_week_agp(
        self, smoothing_window: int = 15, combined: bool = True, show: bool = True
    ):
        from ..plotting.agp import generate_week_agp as _generate_week_agp

        fig = _generate_week_agp(
            self._data.data, smoothing_window=smoothing_window, combined=combined
        )
        self._show(show)
        return fig

    def plot_daily_variations(self, ax=None, show: bool = True):
        from ..plotting.daily_plots import plot_daily_variations

        fig = plot_daily_variations(self._data.data, ax=ax)
        if ax is None:
            self._show(show)
        return fig

    def plot_correlation_matrix(
        self, time_segments: list | None = None, ax=None, show: bool = True
    ):
        from ..plotting.statistical_plots import plot_correlation_matrix

        fig = plot_correlation_matrix(self._data.data, time_segments, ax=ax)
        if ax is None:
            self._show(show)
        return fig

    def plot_time_in_range(self, pregnancy: bool = False, show: bool = True):
        from ..plotting.statistical_plots import plot_time_in_range as _plot_time_in_range

        fig = _plot_time_in_range(
            self._data.data,
            pregnancy=pregnancy,
            tir_pregnancy=self.TIR_pregnancy() if pregnancy else 0,
            tbr63=self.TBR(63) if pregnancy else 0,
            tar140=self.TAR140() if pregnancy else 0,
            tir=self.TIR() if not pregnancy else 0,
            tbr70=self.TBR70() if not pregnancy else 0,
            tbr55=self.TBR55() if not pregnancy else 0,
            tar180=self.TAR180() if not pregnancy else 0,
            tar250=self.TAR250() if not pregnancy else 0,
        )
        self._show(show)
        return fig

    def plot_distribution_comparison(
        self, target_ranges: list[tuple] | None = None, show: bool = True
    ):
        from ..plotting.statistical_plots import plot_distribution_comparison

        fig = plot_distribution_comparison(
            self._data.data,
            target_ranges=target_ranges,
            tir_val=self.TIR(),
            tbr_val=self.TBR(70),
            tar_val=self.TAR(180),
            gmi_val=self.gmi(),
        )
        self._show(show)
        return fig

    def histogram(self, bin_width: int = 10, ax=None, show: bool = True):
        from ..plotting.statistical_plots import histogram as _histogram

        fig = _histogram(self._data.data, bin_width, ax=ax)
        if ax is None:
            self._show(show)
        return fig

    def plot_variability_dashboard(self, figsize: tuple = (14, 10), show: bool = True):
        import matplotlib.pyplot as plt

        fig, axes = plt.subplots(2, 2, figsize=figsize)
        fig.suptitle("Glucose Variability Dashboard", fontsize=16, fontweight="bold")

        # Plot 1: SD metrics bar chart
        sd_metrics = self.calculate_all_sd_metrics()
        sd_keys = list(sd_metrics.keys())
        sd_vals = [sd_metrics[k] if sd_metrics[k] is not None else 0 for k in sd_keys]
        axes[0, 0].bar(range(len(sd_keys)), sd_vals, color="steelblue", alpha=0.7)
        axes[0, 0].set_xticks(range(len(sd_keys)))
        axes[0, 0].set_xticklabels(sd_keys, rotation=45, ha="right", fontsize=8)
        axes[0, 0].set_title("Standard Deviation Metrics")
        axes[0, 0].set_ylabel("SD (mg/dL)")
        axes[0, 0].grid(True, alpha=0.3)

        # Plot 2: CONGA values bar chart
        conga_keys = ["CONGA1", "CONGA2", "CONGA4", "CONGA6", "CONGA24"]
        conga_vals = []
        for k in conga_keys:
            v = self.CONGA(hours=int(k.replace("CONGA", "")))
            conga_vals.append(v.get("value", 0))
        axes[0, 1].bar(conga_keys, conga_vals, color="coral", alpha=0.7)
        axes[0, 1].set_title("CONGA Metrics")
        axes[0, 1].set_ylabel("CONGA (mg/dL)")
        axes[0, 1].grid(True, alpha=0.3)

        # Plot 3: Risk metrics
        risk_text = (
            f"LBGI: {self.LBGI():.2f}\n"
            f"HBGI: {self.HBGI():.2f}\n"
            f"J-Index: {self.j_index():.1f}\n"
            f"M-Value: {self.M_Value():.2f}\n"
            f"MAGE: {self.MAGE():.1f} mg/dL\n"
            f"MODD: {self.MODD().get('value', 0):.1f} mg/dL"
        )
        axes[1, 0].text(
            0.1,
            0.5,
            risk_text,
            transform=axes[1, 0].transAxes,
            fontsize=12,
            fontfamily="monospace",
            verticalalignment="center",
        )
        axes[1, 0].axis("off")
        axes[1, 0].set_title("Risk & Excursion Metrics")

        # Plot 4: CV metrics
        cv_metrics = self.calculate_all_cv_metrics()
        cv_keys = list(cv_metrics.keys())
        cv_vals = [cv_metrics[k] if cv_metrics[k] is not None else 0 for k in cv_keys]
        axes[1, 1].bar(range(len(cv_keys)), cv_vals, color="seagreen", alpha=0.7)
        axes[1, 1].set_xticks(range(len(cv_keys)))
        axes[1, 1].set_xticklabels(cv_keys, rotation=45, ha="right", fontsize=8)
        axes[1, 1].set_title("Coefficient of Variation Metrics")
        axes[1, 1].set_ylabel("CV (%)")
        axes[1, 1].grid(True, alpha=0.3)

        fig.tight_layout()
        self._show(show)
        return fig

    def plot_glucose_statistics(self, figsize: tuple = (14, 10), show: bool = True):
        import matplotlib.pyplot as plt

        fig, axes = plt.subplots(2, 2, figsize=figsize)
        fig.suptitle("Glucose Statistics", fontsize=16, fontweight="bold")

        glucose_series = self._data.glucose

        # Plot 1: Histogram
        from ..plotting._constants import (
            GLUCOSE_AXIS_MAX,
            GLUCOSE_AXIS_MIN,
            TARGET_HIGH,
            TARGET_LOW,
        )

        ax = axes[0, 0]
        ax.hist(glucose_series, bins=50, edgecolor="black", alpha=0.7, color="skyblue")
        ax.axvspan(GLUCOSE_AXIS_MIN, TARGET_LOW, color="#ffcccb", alpha=0.3)
        ax.axvspan(TARGET_LOW, TARGET_HIGH, color="#90ee90", alpha=0.3)
        ax.axvspan(TARGET_HIGH, GLUCOSE_AXIS_MAX, color="#ffcccb", alpha=0.3)
        ax.set_title("Glucose Distribution")
        ax.set_xlabel("Glucose (mg/dL)")
        ax.set_ylabel("Frequency")
        ax.grid(True, alpha=0.3)

        # Plot 2: Box plot
        ax2 = axes[0, 1]
        ax2.boxplot(
            glucose_series,
            vert=True,
            patch_artist=True,
            boxprops={"facecolor": "lightblue"},
        )
        ax2.set_title("Glucose Box Plot")
        ax2.set_ylabel("Glucose (mg/dL)")
        ax2.grid(True, alpha=0.3)

        # Plot 3: Stats text
        stats = self.calculate_all_metrics()
        stats_text = (
            f"DESCRIPTIVE STATISTICS\n\n"
            f"Mean:           {stats['Mean']:.1f} mg/dL\n"
            f"Median:         {stats['Median']:.1f} mg/dL\n"
            f"Std Dev:        {stats['Std']:.1f} mg/dL\n"
            f"CV:             {stats['CV']:.1f}%\n\n"
            f"Percentiles:\n"
            f"P5:             {stats['P5']:.1f} mg/dL\n"
            f"P25:            {stats['P25']:.1f} mg/dL\n"
            f"P75:            {stats['P75']:.1f} mg/dL\n"
            f"P95:            {stats['P95']:.1f} mg/dL\n\n"
            f"Time in Range:\n"
            f"TIR (70-180):   {self.TIR():.1f}%\n"
            f"TBR (<70):      {self.TBR70():.1f}%\n"
            f"TAR (>180):     {self.TAR180():.1f}%\n\n"
            f"GMI:            {self.gmi():.1f}%"
        )
        axes[1, 0].text(
            0.1,
            0.9,
            stats_text,
            transform=axes[1, 0].transAxes,
            fontsize=10,
            fontfamily="monospace",
            verticalalignment="top",
            bbox={"boxstyle": "round", "facecolor": "lightgray", "alpha": 0.8},
        )
        axes[1, 0].axis("off")
        axes[1, 0].set_title("Summary Statistics")

        # Plot 4: Time in range pie chart
        ax4 = axes[1, 1]
        tir = self.TIR()
        tbr70 = self.TBR70()
        tbr55 = self.TBR55()
        tar180 = self.TAR180()
        tar250 = self.TAR250()
        labels = [
            "TIR\n(70-180)",
            "TBR L1\n(55-70)",
            "TBR L2\n(<55)",
            "TAR L1\n(180-250)",
            "TAR L2\n(>250)",
        ]
        sizes = [tir, tbr70, tbr55, tar180, tar250]
        colors_pie = ["#90ee90", "#ffeb9c", "#ffcccb", "#ffa500", "#ff6666"]
        non_zero = [
            (label, sz, clr)
            for label, sz, clr in zip(labels, sizes, colors_pie, strict=False)
            if sz > 0
        ]
        if non_zero:
            labels, sizes, colors_pie = map(list, zip(*non_zero, strict=False))
        ax4.pie(sizes, labels=labels, colors=colors_pie, autopct="%1.1f%%", startangle=90)
        ax4.set_title("Time in Range")

        fig.tight_layout()
        self._show(show)
        return fig

    def plot_comprehensive_dashboard(self, figsize: tuple = (20, 12), show: bool = True):
        """Render a single-figure 2x3 dashboard of single-axis plots.

        Each panel is drawn into its own grid cell (``ax=``) so the whole
        dashboard is one composed figure — unlike the previous version, which
        produced an empty grid plus a cascade of separate windows.
        """
        import matplotlib.pyplot as plt

        from ..plotting.agp import plot_agp
        from ..plotting.daily_plots import (
            plot_daily_variations,
            plot_overlapping_days,
            plot_week_boxplots,
        )
        from ..plotting.statistical_plots import histogram

        fig, axes = plt.subplots(2, 3, figsize=figsize)
        fig.suptitle("Comprehensive Glucose Analysis Dashboard", fontsize=16, fontweight="bold")

        data = self._data.data
        plot_agp(data, ax=axes[0, 0])
        histogram(data, ax=axes[0, 1])
        self._draw_tir_pie(axes[0, 2])
        plot_daily_variations(data, ax=axes[1, 0])
        plot_overlapping_days(data, ax=axes[1, 1])
        plot_week_boxplots(data, ax=axes[1, 2])

        fig.tight_layout()
        self._show(show)
        return fig

    def _draw_tir_pie(self, ax) -> None:
        """Draw a standard time-in-range pie chart into ``ax``."""
        labels = [
            "TIR\n(70-180)",
            "TBR L1\n(55-70)",
            "TBR L2\n(<55)",
            "TAR L1\n(180-250)",
            "TAR L2\n(>250)",
        ]
        sizes = [self.TIR(), self.TBR70(), self.TBR55(), self.TAR180(), self.TAR250()]
        colors_pie = ["#90ee90", "#ffeb9c", "#ffcccb", "#ffa500", "#ff6666"]
        non_zero = [
            (label, sz, clr)
            for label, sz, clr in zip(labels, sizes, colors_pie, strict=False)
            if sz > 0
        ]
        if non_zero:
            labels, sizes, colors_pie = map(list, zip(*non_zero, strict=False))
        ax.pie(sizes, labels=labels, colors=colors_pie, autopct="%1.1f%%", startangle=90)
        ax.set_title("Time in Range")

    # ── Reports ─────────────────────────────────────────────────────────

    def get_comprehensive_report(self) -> dict[str, Any]:
        return {
            "basic_info": self.info(),
            "basic_metrics": self.calculate_all_metrics(),
            "time_statistics": self.time_statistics(),
            "variability_metrics": self.calculate_variability_metrics(),
            "data_quality": self.get_data_quality_metrics(),
        }

    def get_summary_string(self) -> str:
        summary: list[str] = []
        summary.append("=== COMPREHENSIVE GLUCOSE ANALYSIS ===")
        summary.append("")

        info = self.info()
        summary.append("DATA:")
        summary.append(f"  - Records: {info['n_records']:,}")
        summary.append(
            f"  - Period: {info['start_date'].strftime('%d/%m/%Y')} - "
            f"{info['end_date'].strftime('%d/%m/%Y')}"
        )
        summary.append(f"  - Availability: {info['completeness']:.1f}%")
        summary.append("")

        basic = self.calculate_all_metrics()
        summary.append("BASIC METRICS:")
        summary.append(f"  - GMI: {basic['GMI']:.1f}%")
        summary.append(f"  - Mean: {basic['Mean']:.1f} mg/dL")
        summary.append(f"  - Median: {basic['Median']:.1f} mg/dL")
        summary.append(f"  - Std Dev: {basic['Std']:.1f} mg/dL")
        summary.append(f"  - CV: {basic['CV']:.1f}%")
        summary.append("")

        summary.append("TIME IN RANGE:")
        summary.append(f"  - TIR (70-180): {self.TIR():.1f}%")
        summary.append(f"  - TIR tight (70-140): {self.TIR_tight():.1f}%")
        summary.append(f"  - TBR70 (54-70): {self.TBR70():.1f}%")
        summary.append(f"  - TBR55 (<55): {self.TBR55():.1f}%")
        summary.append(f"  - TAR140 (>140): {self.TAR140():.1f}%")
        summary.append(f"  - TAR180 (181-250): {self.TAR180():.1f}%")
        summary.append(f"  - TAR250 (>250): {self.TAR250():.1f}%")
        summary.append("")

        variability = self.calculate_variability_metrics()
        summary.append("VARIABILITY:")
        summary.append(f"  - MAGE: {variability.get('mage_avg', 'N/A')}")
        summary.append(f"  - MODD: {variability.get('modd', 'N/A')}")
        summary.append(f"  - CONGA4: {variability.get('CONGA4', 'N/A')}")
        summary.append(f"  - SD total: {variability.get('SDT', 'N/A')}")
        summary.append(f"  - SD within-day: {variability.get('SDW', 'N/A')}")
        summary.append(f"  - SD between-day: {variability.get('SD_timepoints', 'N/A')}")

        return "\n".join(summary)

    def export_report(self, file_path: str, format: str = "json"):
        report = self.get_comprehensive_report()

        if format.lower() == "json":
            import json

            with Path(file_path).open("w", encoding="utf-8") as f:
                json.dump(report, f, indent=2, default=str, ensure_ascii=False)

        elif format.lower() == "csv":
            flat_report = self._flatten_report(report)
            flat_report.to_csv(file_path, index=False)

        elif format.lower() == "excel":
            with pd.ExcelWriter(file_path, engine="openpyxl") as writer:
                pd.DataFrame([report["basic_info"]]).to_excel(
                    writer, sheet_name="Basic_Info", index=False
                )
                pd.DataFrame([report["basic_metrics"]]).to_excel(
                    writer, sheet_name="Basic_Metrics", index=False
                )
                pd.DataFrame([report["time_statistics"]]).to_excel(
                    writer, sheet_name="Time_Range", index=False
                )

        else:
            raise ValueError(f"Unsupported format: {format}")

    @staticmethod
    def _flatten_report(report: dict[str, Any]) -> pd.DataFrame:
        flat_data: dict[str, Any] = {}
        for section, data in report.items():
            if isinstance(data, dict):
                for key, value in data.items():
                    flat_data[f"{section}_{key}"] = value
            else:
                flat_data[section] = data
        return pd.DataFrame([flat_data])

plot_agp

plot_agp(smoothing_window: int = 15, ax=None, show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_agp(self, smoothing_window: int = 15, ax=None, show: bool = True):
    from ..plotting.agp import plot_agp as _plot_agp

    fig = _plot_agp(self._data.data, smoothing_window, ax=ax)
    if ax is None:
        self._show(show)
    return fig

plot_daily

plot_daily(date: str | None = None, ax=None, show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_daily(self, date: str | None = None, ax=None, show: bool = True):
    from ..plotting.daily_plots import day_graph

    fig = day_graph(self._data.data, date, ax=ax)
    if ax is None and fig is not None:
        self._show(show)
    return fig

plot_overlapping_days

plot_overlapping_days(ax=None, show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_overlapping_days(self, ax=None, show: bool = True):
    from ..plotting.daily_plots import plot_overlapping_days

    fig = plot_overlapping_days(self._data.data, ax=ax)
    if ax is None:
        self._show(show)
    return fig

plot_week_boxplots

plot_week_boxplots(ax=None, show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_week_boxplots(self, ax=None, show: bool = True):
    from ..plotting.daily_plots import plot_week_boxplots

    fig = plot_week_boxplots(self._data.data, ax=ax)
    if ax is None:
        self._show(show)
    return fig

plot_daily_variations

plot_daily_variations(ax=None, show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_daily_variations(self, ax=None, show: bool = True):
    from ..plotting.daily_plots import plot_daily_variations

    fig = plot_daily_variations(self._data.data, ax=ax)
    if ax is None:
        self._show(show)
    return fig

histogram

histogram(bin_width: int = 10, ax=None, show: bool = True)
Source code in cgmpy/analysis/core.py
def histogram(self, bin_width: int = 10, ax=None, show: bool = True):
    from ..plotting.statistical_plots import histogram as _histogram

    fig = _histogram(self._data.data, bin_width, ax=ax)
    if ax is None:
        self._show(show)
    return fig

plot_time_in_range

plot_time_in_range(pregnancy: bool = False, show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_time_in_range(self, pregnancy: bool = False, show: bool = True):
    from ..plotting.statistical_plots import plot_time_in_range as _plot_time_in_range

    fig = _plot_time_in_range(
        self._data.data,
        pregnancy=pregnancy,
        tir_pregnancy=self.TIR_pregnancy() if pregnancy else 0,
        tbr63=self.TBR(63) if pregnancy else 0,
        tar140=self.TAR140() if pregnancy else 0,
        tir=self.TIR() if not pregnancy else 0,
        tbr70=self.TBR70() if not pregnancy else 0,
        tbr55=self.TBR55() if not pregnancy else 0,
        tar180=self.TAR180() if not pregnancy else 0,
        tar250=self.TAR250() if not pregnancy else 0,
    )
    self._show(show)
    return fig

plot_distribution_comparison

plot_distribution_comparison(target_ranges: list[tuple] | None = None, show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_distribution_comparison(
    self, target_ranges: list[tuple] | None = None, show: bool = True
):
    from ..plotting.statistical_plots import plot_distribution_comparison

    fig = plot_distribution_comparison(
        self._data.data,
        target_ranges=target_ranges,
        tir_val=self.TIR(),
        tbr_val=self.TBR(70),
        tar_val=self.TAR(180),
        gmi_val=self.gmi(),
    )
    self._show(show)
    return fig

plot_correlation_matrix

plot_correlation_matrix(time_segments: list | None = None, ax=None, show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_correlation_matrix(
    self, time_segments: list | None = None, ax=None, show: bool = True
):
    from ..plotting.statistical_plots import plot_correlation_matrix

    fig = plot_correlation_matrix(self._data.data, time_segments, ax=ax)
    if ax is None:
        self._show(show)
    return fig

generate_week_agp

generate_week_agp(smoothing_window: int = 15, combined: bool = True, show: bool = True)
Source code in cgmpy/analysis/core.py
def generate_week_agp(
    self, smoothing_window: int = 15, combined: bool = True, show: bool = True
):
    from ..plotting.agp import generate_week_agp as _generate_week_agp

    fig = _generate_week_agp(
        self._data.data, smoothing_window=smoothing_window, combined=combined
    )
    self._show(show)
    return fig

plot_variability_dashboard

plot_variability_dashboard(figsize: tuple = (14, 10), show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_variability_dashboard(self, figsize: tuple = (14, 10), show: bool = True):
    import matplotlib.pyplot as plt

    fig, axes = plt.subplots(2, 2, figsize=figsize)
    fig.suptitle("Glucose Variability Dashboard", fontsize=16, fontweight="bold")

    # Plot 1: SD metrics bar chart
    sd_metrics = self.calculate_all_sd_metrics()
    sd_keys = list(sd_metrics.keys())
    sd_vals = [sd_metrics[k] if sd_metrics[k] is not None else 0 for k in sd_keys]
    axes[0, 0].bar(range(len(sd_keys)), sd_vals, color="steelblue", alpha=0.7)
    axes[0, 0].set_xticks(range(len(sd_keys)))
    axes[0, 0].set_xticklabels(sd_keys, rotation=45, ha="right", fontsize=8)
    axes[0, 0].set_title("Standard Deviation Metrics")
    axes[0, 0].set_ylabel("SD (mg/dL)")
    axes[0, 0].grid(True, alpha=0.3)

    # Plot 2: CONGA values bar chart
    conga_keys = ["CONGA1", "CONGA2", "CONGA4", "CONGA6", "CONGA24"]
    conga_vals = []
    for k in conga_keys:
        v = self.CONGA(hours=int(k.replace("CONGA", "")))
        conga_vals.append(v.get("value", 0))
    axes[0, 1].bar(conga_keys, conga_vals, color="coral", alpha=0.7)
    axes[0, 1].set_title("CONGA Metrics")
    axes[0, 1].set_ylabel("CONGA (mg/dL)")
    axes[0, 1].grid(True, alpha=0.3)

    # Plot 3: Risk metrics
    risk_text = (
        f"LBGI: {self.LBGI():.2f}\n"
        f"HBGI: {self.HBGI():.2f}\n"
        f"J-Index: {self.j_index():.1f}\n"
        f"M-Value: {self.M_Value():.2f}\n"
        f"MAGE: {self.MAGE():.1f} mg/dL\n"
        f"MODD: {self.MODD().get('value', 0):.1f} mg/dL"
    )
    axes[1, 0].text(
        0.1,
        0.5,
        risk_text,
        transform=axes[1, 0].transAxes,
        fontsize=12,
        fontfamily="monospace",
        verticalalignment="center",
    )
    axes[1, 0].axis("off")
    axes[1, 0].set_title("Risk & Excursion Metrics")

    # Plot 4: CV metrics
    cv_metrics = self.calculate_all_cv_metrics()
    cv_keys = list(cv_metrics.keys())
    cv_vals = [cv_metrics[k] if cv_metrics[k] is not None else 0 for k in cv_keys]
    axes[1, 1].bar(range(len(cv_keys)), cv_vals, color="seagreen", alpha=0.7)
    axes[1, 1].set_xticks(range(len(cv_keys)))
    axes[1, 1].set_xticklabels(cv_keys, rotation=45, ha="right", fontsize=8)
    axes[1, 1].set_title("Coefficient of Variation Metrics")
    axes[1, 1].set_ylabel("CV (%)")
    axes[1, 1].grid(True, alpha=0.3)

    fig.tight_layout()
    self._show(show)
    return fig

plot_glucose_statistics

plot_glucose_statistics(figsize: tuple = (14, 10), show: bool = True)
Source code in cgmpy/analysis/core.py
def plot_glucose_statistics(self, figsize: tuple = (14, 10), show: bool = True):
    import matplotlib.pyplot as plt

    fig, axes = plt.subplots(2, 2, figsize=figsize)
    fig.suptitle("Glucose Statistics", fontsize=16, fontweight="bold")

    glucose_series = self._data.glucose

    # Plot 1: Histogram
    from ..plotting._constants import (
        GLUCOSE_AXIS_MAX,
        GLUCOSE_AXIS_MIN,
        TARGET_HIGH,
        TARGET_LOW,
    )

    ax = axes[0, 0]
    ax.hist(glucose_series, bins=50, edgecolor="black", alpha=0.7, color="skyblue")
    ax.axvspan(GLUCOSE_AXIS_MIN, TARGET_LOW, color="#ffcccb", alpha=0.3)
    ax.axvspan(TARGET_LOW, TARGET_HIGH, color="#90ee90", alpha=0.3)
    ax.axvspan(TARGET_HIGH, GLUCOSE_AXIS_MAX, color="#ffcccb", alpha=0.3)
    ax.set_title("Glucose Distribution")
    ax.set_xlabel("Glucose (mg/dL)")
    ax.set_ylabel("Frequency")
    ax.grid(True, alpha=0.3)

    # Plot 2: Box plot
    ax2 = axes[0, 1]
    ax2.boxplot(
        glucose_series,
        vert=True,
        patch_artist=True,
        boxprops={"facecolor": "lightblue"},
    )
    ax2.set_title("Glucose Box Plot")
    ax2.set_ylabel("Glucose (mg/dL)")
    ax2.grid(True, alpha=0.3)

    # Plot 3: Stats text
    stats = self.calculate_all_metrics()
    stats_text = (
        f"DESCRIPTIVE STATISTICS\n\n"
        f"Mean:           {stats['Mean']:.1f} mg/dL\n"
        f"Median:         {stats['Median']:.1f} mg/dL\n"
        f"Std Dev:        {stats['Std']:.1f} mg/dL\n"
        f"CV:             {stats['CV']:.1f}%\n\n"
        f"Percentiles:\n"
        f"P5:             {stats['P5']:.1f} mg/dL\n"
        f"P25:            {stats['P25']:.1f} mg/dL\n"
        f"P75:            {stats['P75']:.1f} mg/dL\n"
        f"P95:            {stats['P95']:.1f} mg/dL\n\n"
        f"Time in Range:\n"
        f"TIR (70-180):   {self.TIR():.1f}%\n"
        f"TBR (<70):      {self.TBR70():.1f}%\n"
        f"TAR (>180):     {self.TAR180():.1f}%\n\n"
        f"GMI:            {self.gmi():.1f}%"
    )
    axes[1, 0].text(
        0.1,
        0.9,
        stats_text,
        transform=axes[1, 0].transAxes,
        fontsize=10,
        fontfamily="monospace",
        verticalalignment="top",
        bbox={"boxstyle": "round", "facecolor": "lightgray", "alpha": 0.8},
    )
    axes[1, 0].axis("off")
    axes[1, 0].set_title("Summary Statistics")

    # Plot 4: Time in range pie chart
    ax4 = axes[1, 1]
    tir = self.TIR()
    tbr70 = self.TBR70()
    tbr55 = self.TBR55()
    tar180 = self.TAR180()
    tar250 = self.TAR250()
    labels = [
        "TIR\n(70-180)",
        "TBR L1\n(55-70)",
        "TBR L2\n(<55)",
        "TAR L1\n(180-250)",
        "TAR L2\n(>250)",
    ]
    sizes = [tir, tbr70, tbr55, tar180, tar250]
    colors_pie = ["#90ee90", "#ffeb9c", "#ffcccb", "#ffa500", "#ff6666"]
    non_zero = [
        (label, sz, clr)
        for label, sz, clr in zip(labels, sizes, colors_pie, strict=False)
        if sz > 0
    ]
    if non_zero:
        labels, sizes, colors_pie = map(list, zip(*non_zero, strict=False))
    ax4.pie(sizes, labels=labels, colors=colors_pie, autopct="%1.1f%%", startangle=90)
    ax4.set_title("Time in Range")

    fig.tight_layout()
    self._show(show)
    return fig

plot_comprehensive_dashboard

plot_comprehensive_dashboard(figsize: tuple = (20, 12), show: bool = True)

Render a single-figure 2x3 dashboard of single-axis plots.

Each panel is drawn into its own grid cell (ax=) so the whole dashboard is one composed figure — unlike the previous version, which produced an empty grid plus a cascade of separate windows.

Source code in cgmpy/analysis/core.py
def plot_comprehensive_dashboard(self, figsize: tuple = (20, 12), show: bool = True):
    """Render a single-figure 2x3 dashboard of single-axis plots.

    Each panel is drawn into its own grid cell (``ax=``) so the whole
    dashboard is one composed figure — unlike the previous version, which
    produced an empty grid plus a cascade of separate windows.
    """
    import matplotlib.pyplot as plt

    from ..plotting.agp import plot_agp
    from ..plotting.daily_plots import (
        plot_daily_variations,
        plot_overlapping_days,
        plot_week_boxplots,
    )
    from ..plotting.statistical_plots import histogram

    fig, axes = plt.subplots(2, 3, figsize=figsize)
    fig.suptitle("Comprehensive Glucose Analysis Dashboard", fontsize=16, fontweight="bold")

    data = self._data.data
    plot_agp(data, ax=axes[0, 0])
    histogram(data, ax=axes[0, 1])
    self._draw_tir_pie(axes[0, 2])
    plot_daily_variations(data, ax=axes[1, 0])
    plot_overlapping_days(data, ax=axes[1, 1])
    plot_week_boxplots(data, ax=axes[1, 2])

    fig.tight_layout()
    self._show(show)
    return fig

get_summary_string

get_summary_string() -> str
Source code in cgmpy/analysis/core.py
def get_summary_string(self) -> str:
    summary: list[str] = []
    summary.append("=== COMPREHENSIVE GLUCOSE ANALYSIS ===")
    summary.append("")

    info = self.info()
    summary.append("DATA:")
    summary.append(f"  - Records: {info['n_records']:,}")
    summary.append(
        f"  - Period: {info['start_date'].strftime('%d/%m/%Y')} - "
        f"{info['end_date'].strftime('%d/%m/%Y')}"
    )
    summary.append(f"  - Availability: {info['completeness']:.1f}%")
    summary.append("")

    basic = self.calculate_all_metrics()
    summary.append("BASIC METRICS:")
    summary.append(f"  - GMI: {basic['GMI']:.1f}%")
    summary.append(f"  - Mean: {basic['Mean']:.1f} mg/dL")
    summary.append(f"  - Median: {basic['Median']:.1f} mg/dL")
    summary.append(f"  - Std Dev: {basic['Std']:.1f} mg/dL")
    summary.append(f"  - CV: {basic['CV']:.1f}%")
    summary.append("")

    summary.append("TIME IN RANGE:")
    summary.append(f"  - TIR (70-180): {self.TIR():.1f}%")
    summary.append(f"  - TIR tight (70-140): {self.TIR_tight():.1f}%")
    summary.append(f"  - TBR70 (54-70): {self.TBR70():.1f}%")
    summary.append(f"  - TBR55 (<55): {self.TBR55():.1f}%")
    summary.append(f"  - TAR140 (>140): {self.TAR140():.1f}%")
    summary.append(f"  - TAR180 (181-250): {self.TAR180():.1f}%")
    summary.append(f"  - TAR250 (>250): {self.TAR250():.1f}%")
    summary.append("")

    variability = self.calculate_variability_metrics()
    summary.append("VARIABILITY:")
    summary.append(f"  - MAGE: {variability.get('mage_avg', 'N/A')}")
    summary.append(f"  - MODD: {variability.get('modd', 'N/A')}")
    summary.append(f"  - CONGA4: {variability.get('CONGA4', 'N/A')}")
    summary.append(f"  - SD total: {variability.get('SDT', 'N/A')}")
    summary.append(f"  - SD within-day: {variability.get('SDW', 'N/A')}")
    summary.append(f"  - SD between-day: {variability.get('SD_timepoints', 'N/A')}")

    return "\n".join(summary)

get_comprehensive_report

get_comprehensive_report() -> dict[str, Any]
Source code in cgmpy/analysis/core.py
def get_comprehensive_report(self) -> dict[str, Any]:
    return {
        "basic_info": self.info(),
        "basic_metrics": self.calculate_all_metrics(),
        "time_statistics": self.time_statistics(),
        "variability_metrics": self.calculate_variability_metrics(),
        "data_quality": self.get_data_quality_metrics(),
    }