This analysis was conducted as part of the broader GLYSIMI study, which investigates the role of the human small intestinal microbiota in shaping postprandial glucose responses (PPGR) to standardized meals.
Within this project, the focus was on characterising individual glucose response patterns. Specifically, the analysis aimed to assess (1) the consistency of glycaemic responses within individuals to repeated consumption of the same meal, and (2) difference in responses to different food products across participants.
Participants consumed a set of standardized breakfast and dinner meals designed to elicit postprandial glucose responses.
Breakfast meals (each containing ~50 g carbohydrates): - Whole grain wheat bread with cream cheese (~390 kcal; WG_Bread) - Refined wheat bread with cream cheese (~350 kcal; R_Bread) - Mixed dried fruit (banana and apple, 50:50 w/w) with yogurt drink (~275 kcal; Fruit_Yoghurt) - Gingerbread with yogurt drink (~285 kcal; Gingerbread_Yoghurt)
Dinner meals (~68 g carbohydrates): - Refined wheat pasta with tomato sauce, soy-based minced meat replacer, and vegetables (~680 kcal; Pasta) - Whole grain rice with soy-based meat replacer, ketjap sauce, and vegetables (~735 kcal; Nasi)
In addition, participants consumed a glucose control drink containing 50 g of glucose.
Continuous glucose measurements were obtained using the FreeStyle Libre sensor system. These data were integrated with dietary intake records collected via the Traqq application, where participants logged meal timing. An additional dataset provided information on the allocation of specific food products to participants across study days.
The analysis focused on quantifying postprandial responses using incremental area under the curve (iAUC) above baseline. In addition, glucose response curves were explored and compared across individuals and meal types to assess patterns in glycaemic variability.
knitr::opts_chunk$set(echo = TRUE)
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.2.0 ✔ readr 2.2.0
## ✔ forcats 1.0.1 ✔ stringr 1.6.0
## ✔ ggplot2 4.0.2 ✔ tibble 3.3.1
## ✔ lubridate 1.9.5 ✔ tidyr 1.3.2
## ✔ purrr 1.2.1
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(ggpubr)
library(pals)
library(kableExtra)
##
## Attaching package: 'kableExtra'
##
## The following object is masked from 'package:dplyr':
##
## group_rows
library(rstatix)
##
## Attaching package: 'rstatix'
##
## The following object is masked from 'package:stats':
##
## filter
library(here)
## here() starts at C:/Users/malin009/OneDrive - Wageningen University & Research/Git/CGM_data
library(dplyr)
library(data.table)
##
## Attaching package: 'data.table'
##
## The following objects are masked from 'package:lubridate':
##
## hour, isoweek, isoyear, mday, minute, month, quarter, second, wday,
## week, yday, year
##
## The following objects are masked from 'package:dplyr':
##
## between, first, last
##
## The following object is masked from 'package:purrr':
##
## transpose
library(lubridate)
library(janitor)
##
## Attaching package: 'janitor'
##
## The following object is masked from 'package:rstatix':
##
## make_clean_names
##
## The following objects are masked from 'package:stats':
##
## chisq.test, fisher.test
library(readxl)
library(ggsci)
library(hms)
##
## Attaching package: 'hms'
##
## The following object is masked from 'package:lubridate':
##
## hms
library(fuzzyjoin)
## Warning: package 'fuzzyjoin' was built under R version 4.5.3
library(ggrepel)
library(DescTools)
## Warning: package 'DescTools' was built under R version 4.5.3
##
## Attaching package: 'DescTools'
##
## The following object is masked from 'package:data.table':
##
## %like%
library(lme4)
## Loading required package: Matrix
##
## Attaching package: 'Matrix'
##
## The following objects are masked from 'package:tidyr':
##
## expand, pack, unpack
library(purrr)
library(broom.mixed)
## Warning: package 'broom.mixed' was built under R version 4.5.3
library(lmerTest)
## Warning: package 'lmerTest' was built under R version 4.5.3
##
## Attaching package: 'lmerTest'
##
## The following object is masked from 'package:lme4':
##
## lmer
##
## The following object is masked from 'package:stats':
##
## step
library(patchwork)
here::i_am("Postprandial_GM.Rmd")
## here() starts at C:/Users/malin009/OneDrive - Wageningen University & Research/Git/CGM_data
script_path <- here::here()
cat("The project root is located at:", script_path, "\n")
## The project root is located at: C:/Users/malin009/OneDrive - Wageningen University & Research/Git/CGM_data
setwd(script_path)
Continuous glucose monitoring (CGM) data were imported from multiple
raw .csv files. An initial file was inspected to determine
relevant columns and file structure, which informed the selection of
variables and removal of non-data rows.
Participant identifiers were extracted from file names and appended to each dataset prior to merging into a single dataframe.
Before converting variables to numeric and datetime formats, the
structure of the Timestamp column was explored by
quantifying the occurrence of specific characters (e.g. dashes, colons,
slashes). This step ensured consistent parsing of date-time values.
Glucose values were converted to numeric format (handling comma decimal separators), and timestamps were parsed into standard datetime objects. As historical measurements were included in the raw exports, data prior to 2022 were excluded from further analysis.
Missing CGM observations were excluded to ensure that baseline glucose values reflected the closest valid preprandial measurement.
# Read all files from the raw_data folder (in work directory) and select only columns of interest, remove first rows in each file
files <- list.files(path = "./raw_data", recursive=TRUE, full.names = TRUE)
my_cols<- c("Timestamp", "glucose mmol/l")
tbl_fread <- map(files, ~fread(.x, skip = "Apparat", select = my_cols))
# the following code adds a column in each df in a list with a name of participant
part_id <- tools::file_path_sans_ext(basename(files))
tbl_fread <- map2(tbl_fread, part_id, ~mutate(.x, part_id = .y))
# join data frames from list
tbl_fread_df <- bind_rows(tbl_fread)
# explore Timestamp column
characters_in_string<- tbl_fread_df %>% mutate(dashes =str_count(Timestamp, pattern="-"),
colon =str_count(Timestamp, pattern=":"),
slash =str_count(Timestamp, pattern="/")) %>% select(dashes, colon, slash) %>%
pivot_longer(cols = c("dashes", "colon", "slash"), names_to = "character", values_to = "number")
ggplot(characters_in_string, aes(number))+
geom_histogram()+
facet_wrap(~character)+
theme_bw()
## `stat_bin()` using `bins = 30`. Pick better value `binwidth`.
# looks like date string has always 1 colon, 2 dashes and 0 slashes, therefore the date looks like: DD/MM/YYYY HH:MM
# changing Timestamp string into date format and glucose measurement into numeric
tbl_fread_df<- tbl_fread_df %>%
mutate(Timestamp = parse_date_time(Timestamp, "dmy HM"), CGM_glucose = as.numeric(gsub(",", ".", `glucose mmol/l`)))%>%
filter(year(Timestamp) >= 2022) %>% select(-`glucose mmol/l`)
# deleting timepoint with no glucose measurement
tbl_fread_df <- tbl_fread_df %>%
drop_na(CGM_glucose)
Dietary intake data consisted of two sources:
(1) data exported from the Traqq application, where participants
recorded meal timing, and
(2) a study protocol defining the planned food products assigned to each
participant.
The Traqq dataset was originally in Dutch. After initial inspection, relevant columns were selected and renamed for clarity. Meal types were derived from session labels (e.g. breakfast vs dinner), based on consultation with colleagues involved in the study setup.
Meal timing information was stored separately as date and time (in seconds), requiring conversion into a unified datetime format to enable integration with CGM data.
Additional variables describing meal completion and quantity consumed were consolidated from separate columns (breakfast vs dinner) into unified fields for downstream analysis.
The code below shows the selection and transformation of variables from traqq application used in further analysis.
app_data<- read.csv("part_portfolio_app.csv")
app_data <- app_data %>% select(-1)
app_data<- app_data %>% select(Subject: Ontbijt.volledig) %>%
mutate(meal = case_when(Session.Name == "Nuttigen maaltijd?" ~ "Dinner",
Session.Name == "Activeiten vandaag?" ~ "Breakfast",
TRUE ~ NA_character_)) %>%
unite(col = "time", c(Hoe.laat.maaltijd, Hoe.laat.ontbijt), na.rm = TRUE) %>%
mutate(time = as_hms(as.numeric(time)), # seconds → hms
Datetime = as.POSIXct(ymd(Date)) + as.numeric(time),
Date = ymd(Date),
breakfast_duration = hms::as_hms(Duur.ontbijt),
completed = coalesce(Maaltijd.volledig, Ontbijt.volledig),
amount = coalesce(Hoeveel.maaltijd..gram., Hoeveel.ontbijt))%>%
select(-Session.Name) %>%
select(Subject, Date, time, Datetime, breakfast_duration, meal, completed, amount) %>%
drop_na(time)
The code below demonstrates the import of the study protocol containing information on allocated test products, followed by merging with Traqq application data to generate a complete dietary dataset.
products<- read.csv("products_portfolio.csv") %>% select(-1) %>%
mutate(Date = dmy(Date))
eaten_products<- app_data %>% left_join(products, by = c("Subject", "Date", "meal"))
Merging CGM measurements with dietary data
To accurately quantify postprandial responses, baseline glucose was defined as the closest CGM measurement preceding each meal. This avoids bias introduced by postprandial glucose increases when no pre-meal measurement is available.
baseline <- tbl_fread_df %>%
fuzzy_left_join(eaten_products, by = c("part_id" = "Subject", "Timestamp" = "Datetime"),
match_fun = list(`==`, `<=`)) %>% # ONLY values before meal
drop_na(meal) %>% # to ensure only meal moments are considered
group_by(part_id, Datetime) %>%
slice_max(Timestamp, n = 1, with_ties = FALSE) %>% # closest before
ungroup() %>%
rename(baseline_glucose = CGM_glucose)
Before baseline measurement will be joined with 2h frame CGM measurement the window time before meal consumption will be calculated to evaluate acceptability of this gap. 30 minute gap will be still accepted regarding that the sensor saves measurements every 30 minutes and sometime participants might have forgotten to scan it with the application..
baseline <- baseline %>%
mutate(baseline_gap = as.numeric(difftime(Datetime, Timestamp, units = "mins")),
baseline_quality = case_when(baseline_gap <= 30 ~ "good",
baseline_gap <= 45 ~ "acceptable",
TRUE ~ "poor"))
baseline %>% filter(baseline_quality != "good") %>% group_by(product, baseline_quality) %>%count()
## # A tibble: 4 × 3
## # Groups: product, baseline_quality [4]
## product baseline_quality n
## <chr> <chr> <int>
## 1 Nasi acceptable 1
## 2 Pasta acceptable 1
## 3 Pasta poor 1
## 4 WG_Bread poor 1
baseline %>% filter(baseline_quality != "good") %>% select(part_id, product, Date, baseline_quality, baseline_gap)
## # A tibble: 4 × 5
## part_id product Date baseline_quality baseline_gap
## <chr> <chr> <date> <chr> <dbl>
## 1 P03 Pasta 2022-02-10 acceptable 31
## 2 P03 Pasta 2022-02-13 poor 61
## 3 P09 Nasi 2022-02-23 acceptable 32
## 4 P10 WG_Bread 2022-02-07 poor 70
In two cases, the temporal gap between meal onset and baseline glucose measurement exceeded one hour, which may compromise baseline estimation. This occurred for the Pasta meal in participant P03 and the WG_Bread meal in participant P10. These time series should therefore be interpreted with caution and may be excluded from downstream analyses if considered biologically unreliable.
Glucose response curves were extracted over a 2-hour window following each meal. Due to the discrete nature of CGM measurements, exact alignment with meal timing was not always possible; All available measurements within the defined window and after start of meal eating were included.
response <- tbl_fread_df %>%
fuzzy_inner_join(eaten_products, by = c("part_id" = "Subject",
"Timestamp" = "Datetime"),
match_fun = list(`==`, function(x, y) x >= y & x <= y + lubridate::hours(2)))
full_response<- baseline %>% select(-baseline_gap) %>% rename(CGM_glucose = baseline_glucose) %>%
bind_rows(response) %>% arrange(Timestamp) %>%
group_by(part_id, Date, product) %>% fill(baseline_quality) %>%
mutate(time_from_meal = as.numeric(difftime(Timestamp, Datetime, units = "mins"))) %>% ungroup
To assess the quality of postprandial glucose response curves, a quality control procedure was conducted at the meal-event level. For each participant and meal occasion, the number of available CGM measurements was calculated, together with the timing of the first and last measurements within the 2-hour postprandial window as well as the coverage of the 2-hour window.
qc_response <- full_response %>%
group_by(part_id, Date, product) %>%
summarise(first_measurement = min(time_from_meal),
n_measurements = n(),
last_measurement = max(time_from_meal),
coverage_ratio = (last_measurement - first_measurement) / 120,
.groups = "drop") %>%
mutate(coverage_description = case_when(coverage_ratio<=0.2 ~ "no",
coverage_ratio<=0.6 ~ "poor",
coverage_ratio<0.8 ~ "to examine",
coverage_ratio>=0.8 ~ "good")) %>%
mutate(coverage_description = fct_relevel(as.factor(coverage_description), c("no", "poor", "to examine", "good")))
Exploratory visualisations were used to assess variability in measurement density and temporal coverage across participants and meal types.
qc_response %>% ggplot(aes(x = first_measurement, y = last_measurement, shape = coverage_description, fill = n_measurements)) +
geom_point(size = 3, alpha = 0.6, aes(x = first_measurement, y = last_measurement, shape = coverage_description, fill = n_measurements)) +
scale_shape_manual(values = c(25, 24, 23, 21)) +
geom_text_repel(data=(qc_response %>%filter(n_measurements <3 | coverage_ratio<0.8)), size = 3, aes(label = part_id))+
scale_fill_gsea() +
facet_wrap(~product)+
theme_bw()
Visual inspection of postprandial glucose response curves indicated good data quality for the following meals: Fruit_Yoghurt, Gingerbread_Yoghurt, WG_Bread, and the glucose drink control.
In contrast, several response curves (Pasta, Nasi, R_Bread, and one WG_Bread observation) exhibited incomplete temporal coverage, with the final available CGM measurement frequently occurring approximately 70–80 minutes after meal consumption.
qc_response %>% filter(coverage_ratio<0.8 | n_measurements <6 | first_measurement<=-30 | last_measurement < 60) %>% arrange(coverage_ratio) %>% kbl() %>%
kableExtra::kable_paper(font_size = 12) %>%
scroll_box(width = "800px", height = "500px")
| part_id | Date | product | first_measurement | n_measurements | last_measurement | coverage_ratio | coverage_description |
|---|---|---|---|---|---|---|---|
| P03 | 2022-02-10 | Pasta | -31 | 1 | -31 | 0.0000000 | no |
| P03 | 2022-02-13 | Pasta | -61 | 1 | -61 | 0.0000000 | no |
| P03 | 2022-02-14 | Pasta | 0 | 2 | 0 | 0.0000000 | no |
| P03 | 2022-02-15 | Nasi | -28 | 1 | -28 | 0.0000000 | no |
| P03 | 2022-02-16 | Nasi | -22 | 1 | -22 | 0.0000000 | no |
| P06 | 2022-02-10 | Pasta | -5 | 1 | -5 | 0.0000000 | no |
| P09 | 2022-02-23 | Nasi | -32 | 1 | -32 | 0.0000000 | no |
| P06 | 2022-02-14 | Nasi | -2 | 2 | 13 | 0.1250000 | no |
| P09 | 2022-03-01 | Nasi | -14 | 2 | 1 | 0.1250000 | no |
| P01 | 2022-02-09 | R_Bread | -11 | 4 | 34 | 0.3750000 | poor |
| P09 | 2022-02-27 | Pasta | -6 | 4 | 39 | 0.3750000 | poor |
| P09 | 2022-03-02 | Nasi | -8 | 5 | 52 | 0.5000000 | poor |
| P06 | 2022-02-09 | R_Bread | -2 | 6 | 73 | 0.6250000 | to examine |
| P09 | 2022-02-24 | Pasta | -4 | 6 | 71 | 0.6250000 | to examine |
| P06 | 2022-02-16 | R_Bread | -3 | 7 | 87 | 0.7500000 | to examine |
| P10 | 2022-02-17 | R_Bread | -6 | 7 | 84 | 0.7500000 | to examine |
| P09 | 2022-02-25 | glucose_drink | -2 | 5 | 108 | 0.9166667 | good |
| P01 | 2022-02-08 | Pasta | -26 | 5 | 107 | 1.1083333 | good |
| P10 | 2022-02-07 | WG_Bread | -70 | 4 | 111 | 1.5083333 | good |
Based on this quality assessment, response curves with insufficient postprandial coverage were excluded from downstream analyses. Additionally, postprandial response curves with baseline glucose measurements obtained more than 1 hour before meal consumption were excluded from further analysis. This decision was made to ensure comparability of iAUC estimates across meals and participants.
The code below generates a vector of participant–date–product combinations that were removed from the full response dataset due to poor temporal coverage.
records_to_exclude<- qc_response %>% filter(coverage_ratio<0.8 | first_measurement < -60) %>%
arrange(coverage_ratio) %>%
mutate(exclude = paste0(part_id, "_", Date, "_", product)) %>% pull(exclude)
Below, the final analytical dataset is prepared following quality control and exclusion procedures. Data quality was reassessed through additional visual inspection of postprandial glucose response curves.
Subsequently, repeated consumption occasions of the same test products were identified by assigning consecutive meal numbers within each participant, as most products (except the glucose drink control) were consumed multiple times.
full_response_qc<- full_response %>% mutate(exclude = paste0(part_id, "_", Date, "_", product)) %>%
filter(!exclude %in% records_to_exclude) %>% select(-exclude)
full_response_qc %>% group_by(part_id, Date, product) %>%
summarise(first_measurement = min(time_from_meal),
n_measurements = n(),
last_measurement = max(time_from_meal),
coverage_ratio = (last_measurement - first_measurement) / 120,
.groups = "drop") %>%
mutate(coverage_description = case_when(coverage_ratio<=0.2 ~ "no",
coverage_ratio<=0.6 ~ "poor",
coverage_ratio<0.8 ~ "to examine",
coverage_ratio>=0.8 ~ "good")) %>%
mutate(coverage_description = fct_relevel(as.factor(coverage_description), c("no", "poor", "to examine", "good"))) %>%
ggplot(aes(x = first_measurement, y = last_measurement, shape = coverage_description, fill = n_measurements)) +
geom_point(size = 3, alpha = 0.6, aes(x = first_measurement, y = last_measurement, shape = coverage_description, fill = n_measurements)) +
scale_shape_manual(values = c(25, 24, 23, 21)) +
scale_fill_gsea() +
facet_wrap(~product)+
theme_bw()
## Warning: There was 1 warning in `mutate()`.
## ℹ In argument: `coverage_description = fct_relevel(...)`.
## Caused by warning:
## ! 3 unknown levels in `f`: no, poor, and to examine
full_response_qc<- full_response_qc %>%
arrange(part_id, product, Date) %>%
group_by(part_id, product) %>% mutate(consumption = dense_rank(Date)) %>%
ungroup %>%
unique %>%
mutate(completed = if_else(completed == "1", "yes", "no")) %>%
mutate(completed = fct_relevel(as.factor(completed), c("yes", "no"))) %>%
mutate(consumption = case_when(consumption == "1" ~ "first",
consumption == "2" ~ "second",
consumption == "3" ~ "third",
consumption == "4" ~ "fourth",
consumption == "5" ~ "fifth"))
Below, several postprandial glycemic response curves are presented alongside the reference response to the glucose drink. The first panel shows breakfasts with a caloric content of approximately 280 kcal, the second panel includes breakfasts ranging from 350–390 kcal, and the third panel displays all dinner meals.
# plot caloric content of approximately 280 kcal
full_response_qc %>% filter(product %in% c("Fruit_Yoghurt", "Gingerbread_Yoghurt", "glucose_drink")) %>%
mutate(product = fct_relevel(as.factor(product), c("Fruit_Yoghurt", "Gingerbread_Yoghurt", "glucose_drink"))) %>%
ggplot(aes(x=time_from_meal, y = CGM_glucose, group = interaction(part_id, product, consumption)))+
geom_point(size = 3, color = "black", aes(shape = completed, fill = product))+
scale_shape_manual(values = c(21, 24), name = "completed")+
geom_smooth(se = FALSE, aes(color = product, linetype = consumption))+
scale_color_manual(values = pal_bmj("default")(9)[c(1,2,9)])+
scale_fill_manual(values = pal_bmj("default")(9)[c(1,2,9)])+
facet_wrap(~part_id, scales = "free")+
labs(x = "time from consumption [min]", y = "glucose concentration [mmol/l]", title = "Individual glycemic response to breakfast meal", subtitle = "fruit with yoghurt, gingerbread with yoghurt and standard glucose drink")+
guides(color = guide_legend(ncol = 1, override.aes = list(shape = 21)),
linetype = guide_legend(ncol = 1, override.aes = list(color = "black")),
shape = guide_legend(ncol = 1)) +
theme_bw()+
theme(legend.box = "horizontal", legend.position = c(0.75, 0.2))
## `geom_smooth()` using method = 'loess' and formula = 'y ~ x'
# plot caloric content of 350-390 kcal
full_response_qc %>% filter(product %in% c("WG_Bread", "R_Bread", "glucose_drink")) %>%
mutate(product = fct_relevel(as.factor(product), c("WG_Bread", "R_Bread", "glucose_drink")))%>%
ggplot(aes(x=time_from_meal, y = CGM_glucose, group = interaction(part_id, product, consumption)))+
geom_point(size = 3, color = "black", aes(shape = completed, fill = product))+
scale_shape_manual(values = c(21, 24), name = "completed")+
geom_smooth(se = FALSE, aes(color = product, linetype = consumption))+
scale_color_manual(values = pal_bmj("default")(9)[c(4,5,9)])+
scale_fill_manual(values = pal_bmj("default")(9)[c(4,5,9)])+
facet_wrap(~part_id, scales = "free")+
labs(x = "time from consumption [min]", y = "glucose concentration [mmol/l]", title = "Individual glycemic response to breakfast meal", subtitle = "whole grain with cheese spread, refined bread with cheeses spread and standard glucose drink")+
guides(color = guide_legend(ncol = 1, override.aes = list(shape = 21)),
linetype = guide_legend(ncol = 1, override.aes = list(color = "black")),
shape = guide_legend(ncol = 1)) +
theme_bw()+
theme(legend.box = "horizontal", legend.position = c(0.75, 0.2))
## `geom_smooth()` using method = 'loess' and formula = 'y ~ x'
# plot dinner
full_response_qc %>% filter(product %in% c("Pasta", "Nasi", "glucose_drink")) %>%
mutate(product = fct_relevel(as.factor(product), c("Pasta", "Nasi", "glucose_drink")))%>%
mutate(consumption = fct_relevel(as.factor(consumption), c("first", "second", "third", "fourth", "fifth")))%>%
ggplot(aes(x=time_from_meal, y = CGM_glucose, group = interaction(part_id, product, consumption)))+
geom_point(size = 2, color = "black", aes(shape = completed, fill = product))+
scale_shape_manual(values = c(21, 24), name = "completed")+
geom_smooth(se = FALSE, aes(color = product, linetype = consumption))+
scale_color_manual(values = pal_bmj("default")(9)[c(7,8,9)])+
scale_fill_manual(values = pal_bmj("default")(9)[c(7,8,9)])+
scale_linetype_manual(values = c("solid", "longdash", "dashed", "dotdash", "dotted"))+
facet_wrap(~part_id, scales = "free")+
labs(x = "time from consumption [min]", y = "glucose concentration [mmol/l]", title = "Individual glycemic response to dinner", subtitle = "nasi, pasta and standard glucose drink")+
guides(color = guide_legend(ncol = 1, override.aes = list(shape = 21)),
linetype = guide_legend(ncol = 1, keywidth = unit(2, "cm"), override.aes = list(color = "black")),
shape = guide_legend(ncol = 1)) +
theme_bw()+
theme(legend.box = "horizontal", legend.position = c(0.75, 0.17))
## `geom_smooth()` using method = 'loess' and formula = 'y ~ x'
To quantify the postprandial response to each meal and consumption occasion, the incremental area under the curve (iAUC) was calculated. This approach was chosen because it adjusts for baseline glucose levels and focuses on glucose excursions above baseline, without penalizing decreases below baseline. Postprandial response curves were aligned to meal start time, and only measurements collected after meal consumption contributed to iAUC calculation.
Since the DescTools package does not provide a direct function for calculating incremental area under the curve (iAUC), a custom implementation was used. Glucose values were first adjusted relative to baseline glucose concentration. During iAUC calculation, only glucose values above baseline contributed to the final area, whereas values below baseline were set to zero before applying trapezoidal integration. This approach ensures that decreases in glucose concentration below baseline do not artificially reduce the estimated postprandial glycemic response.
The code below shows the iAUC calculation. To account for variability in observation windows, all curves were standardized to a fixed 2-hour (120 minutes) postprandial period. Although the target window was 2 hours, actual coverage varied slightly depending on exact meal timing and sensor scanning frequency. Standardization ensures comparability of iAUC values across participants and consumption occasions.
full_response_qc<- full_response_qc %>%
group_by(part_id, product, consumption) %>%
arrange(Timestamp, .by_group = TRUE) %>%
mutate(time_from_baseline = as.numeric(difftime(Timestamp, first(Timestamp), units = "mins")),
glucose_adj = CGM_glucose - first(CGM_glucose),
glucose_increment = pmax(glucose_adj, 0)) %>%
ungroup
parameters_2h<- full_response_qc %>% group_by(part_id, product, consumption) %>%
arrange(Timestamp, .by_group = TRUE) %>%
mutate(base_glc = first(CGM_glucose),
peak_glc = max(CGM_glucose),
min_glc = min(CGM_glucose)) %>% ungroup %>%
arrange(Timestamp) %>%
filter(time_from_meal >=0) %>%
group_by(part_id, product, consumption, base_glc, peak_glc, min_glc) %>%
arrange(Timestamp, .by_group = TRUE) %>%
summarize(duration=last(time_from_meal) - first(time_from_meal),
iAUC = AUC(x = time_from_meal,
y = glucose_increment,
method = "trapezoid"), .groups = "drop") %>%
mutate(iAUC_norm = iAUC*120/duration)
The visualization below illustrates the approach used for incremental area under the curve (iAUC) calculation using the WG_Bread response as an example. Each panel represents one participant and shows the postprandial glucose response measured using continuous glucose monitoring (CGM).
The green vertical line indicates the time of meal consumption, whereas the red dashed horizontal line represents baseline glucose concentration determined from the closest CGM measurement prior to meal intake. Blue dashed vertical lines indicate the beginning and end of the response window used for iAUC calculation.
The shaded blue area represents glucose excursions above baseline that contributed to the final iAUC value. Glucose concentrations below baseline were excluded from the calculation by setting negative baseline-adjusted values to zero prior to trapezoidal integration.
response_WG_Bread_iAUC_calc<- full_response_qc %>%
group_by(part_id, product, consumption) %>%
arrange(Timestamp, .by_group = TRUE) %>%
mutate(base_glc = first(CGM_glucose),
peak_glc = max(CGM_glucose),
min_glc = min(CGM_glucose)) %>% ungroup %>%
filter(product == "WG_Bread" & consumption == "first")
i_response_WG_Bread_iAUC_calc <- response_WG_Bread_iAUC_calc%>%
group_by(part_id, product, consumption) %>%
arrange(Timestamp, .by_group = TRUE) %>%
filter(time_from_meal>=0) %>%
mutate(first_meas = first(time_from_meal),
last_meas = last(time_from_meal),
glucose_iAUC = pmax(CGM_glucose, base_glc)) %>% ungroup
response_WG_Bread_iAUC_calc %>%
ggplot(aes(x=time_from_meal, y = CGM_glucose, group = part_id, product))+
geom_point(size = 3, color = "black", fill = "#7D5CC6FF")+
geom_line(color = "#7D5CC6FF")+
geom_vline(aes(xintercept=0), linetype="solid", size = 1, color = "green4")+
geom_vline(data = i_response_WG_Bread_iAUC_calc, aes(xintercept=first_meas), linetype="dashed", linewidth = 1, color = "dodgerblue1")+
geom_vline(data = i_response_WG_Bread_iAUC_calc, aes(xintercept=last_meas), linetype="dashed", linewidth = 1, color = "dodgerblue1")+
geom_hline(aes(yintercept=base_glc), linetype="dashed", linewidth = 1, color = "red3")+
geom_ribbon(data = i_response_WG_Bread_iAUC_calc,
aes(ymin = base_glc, ymax = glucose_iAUC),
fill = "dodgerblue1",alpha = 0.3)+
facet_wrap(~part_id, scales = "free")+
labs(x = "time from consumption [min]", y = "glucose concentration [mmol/l]", title = "iAUC calculation", subtitle = "WG_Bread shown as an example.\nGreen line = meal consumption time; blue dashed lines = iAUC calculation window;\nred dashed line = baseline glucose concentration.")+
theme_bw()+
theme(legend.box = "horizontal", legend.position = c(0.75, 0.2))
## Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
## ℹ Please use `linewidth` instead.
## This warning is displayed once per session.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
First, it was assessed whether the same meal elicits different glycemic responses across repeated consumption occasions, while accounting for participant-specific baseline differences using linear mixed-effects models with participant-level random effects.
Prior to model fitting, distributional assumptions were evaluated. Normality was assessed using visual inspection of Q–Q plots, supported by Shapiro–Wilk tests. Given the sensitivity of formal tests to sample size, graphical diagnostics were prioritized for inference.
Due to fundamentally different statistical properties, integrated postprandial response (iAUC) was analysed separately from instantaneous glucose metrics (baseline, peak, and minimum glucose levels). This ensured appropriate scaling and interpretability of distributional diagnostics.
plot_data_consumption <- parameters_2h %>% select(-c(duration, iAUC)) %>%
filter(product != "glucose_drink") %>%
pivot_longer(cols = c("base_glc", "peak_glc", "min_glc", "iAUC_norm"), values_to = "value", names_to = "parameter")
stat_consumption_df <- plot_data_consumption %>%
group_by(parameter, product) %>%
summarise(p = shapiro.test(value)$p.value,
label = paste0("Shapiro p = ", signif(p, 2)),
.groups = "drop")
plot_data_consumption %>%
filter(parameter == "iAUC_norm") %>%
ggplot(aes(value)) +
geom_histogram(aes(y = after_stat(density)), bins = 25, fill = "dodgerblue4", color = "black", alpha = 0.8) +
geom_density(color = "red", linewidth = 1) +
geom_text(data = (stat_consumption_df %>% filter(parameter == "iAUC_norm")),
aes(x = -Inf, y = Inf, label = label), hjust = -0.1, vjust = 1.2, inherit.aes = FALSE, size = 4) +
facet_wrap(~product) +
theme_bw() +
labs(title = "Distribution of iAUC across meals", x = "iAUC (normalized)", y = "Density")
plot_data_consumption %>%
filter(parameter != "iAUC_norm") %>%
ggplot(aes(value)) +
geom_histogram(aes(y = after_stat(density)), bins = 25, fill = "dodgerblue2", color = "black", alpha = 0.8) +
geom_density(color = "red") +
geom_text(data = (stat_consumption_df %>% filter(parameter != "iAUC_norm")),
aes(x = -Inf, y = Inf, label = label), hjust = -0.1, vjust = 1.2, inherit.aes = FALSE, size = 4) +
facet_grid(cols = vars(product), rows = vars(parameter), scales = "free") +
theme_bw()
ggplot(plot_data_consumption, aes(sample = value)) +
stat_qq(size = 2, alpha = 0.7) +
stat_qq_line(color = "red") +
facet_grid(cols = vars(product), rows = vars(parameter), scales = "free") +
theme_bw() +
labs(title = "Q-Q plots for glycemic response parameters")
Histogram and Q–Q plots indicated that all parameters, apart from iAUC, were approximately normally distributed. Therefore, iAUC will be log and square root-transformed to reduce skewness, and distributional assumptions will be reassessed after transformation.
stat_consumption_df_iAUC_trans<- parameters_2h %>%
mutate(iAUC_log = log1p(iAUC_norm),
iAUC_sqrt = sqrt(iAUC_norm)) %>%
pivot_longer(cols = c("iAUC_log", "iAUC_sqrt"), values_to = "value", names_to = "iAUC_trans") %>%
group_by(product, iAUC_trans) %>%
summarise(p = shapiro.test(value)$p.value,
label = paste0("Shapiro p = ", signif(p, 2)),
.groups = "drop")
For future analyses, the transformation strategy will be tailored to the distributional characteristics of each meal type. A square-root transformation of iAUC will be applied for dinner meals and bread-based breakfasts, whereas a log transformation will be used for glucose drink and yoghurt-based breakfasts, due to their more pronounced right-skewed distributions.
parameters_2h_transformed<- parameters_2h %>% select(-c(duration, iAUC)) %>% mutate(iAUC_log = log1p(iAUC_norm),
iAUC_sqrt = sqrt(iAUC_norm)) %>%
mutate(iAUC_transformed = case_when(
product %in% c("Pasta", "Nasi", "WG_Bread", "R_Bread") ~ iAUC_sqrt,
product %in% c("Fruit_Yoghurt", "Gingerbread_Yoghurt", "glucose_drink") ~ iAUC_log,
TRUE ~ NA_real_))
consumption_models<- parameters_2h_transformed %>%
filter(product != "glucose_drink") %>%
mutate(consumption = fct_relevel(as.factor(consumption), c("first", "second", "third", "fourth", "fifth"))) %>%
pivot_longer(cols = c("base_glc", "peak_glc", "min_glc", "iAUC_transformed"), values_to = "value", names_to = "parameter") %>% group_by(parameter, product) %>%
nest() %>%
mutate(model = map(data, ~ lmer(value ~ consumption + (1|part_id), data = .x)),
tidy_results = map(model,~ broom.mixed::tidy(.x, effects = "fixed")))%>%
select(parameter, product, tidy_results) %>%
unnest(tidy_results)
consumption_models<- consumption_models %>% filter(term != "(Intercept)") %>% mutate(
label = paste0("p = ", round(p.value, 3)),
text_color = ifelse(p.value < 0.05, "red", "black"))
consumption_models %>% arrange(p.value) %>% kbl() %>%
kableExtra::kable_paper(font_size = 12) %>%
scroll_box(width = "800px", height = "500px")
| parameter | product | effect | term | estimate | std.error | statistic | df | p.value | label | text_color |
|---|---|---|---|---|---|---|---|---|---|---|
| iAUC_transformed | Nasi | fixed | consumptionthird | -6.1513251 | 2.6046365 | -2.3616828 | 21.392847 | 0.0277403 | p = 0.028 | red |
| base_glc | Pasta | fixed | consumptionfourth | 1.2586786 | 0.5844212 | 2.1537182 | 28.202484 | 0.0399541 | p = 0.04 | red |
| base_glc | Nasi | fixed | consumptionthird | 1.2522227 | 0.5777195 | 2.1675271 | 20.901264 | 0.0418929 | p = 0.042 | red |
| base_glc | WG_Bread | fixed | consumptionsecond | 0.2006307 | 0.0977390 | 2.0527185 | 7.130190 | 0.0784781 | p = 0.078 | black |
| iAUC_transformed | R_Bread | fixed | consumptionsecond | -1.6340990 | 0.8795486 | -1.8578837 | 5.753646 | 0.1146482 | p = 0.115 | black |
| peak_glc | Pasta | fixed | consumptionfourth | 0.6873727 | 0.4248813 | 1.6177995 | 27.814802 | 0.1169918 | p = 0.117 | black |
| peak_glc | Pasta | fixed | consumptionfifth | 0.6540393 | 0.4248813 | 1.5393462 | 27.814802 | 0.1350200 | p = 0.135 | black |
| base_glc | Nasi | fixed | consumptionsecond | 0.8118857 | 0.5289063 | 1.5350276 | 20.252687 | 0.1402523 | p = 0.14 | black |
| iAUC_transformed | WG_Bread | fixed | consumptionsecond | -2.1820941 | 1.4827926 | -1.4716111 | 8.179538 | 0.1785236 | p = 0.179 | black |
| base_glc | Fruit_Yoghurt | fixed | consumptionsecond | -0.2300000 | 0.1605892 | -1.4322259 | 9.000000 | 0.1858809 | p = 0.186 | black |
| min_glc | Nasi | fixed | consumptionthird | 0.4306475 | 0.3156705 | 1.3642312 | 20.110048 | 0.1875690 | p = 0.188 | black |
| min_glc | Fruit_Yoghurt | fixed | consumptionsecond | -0.2300000 | 0.1626516 | -1.4140651 | 9.000000 | 0.1909894 | p = 0.191 | black |
| peak_glc | Pasta | fixed | consumptionthird | 0.4790661 | 0.3680378 | 1.3016764 | 27.455091 | 0.2038437 | p = 0.204 | black |
| iAUC_transformed | Fruit_Yoghurt | fixed | consumptionsecond | 0.2578203 | 0.1882431 | 1.3696136 | 9.000000 | 0.2040061 | p = 0.204 | black |
| iAUC_transformed | Pasta | fixed | consumptionthird | 1.9914792 | 1.9061546 | 1.0447627 | 28.405791 | 0.3049425 | p = 0.305 | black |
| peak_glc | Nasi | fixed | consumptionsecond | 0.4297878 | 0.4123316 | 1.0423354 | 19.670490 | 0.3098983 | p = 0.31 | black |
| base_glc | Nasi | fixed | consumptionfourth | 0.5947847 | 0.6098738 | 0.9752586 | 21.060445 | 0.3405018 | p = 0.341 | black |
| peak_glc | Pasta | fixed | consumptionsecond | 0.3400000 | 0.3543977 | 0.9593741 | 27.234324 | 0.3458107 | p = 0.346 | black |
| iAUC_transformed | Pasta | fixed | consumptionfourth | -2.0801632 | 2.1719616 | -0.9577348 | 29.929874 | 0.3458695 | p = 0.346 | black |
| peak_glc | WG_Bread | fixed | consumptionsecond | -0.3551710 | 0.3584243 | -0.9909233 | 7.382764 | 0.3530828 | p = 0.353 | black |
| min_glc | Pasta | fixed | consumptionthird | 0.2723426 | 0.3063728 | 0.8889256 | 27.514270 | 0.3817491 | p = 0.382 | black |
| iAUC_transformed | Gingerbread_Yoghurt | fixed | consumptionsecond | -0.1124059 | 0.1446809 | -0.7769230 | 9.000000 | 0.4571374 | p = 0.457 | black |
| iAUC_transformed | Nasi | fixed | consumptionfourth | -1.8476534 | 2.7395099 | -0.6744467 | 21.941505 | 0.5070719 | p = 0.507 | black |
| peak_glc | Nasi | fixed | consumptionthird | -0.3055662 | 0.4528623 | -0.6747442 | 19.927186 | 0.5075919 | p = 0.508 | black |
| peak_glc | R_Bread | fixed | consumptionsecond | -0.3506697 | 0.5107990 | -0.6865122 | 5.872951 | 0.5185609 | p = 0.519 | black |
| peak_glc | Fruit_Yoghurt | fixed | consumptionsecond | 0.1300000 | 0.1961009 | 0.6629241 | 9.000000 | 0.5239924 | p = 0.524 | black |
| base_glc | Pasta | fixed | consumptionfifth | 0.3253453 | 0.5844212 | 0.5566965 | 28.202484 | 0.5821259 | p = 0.582 | black |
| iAUC_transformed | Nasi | fixed | consumptionsecond | -1.3021006 | 2.4107747 | -0.5401171 | 20.123794 | 0.5950438 | p = 0.595 | black |
| peak_glc | Nasi | fixed | consumptionfourth | -0.2277007 | 0.4785869 | -0.4757770 | 19.965160 | 0.6393978 | p = 0.639 | black |
| peak_glc | Gingerbread_Yoghurt | fixed | consumptionsecond | -0.1900000 | 0.3973384 | -0.4781819 | 9.000000 | 0.6439221 | p = 0.644 | black |
| min_glc | Nasi | fixed | consumptionfourth | 0.1543236 | 0.3335580 | 0.4626591 | 20.162940 | 0.6485629 | p = 0.649 | black |
| iAUC_transformed | Pasta | fixed | consumptionsecond | 0.7548602 | 1.8471750 | 0.4086566 | 27.807755 | 0.6859218 | p = 0.686 | black |
| iAUC_transformed | Pasta | fixed | consumptionfifth | 0.8388478 | 2.1719616 | 0.3862167 | 29.929874 | 0.7020682 | p = 0.702 | black |
| min_glc | WG_Bread | fixed | consumptionsecond | 0.0285707 | 0.0772040 | 0.3700684 | 7.050758 | 0.7221977 | p = 0.722 | black |
| min_glc | Nasi | fixed | consumptionsecond | 0.1021737 | 0.2876614 | 0.3551875 | 19.788989 | 0.7262068 | p = 0.726 | black |
| base_glc | Pasta | fixed | consumptionsecond | 0.1600000 | 0.4889668 | 0.3272206 | 27.332949 | 0.7459940 | p = 0.746 | black |
| min_glc | Pasta | fixed | consumptionfourth | -0.0878739 | 0.3534534 | -0.2486153 | 27.943403 | 0.8054765 | p = 0.805 | black |
| base_glc | R_Bread | fixed | consumptionsecond | -0.0393056 | 0.1602149 | -0.2453305 | 6.177904 | 0.8141477 | p = 0.814 | black |
| base_glc | Pasta | fixed | consumptionthird | 0.1028173 | 0.5072099 | 0.2027115 | 27.647978 | 0.8408449 | p = 0.841 | black |
| min_glc | Pasta | fixed | consumptionfifth | 0.0454594 | 0.3534534 | 0.1286151 | 27.943403 | 0.8985835 | p = 0.899 | black |
| min_glc | Pasta | fixed | consumptionsecond | 0.0300000 | 0.2951385 | 0.1016472 | 27.258084 | 0.9197808 | p = 0.92 | black |
| base_glc | Gingerbread_Yoghurt | fixed | consumptionsecond | -0.0100000 | 0.1779201 | -0.0562050 | 9.000000 | 0.9564065 | p = 0.956 | black |
| min_glc | R_Bread | fixed | consumptionsecond | -0.0018960 | 0.1211812 | -0.0156463 | 6.062898 | 0.9880187 | p = 0.988 | black |
| min_glc | Gingerbread_Yoghurt | fixed | consumptionsecond | 0.0000000 | 0.1282359 | 0.0000000 | 9.000000 | 1.0000000 | p = 1 | black |
The results indicate that only baseline glucose prior to the third occasion of Nasi consumption, the fourth occasion of Pasta consumption, and iAUC during the third Nasi consumption differed significantly from the first consumption occasion.
Below also a plot with such visualization is presented.
# Since participant P06 had no glycemic response data for R_Bread consumption, participant-specific colors were fixed across plots to maintain consistent color mapping and enable a common legend.
all_participants <- c("P01","P02","P03","P04","P05",
"P06","P07","P08","P09","P10")
plot_data_consumption <- parameters_2h_transformed %>%
mutate(consumption = fct_relevel(as.factor(consumption),
c("first","second","third","fourth","fifth"))) %>%
mutate(part_id = factor(part_id, levels = all_participants)) %>%
mutate(product = fct_relevel(as.factor(product), c("Fruit_Yoghurt","Gingerbread_Yoghurt","WG_Bread","R_Bread","Nasi", "Pasta", "glucose_drink"))) %>%
mutate(meal_set = case_when(product %in% c("Fruit_Yoghurt","Gingerbread_Yoghurt") ~ "Breakfast_280kcal",
product %in% c("WG_Bread","R_Bread") ~ "Breakfast_380kcal",
product %in% c("Nasi", "Pasta") ~ "Dinner",
TRUE ~ NA_character_)) %>%
pivot_longer(cols = c(base_glc, peak_glc, min_glc, iAUC_transformed),
names_to = "parameter",
values_to = "value")
participant_ids <- sort(unique(parameters_2h_transformed$part_id))
participant_colors <- setNames(
glasbey(length(participant_ids)),
participant_ids)
breakfast_set1_consumption_plot<- plot_data_consumption %>%
filter(meal_set == "Breakfast_280kcal") %>%
ggplot(aes(x = consumption, y = value)) +
geom_boxplot(fill = "grey90", outlier.shape = NA, width = 0.5) +
geom_jitter(aes(fill = part_id),
color = "black",
shape = 21,
width = 0.2,
size = 2.5,
alpha = 0.8) +
geom_line(aes(group = part_id, color = part_id), alpha = 0.4) +
stat_summary(fun = mean, geom = "point", shape = 23, fill = "dodgerblue1", size = 2) +
stat_summary(fun = mean, geom = "text",
color = "dodgerblue3", vjust = -2,
aes(label = round(after_stat(y), 1))) +
geom_text(data = (consumption_models %>% filter(term != "(Intercept)") %>%
filter(product %in% c("Fruit_Yoghurt","Gingerbread_Yoghurt"))),
aes(x = -Inf, y = Inf, label = paste0("p = ", round(p.value, 3))), hjust = -0.1, vjust = 1.2, inherit.aes = FALSE, size = 4) +
scale_fill_manual(values = participant_colors, drop = FALSE) +
scale_color_manual(values = participant_colors, drop = FALSE) +
facet_grid(rows = vars(parameter), cols = vars(product), scales = "free")+
theme_bw() +
theme(axis.text.x = element_text(angle = 45, hjust = 1), axis.title.x = element_blank(), axis.title.y = element_blank())
breakfast_set2_consumption_plot<- plot_data_consumption %>%
filter(meal_set == "Breakfast_380kcal") %>%
ggplot(aes(x = consumption, y = value)) +
geom_boxplot(fill = "grey90", outlier.shape = NA, width = 0.5) +
geom_jitter(aes(fill = part_id),
color = "black",
shape = 21,
width = 0.2,
size = 2.5,
alpha = 0.8) +
geom_line(aes(group = part_id, color = part_id), alpha = 0.4) +
stat_summary(fun = mean, geom = "point", shape = 23, fill = "dodgerblue1", size = 2) +
stat_summary(fun = mean, geom = "text",
color = "dodgerblue3", vjust = -2,
aes(label = round(after_stat(y), 1))) +
geom_text(data = (consumption_models %>% filter(term != "(Intercept)") %>%
filter(product %in% c("WG_Bread","R_Bread"))),
aes(x = -Inf, y = Inf, label = paste0("p = ", round(p.value, 3))), hjust = -0.1, vjust = 1.2, inherit.aes = FALSE, size = 4) +
scale_fill_manual(values = participant_colors, drop = FALSE) +
scale_color_manual(values = participant_colors, drop = FALSE) +
facet_grid(rows = vars(parameter), cols = vars(product), scales = "free")+
theme_bw() +
theme(axis.text.x = element_text(angle = 45, hjust = 1), axis.title.x = element_blank(), axis.title.y = element_blank())
dinner_consumption_plot<- plot_data_consumption %>%
filter(meal_set == "Dinner") %>%
ggplot(aes(x = consumption, y = value)) +
geom_boxplot(fill = "grey90", outlier.shape = NA, width = 0.5) +
geom_jitter(aes(fill = part_id),
color = "black",
shape = 21,
width = 0.2,
size = 2.5,
alpha = 0.8) +
geom_line(aes(group = part_id, color = part_id), alpha = 0.4) +
stat_summary(fun = mean, geom = "point", shape = 23, fill = "dodgerblue1", size = 2) +
stat_summary(fun = mean, geom = "text",
color = "dodgerblue3", vjust = -2,
aes(label = round(after_stat(y), 1))) +
geom_text(data = (consumption_models %>%
filter(product %in% c("Nasi","Pasta")) %>%
filter(term == "consumptionsecond")),
aes(x = -Inf, y = Inf, label = paste0("p_second = ", round(p.value, 3))), hjust = -0.1, vjust = 1.2, inherit.aes = FALSE, size = 4, color = consumption_models %>%
filter(product %in% c("Nasi","Pasta")) %>%
filter(term == "consumptionsecond") %>%
pull(text_color)) +
geom_text(data = (consumption_models %>%
filter(product %in% c("Nasi","Pasta")) %>%
filter(term == "consumptionthird")),
aes(x = -Inf, y = Inf, label = paste0("p_third = ", round(p.value, 3))), hjust = -0.1, vjust = 2.5, inherit.aes = FALSE, size = 4, color = consumption_models %>%
filter(product %in% c("Nasi","Pasta")) %>%
filter(term == "consumptionthird") %>%
pull(text_color)) +
geom_text(data = (consumption_models %>%
filter(product %in% c("Nasi","Pasta")) %>%
filter(term == "consumptionfourth")),
aes(x = -Inf, y = Inf, label = paste0("p_fourth = ", round(p.value, 3))), hjust = -0.1, vjust = 3.7, inherit.aes = FALSE, size = 4, color = consumption_models %>%
filter(product %in% c("Nasi","Pasta")) %>%
filter(term == "consumptionfourth") %>%
pull(text_color)) +
scale_fill_manual(values = participant_colors, drop = FALSE) +
scale_color_manual(values = participant_colors, drop = FALSE) +
facet_grid(rows = vars(parameter), cols = vars(product), scales = "free")+
theme_bw() +
theme(axis.text.x = element_text(angle = 45, hjust = 1), axis.title.x = element_blank(), axis.title.y = element_blank())
print(breakfast_set1_consumption_plot)
print(breakfast_set2_consumption_plot)
print(dinner_consumption_plot)
This section compares glycemic response parameters between products with similar caloric value and meal type. Products were grouped into three meal sets: lower-calorie breakfasts (~280 kcal), bread-based breakfasts (~350–390 kcal), and dinner meals. For each parameter (baseline glucose, peak glucose, minimum glucose, and transformed iAUC), linear mixed-effects models were fitted with product as a fixed effect and participant ID as a random effect to account for repeated measurements within individuals.
Model results were extracted and p-values corresponding to product differences were visualized directly on the plots. Boxplots together with individual observations were used to present the distribution of glycemic response parameters across meal sets. Mean values for each product are additionally displayed on the plots. Significant product differences (p < 0.05) are highlighted in red.
product_models<- parameters_2h_transformed %>%
mutate(meal_set = case_when(product %in% c("Fruit_Yoghurt","Gingerbread_Yoghurt") ~ "Breakfast_280kcal",
product %in% c("WG_Bread","R_Bread") ~ "Breakfast_380kcal",
product %in% c("Nasi", "Pasta") ~ "Dinner",
TRUE ~ NA_character_)) %>%
filter(product != "glucose_drink") %>%
mutate(consumption = fct_relevel(as.factor(consumption), c("first", "second", "third", "fourth", "fifth"))) %>%
pivot_longer(cols = c("base_glc", "peak_glc", "min_glc", "iAUC_transformed"), values_to = "value", names_to = "parameter") %>%
group_by(parameter, meal_set) %>%
nest() %>%
mutate(model = map(data, ~ lmer(value ~ product + (1|part_id), data = .x)),
tidy_results = map(model,~ broom.mixed::tidy(.x, effects = "fixed")))%>%
select(parameter, meal_set, tidy_results) %>%
unnest(tidy_results)
product_models<- product_models %>% filter(term != "(Intercept)") %>% mutate(
label = paste0("p = ", round(p.value, 3)),
text_color = ifelse(p.value < 0.05, "red", "black"))
product_models %>% arrange(p.value) %>% kbl() %>%
kableExtra::kable_paper(font_size = 12) %>%
scroll_box(width = "800px", height = "500px")
| parameter | meal_set | effect | term | estimate | std.error | statistic | df | p.value | label | text_color |
|---|---|---|---|---|---|---|---|---|---|---|
| peak_glc | Breakfast_280kcal | fixed | productGingerbread_Yoghurt | 1.2700000 | 0.2127894 | 5.9683436 | 29.00000 | 0.0000017 | p = 0 | red |
| iAUC_transformed | Breakfast_280kcal | fixed | productGingerbread_Yoghurt | 0.5415323 | 0.1150014 | 4.7089185 | 29.00000 | 0.0000569 | p = 0 | red |
| peak_glc | Dinner | fixed | productPasta | -0.5081198 | 0.1888682 | -2.6903401 | 62.19093 | 0.0091542 | p = 0.009 | red |
| base_glc | Breakfast_380kcal | fixed | productWG_Bread | 0.1848718 | 0.1046232 | 1.7670258 | 23.24711 | 0.0903515 | p = 0.09 | black |
| iAUC_transformed | Dinner | fixed | productPasta | -1.9002372 | 1.1669401 | -1.6283931 | 63.44545 | 0.1083977 | p = 0.108 | black |
| iAUC_transformed | Breakfast_380kcal | fixed | productWG_Bread | -1.2356188 | 0.7909588 | -1.5621784 | 23.49331 | 0.1316171 | p = 0.132 | black |
| base_glc | Breakfast_280kcal | fixed | productGingerbread_Yoghurt | 0.1300000 | 0.1105004 | 1.1764660 | 29.00000 | 0.2489757 | p = 0.249 | black |
| min_glc | Breakfast_380kcal | fixed | productWG_Bread | 0.0754855 | 0.0735237 | 1.0266827 | 23.09903 | 0.3152018 | p = 0.315 | black |
| peak_glc | Breakfast_380kcal | fixed | productWG_Bread | -0.2370783 | 0.2595725 | -0.9133412 | 23.26611 | 0.3704313 | p = 0.37 | black |
| min_glc | Dinner | fixed | productPasta | -0.0589175 | 0.1435923 | -0.4103108 | 62.21280 | 0.6829876 | p = 0.683 | black |
| min_glc | Breakfast_280kcal | fixed | productGingerbread_Yoghurt | 0.0150000 | 0.1024737 | 0.1463790 | 29.00000 | 0.8846350 | p = 0.885 | black |
| base_glc | Dinner | fixed | productPasta | 0.0357259 | 0.2612722 | 0.1367383 | 62.25436 | 0.8916789 | p = 0.892 | black |
plot_data_consumption %>%
filter(product != "glucose_drink") %>%
ggplot(aes(x = meal_set, y = value)) +
geom_boxplot(aes(fill = product), outlier.shape = NA, width = 0.5, alpha = 0.5) +
geom_jitter(aes(fill = product),
color = "black", shape = 21, size = 2.5, alpha = 0.8, position = position_jitterdodge(jitter.width = 0.2,
dodge.width = 0.5)) +
stat_summary(aes(fill = product), fun = mean, geom = "point", shape = 23, size = 4, position = position_dodge(width = 0.5)) +
stat_summary(fun = mean, geom = "label", vjust = -2, position = position_dodge(width = 0.5), aes(color = product, label = round(after_stat(y), 1))) +
geom_text(data = product_models,
aes(x = meal_set, y = Inf, label = paste0("p = ", round(p.value, 4))), hjust = 0.5, vjust = 3, inherit.aes = FALSE, size = 4, color = product_models$text_color) +
scale_color_manual(values = pal_bmj("default")(9)[c(1,2,4,5,7,8)])+
scale_fill_manual(values = pal_bmj("default")(9)[c(1,2,4,5,7,8)])+
facet_wrap(~parameter, scales = "free")+
guides(color = "none")+
theme_bw() +
theme(axis.text.x = element_text(angle = 45, hjust = 1), axis.title.x = element_blank(), axis.title.y = element_blank())
The plot indicate a significantly stronger glycemic response after consumption of gingerbread with yoghurt compared to fruit with yoghurt, as reflected by both higher iAUC values and higher peak glucose concentrations. No statistically significant differences were observed for the remaining compared product pairs.
Linear mixed-effects models were used to assess differences in glycemic response parameters between food products. The analysis included baseline glucose, peak glucose, minimum glucose, and incremental area under the curve (iAUC), which were reshaped into a long format for modelling.
For each glycemic parameter, a separate model was fitted with product as a fixed effect and participant ID as a random intercept to account for repeated measurements within individuals. The glucose drink served as the reference category.
Estimated fixed effects were extracted and used to obtain p-values for pairwise comparisons between each test product and the reference. Significance levels were encoded using standard notation (* p < 0.05, ** p < 0.01, *** p < 0.001).
These model-derived results were subsequently used for graphical annotation of statistically significant differences between products.
product_to_OGTT_models<- parameters_2h_transformed %>%
filter(!product%in% c("Nasi", "Pasta")) %>%
pivot_longer(cols = c("base_glc", "peak_glc", "min_glc", "iAUC_transformed"), values_to = "value", names_to = "parameter") %>%
mutate(product = fct_relevel(as.factor(product), c("glucose_drink", "Fruit_Yoghurt","Gingerbread_Yoghurt", "WG_Bread","R_Bread"))) %>%
group_by(parameter) %>%
nest() %>%
mutate(model = map(data, ~ lmer(value ~ product + (1|part_id), data = .x)),
tidy_results = map(model,~ broom.mixed::tidy(.x, effects = "fixed")))%>%
select(parameter, tidy_results) %>%
unnest(tidy_results)
product_to_OGTT_models<- product_to_OGTT_models %>% filter(term != "(Intercept)") %>% mutate(
label = paste0("p = ", round(p.value, 3)), group1 = "glucose_drink") %>%
rename("group2" = "term") %>%
mutate(group2 = str_remove(group2, "product")) %>%
mutate(p.label = case_when(p.value < 0.001 ~ "***",
p.value < 0.01 ~ "**",
p.value < 0.05 ~ "*",
TRUE ~ "ns")) %>%
mutate(xmin = 1, xmax = c(2,3,4,5))
y_positions <- plot_data_consumption %>%
filter(!product %in% c("Nasi", "Pasta")) %>%
group_by(parameter) %>%
summarise(y.position = max(value, na.rm = TRUE) * 1.15)
product_to_OGTT_models <- left_join(product_to_OGTT_models, y_positions, by = "parameter") %>%
group_by(parameter) %>%
mutate(y.position = y.position + c(0,1,2,3)*0.05*max(y.position)) %>% ungroup
product_to_OGTT_models_sig <- product_to_OGTT_models %>%
filter(p.label != "ns")
product_to_OGTT_models %>% arrange(p.value) %>% kbl() %>%
kableExtra::kable_paper(font_size = 12) %>%
scroll_box(width = "800px", height = "500px")
| parameter | effect | group2 | estimate | std.error | statistic | df | p.value | label | group1 | p.label | xmin | xmax | y.position |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| peak_glc | fixed | Fruit_Yoghurt | -2.6950000 | 0.3048043 | -8.8417395 | 70.01196 | 0.0000000 | p = 0 | glucose_drink | *** | 1 | 2 | 15.41000 |
| iAUC_transformed | fixed | R_Bread | 8.0215905 | 0.9294535 | 8.6304377 | 70.47392 | 0.0000000 | p = 0 | glucose_drink | *** | 1 | 5 | 31.50295 |
| iAUC_transformed | fixed | WG_Bread | 7.0758291 | 0.9053118 | 7.8159027 | 70.17883 | 0.0000000 | p = 0 | glucose_drink | *** | 1 | 4 | 30.13326 |
| peak_glc | fixed | WG_Bread | -1.6150244 | 0.3111192 | -5.1910155 | 70.02792 | 0.0000020 | p = 0 | glucose_drink | *** | 1 | 4 | 16.95100 |
| peak_glc | fixed | Gingerbread_Yoghurt | -1.4250000 | 0.3048043 | -4.6751313 | 70.01196 | 0.0000139 | p = 0 | glucose_drink | *** | 1 | 3 | 16.18050 |
| peak_glc | fixed | R_Bread | -1.4106355 | 0.3197293 | -4.4119686 | 70.06610 | 0.0000363 | p = 0 | glucose_drink | *** | 1 | 5 | 17.72150 |
| min_glc | fixed | WG_Bread | 0.2461201 | 0.1151450 | 2.1374806 | 70.01683 | 0.0360526 | p = 0.036 | glucose_drink |
|
1 | 4 | 9.23450 |
| min_glc | fixed | R_Bread | 0.1998197 | 0.1183397 | 1.6885263 | 70.03592 | 0.0957585 | p = 0.096 | glucose_drink | ns | 1 | 5 | 9.65425 |
| min_glc | fixed | Gingerbread_Yoghurt | 0.1700000 | 0.1128046 | 1.5070308 | 70.00885 | 0.1363021 | p = 0.136 | glucose_drink | ns | 1 | 3 | 8.81475 |
| iAUC_transformed | fixed | Fruit_Yoghurt | -1.2846222 | 0.8873017 | -1.4477852 | 70.05358 | 0.1521379 | p = 0.152 | glucose_drink | ns | 1 | 2 | 27.39387 |
| min_glc | fixed | Fruit_Yoghurt | 0.1550000 | 0.1128046 | 1.3740575 | 70.00885 | 0.1738072 | p = 0.174 | glucose_drink | ns | 1 | 2 | 8.39500 |
| base_glc | fixed | WG_Bread | 0.1661695 | 0.1361955 | 1.2200813 | 70.03232 | 0.2265267 | p = 0.227 | glucose_drink | ns | 1 | 4 | 9.36100 |
| base_glc | fixed | Gingerbread_Yoghurt | 0.1550000 | 0.1334295 | 1.1616623 | 70.01963 | 0.2493173 | p = 0.249 | glucose_drink | ns | 1 | 3 | 8.93550 |
| iAUC_transformed | fixed | Gingerbread_Yoghurt | -0.7430899 | 0.8873017 | -0.8374716 | 70.05358 | 0.4051757 | p = 0.405 | glucose_drink | ns | 1 | 3 | 28.76357 |
| base_glc | fixed | Fruit_Yoghurt | 0.0250000 | 0.1334295 | 0.1873649 | 70.01963 | 0.8519164 | p = 0.852 | glucose_drink | ns | 1 | 2 | 8.51000 |
| base_glc | fixed | R_Bread | 0.0224388 | 0.1399686 | 0.1603133 | 70.06266 | 0.8730956 | p = 0.873 | glucose_drink | ns | 1 | 5 | 9.78650 |
plot_data_consumption %>%
filter(!product%in% c("Nasi", "Pasta")) %>%
ggplot(aes(x = product, y = value)) +
geom_boxplot(aes(fill = product), outlier.shape = NA, width = 0.5, alpha = 0.5) +
geom_jitter(aes(fill = product),
color = "black", shape = 21, size = 2.5, alpha = 0.8, width = 0.2) +
stat_summary(aes(fill = product), fun = mean, geom = "point", shape = 23, size = 4) +
stat_summary(fun = mean, geom = "label", vjust = -2, aes(color = product, label = round(after_stat(y), 1))) +
stat_pvalue_manual(product_to_OGTT_models_sig, label = "p.label",xmin = "group1", xmax = "group2", y.position = "y.position", tip.length = 0.01, bracket.size = 0.5, size = 5)+
scale_color_manual(values = pal_bmj("default")(9)[c(1,2,4,5, 9)])+
scale_fill_manual(values = pal_bmj("default")(9)[c(1,2,4,5, 9)])+
facet_wrap(~parameter, scales = "free")+
guides(color = "none")+
theme_bw() +
theme(axis.text.x = element_text(angle = 45, hjust = 1), axis.title.x = element_blank(), axis.title.y = element_blank())
The highest peak in glycemic response was observed after consumption of the glucose drink. In contrast, the incremental area under the curve (iAUC) indicated a significantly greater overall glycemic response following bread-based breakfasts compared to the glucose drink.