Insights from nine nights of self-applied, low-density sleep EEG during sleep restriction therapy: a proof-of-concept evaluation
The 7 matches
- [1] § Methods › Outcome Measures › Secondary Outcomes › Power Spectral Analysis ↔ Scripts/RESTORE_Spectral_Analysis_NREM.m, lines 140–187 · score 0.83 · 0.5–32 Hz, FieldTrip, mtmfft, overlap, tapered, beta
- [2] § Methods › Outcome Measures › Secondary Outcomes › Power Spectral Analysis ↔ Scripts/RESTORE_Spectral_Analysis_REM.m, lines 140–181 · score 0.75 · 0.5–32 Hz, FieldTrip, mtmfft, overlap, tapered, segments
- [3] § Methods › Outcome Measures › Secondary Outcomes › EEG-derived Sleep Architecture ↔ Scripts/06_EEG.Rmd, lines 91–161 · score 0.53 · artefact percentage, blinded, absolute, latency, N1, N2
- [4] § Methods › Outcome Measures › Secondary Outcomes › Sleep Diary and Actigraphy-derived Sleep Continuity ↔ Scripts/05_sleep_diary_actigraphy.Rmd, lines 48–152 · score 0.53 · Sleep Diary, survey, Actigraphy, PANAS, waking, SOL
- [5] § Results › EEG-Derived Sleep Architecture ↔ Scripts/06_EEG.Rmd, lines 163–245 · score 0.53 · REM latency, sleep stages, N1, N2, N3, SOL
- [6] § Methods › Outcome Measures › Statistical Analysis ↔ Scripts/02_follow_up.Rmd, lines 112–156 · score 0.53 · RStudio, change score, Cohen, timepoint, SD, baseline
- [7] § Methods › Outcome Measures › Statistical Analysis ↔ Scripts/08_spindles_and_SOs.Rmd, lines 57–101 · score 0.50 · Linear mixed models, LMMs, timepoint, baseline, nights, sleep
Paper
Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC
The paper is loaded when this pane is shown.
The authors' code
R Markdown · 1,171 lines · 40 KB · CC-BY-4.0 · 2 matches
- ---
- title: "EEG Sleep Analysis"
- author: "Emily Stanyer"
- date: "`r Sys.Date()`"
- output:
- html_document:
- toc: true
- df_print: paged
- pdf_document:
- toc: true
- number_sections: true
- params:
- # ---- INPUTS ----
- profile_dir: "Data/raw/EEG/sleep_profile/" # folder of nightly EEG profile .xlsx files
- blinding_file: "Data/raw/EEG/blinding_files/blinding.xlsx" # has ParticipantID, Timepoint, Original_filename, Real_ID (if available)
- notes_file: "Data/raw/EEG/scoring_notes/Scoring_Notes_Final_110725.xlsx" # has ParticipantID, artefact_percentage, scoreability, impedances
- # ---- OUTPUTS ----
- proc_dir: "Data/processed/EEG"
- fig_dir: "Figures/EEG"
- out_dir: "Output/EEG"
- apply_artifact_filter: false # set true to drop nights with artefact % > threshold
- artifact_threshold: 10 # percent
- ---
- ```{r setup, include=FALSE}
- knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE,
- fig.width = 10, fig.height = 4.8)
- suppressPackageStartupMessages({
- library(readr)
- library(readxl)
- library(dplyr)
- library(tidyr)
- library(lubridate)
- library(stringr)
- library(ggplot2)
- library(ggprism)
- library(writexl)
- library(knitr)
- library(kableExtra)
- library(sessioninfo)
- library(lme4)
- library(emmeans)
- library(purrr)
- })
- # ---- paths ----
- out_dir <- params$out_dir
- fig_dir <- params$fig_dir
- dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)
- dir.create(fig_dir, recursive = TRUE, showWarnings = FALSE)
- set.seed(2025)
- ```
- # Read and combine raw EEG profile files
- ```{r 1-read-eeg}
- # Helper: deduplicate readxl-style column suffixes like NAME...1, NAME...2 (keep first)
- library(here)
- dedup_cols <- function(df){
- base <- gsub("\\.{3}\\d+$", "", names(df))
- keep <- !duplicated(base)
- names(df) <- base
- df[, keep, drop = FALSE]
- }
- # Helper: safer numeric coercion from character
- numify <- function(x){ suppressWarnings(as.numeric(x)) }
- # 1) List files
- # Instead of a hardcoded string, use:
- profile_dir <- here::here(params$profile_dir)
- stopifnot(dir.exists(profile_dir))
- file_list <- list.files(profile_dir, pattern = "\\.xlsx$", full.names = TRUE)
- cat("Found", length(file_list), "profile files in", profile_dir, "\n")
- if(length(file_list) == 0) stop("No .xlsx files found in ", profile_dir)
- # 2) Read each file (skip=1 to match your export), tag ParticipantID from filename (digits before first underscore)
- raw_list <- map(file_list, \(pth){
- df <- readxl::read_excel(pth, skip = 1, col_types = "text")
- df$ParticipantID <- stringr::str_extract(basename(pth), "^\\d+")
- dedup_cols(df)
- })
- raw <- bind_rows(raw_list)
- cat("Combined rows:", nrow(raw), " Cols:", ncol(raw), "\n")
- ```
- # Join blinding & artefact/notes; derive day/date/Real_ID
- ```{r 2-join-blinding-notes}
- # --- 1. Define missing helper function ---
- load_external <- function(path) {
- # Use here::here() to ensure path is absolute relative to project root
- abs_path <- here::here(path)
- if(!file.exists(abs_path)) stop("External file not found at: ", abs_path)
- readxl::read_xlsx(abs_path) %>%
- dplyr::mutate(ParticipantID = trimws(as.character(ParticipantID)))
- }
- # --- 2. Load Blinding and Notes ---
- blinding <- load_external(params$blinding_file)
- notes <- load_external(params$notes_file)
- expected_cols <- c(
- "ParticipantID", "Timepoint", "Day",
- # Durations
- "TIB_DURATION", "SLEEP_DURATION", "WAKE_DURATION", "NONREM_DURATION",
- "STAGE1_DURATION", "STAGE2_DURATION", "STAGE3_DURATION", "STAGE4_DURATION",
- "REM_DURATION", "WAKE_AFTER_SLEEP_ON", "MOVEMENT_DURATION",
- # Percentages
- "WAKE_PERCENTAGE", "REM_PERCENTAGE", "NONREM_PERCENTAGE", "SLEEP_EFFICIENCY",
- "STAGE1_PERCENTAGE", "STAGE2_PERCENTAGE", "STAGE3_PERCENTAGE", "DEEPSLEEP_PERCENTAGE",
- # Latencies
- "SLEEPLATENCY", "REM_LATENCY", "SLEEPLATENCY_DEEPSLEEP", "SLEEPLATENCY_LPS",
- # Other Metrics
- "FIRST_REM", "STAGE_CHANGE_NUMBER", "WAKE_NUMBER", "ARTEFACT_PERCENTAGE",
- "REM_DENSITY", "SLEEP_PROFILE_MANUALLY_SCORED"
- )
- # --- 3. Join and Derive ---
- merged <- raw %>%
- dplyr::mutate(ParticipantID = trimws(as.character(ParticipantID))) %>%
- # Ensure expected_cols contains all the software headers we discussed!
- dplyr::select(dplyr::any_of(c(expected_cols, "ParticipantID", "Original_filename", "Timepoint"))) %>%
- dplyr::left_join(blinding, by = "ParticipantID", suffix = c("", ".bl")) %>%
- dplyr::left_join(notes %>% dplyr::select(ParticipantID, dplyr::any_of(c("artefact_percentage", "scoreability"))),
- by = "ParticipantID") %>%
- dplyr::mutate(
- # 1. Handle filename and ID derivation
- Original_filename = dplyr::coalesce(
- !!!rlang::syms(intersect(names(.), c("Original_filename", "Original_filename.x", "Original_filename.bl"))),
- ParticipantID
- ),
- Real_ID = dplyr::coalesce(
- !!!rlang::syms(intersect(names(.), c("Real_ID", "Real_ID.bl", "Real_ID.y"))),
- stringr::str_extract(Original_filename, "(?<=REST_)\\d{3}"),
- ParticipantID
- ),
- # 2. Extract Date
- date_str = stringr::str_extract(Original_filename, "\\d{8}"),
- date = suppressWarnings(lubridate::dmy(date_str)),
- # 3. IMPROVED Day Extraction (Fixes the NA issue)
- # This looks for 'N' or 'NIGHT' followed by the number
- day_num = stringr::str_extract(Original_filename, "(?i)(?<=NIGHT|N|N_)\\d+"),
- day = dplyr::case_when(
- stringr::str_detect(Original_filename, "(?i)BSL|Baseline") ~ "BSL",
- stringr::str_detect(Original_filename, "(?i)FU|Follow") ~ "FU",
- !is.na(day_num) ~ as.character(day_num), # Maps 'N1' to '1', 'N2' to '2', etc.
- TRUE ~ as.character(Timepoint) # Fallback to Timepoint column
- )
- ) %>%
- dplyr::select(-dplyr::any_of(c("Original_filename.x","Original_filename.y","Original_filename.bl",
- "Real_ID.x","Real_ID.y","Real_ID.bl", "date_str", "day_num")))
- ```
- # Clean the data
- ```{r 3-clean-transform, message=FALSE, warning=FALSE}
- # 1. Settings
- day_levels <- c("BSL","1","2","3","4","5","6","7","FU")
- # Heuristic unit converter: day-fraction -> minutes; hours -> minutes; else assume minutes
- convert_to_minutes <- function(x){
- x <- numify(x)
- if (all(is.na(x))) return(x)
- xp <- x[!is.na(x)]
- if (length(xp) > 0 && max(xp, na.rm = TRUE) <= 1.1) {
- x * 24 * 60
- } else if (length(xp) > 0 && stats::median(xp, na.rm = TRUE) <= 20) {
- x * 60
- } else x
- }
- # Columns that represent durations (will be converted to minutes)
- duration_vars <- c(
- "TIB_DURATION","SLEEP_DURATION","WAKE_DURATION","REM_DURATION","NONREM_DURATION",
- "REM_LATENCY","SLEEPLATENCY","SLEEPLATENCY_NONREM","WAKE_AFTER_SLEEP_ON",
- "STAGE1_DURATION","STAGE2_DURATION","STAGE3_DURATION","STAGE4_DURATION",
- "SLEEPLATENCY_DEEPSLEEP","SLEEPLATENCY_LPS","MOVEMENT_DURATION",
- "MOVEMENT_DURATION_SPT","WAKE_DURATION_SPT","REM_DURATION_SPT",
- "STAGE1_DURATION_SPT","STAGE2_DURATION_SPT","STAGE3_DURATION_SPT","STAGE4_DURATION_SPT",
- "ARTEFACT_DURATION_SPT","FIRST_REM","REM_LATENCY_MSLT","REM_LATENCY_MWT"
- )
- # 2. Clean the raw column names
- # Removes Excel suffixes like "...15" and trims hidden spaces
- names(merged) <- trimws(gsub("\\.{3}\\d+$", "", names(merged)))
- # 3. Define the Rename Map (NEW NAME = OLD SOFTWARE NAME)
- rename_map <- c(
- "Wake (mins)" = "WAKE_DURATION",
- "REM (mins)" = "REM_DURATION",
- "N1 (mins)" = "STAGE1_DURATION",
- "N2 (mins)" = "STAGE2_DURATION",
- "N3 (mins)" = "STAGE3_DURATION",
- "Wake (%)" = "WAKE_PERCENTAGE",
- "REM (%)" = "REM_PERCENTAGE",
- "N1 (%)" = "STAGE1_PERCENTAGE",
- "N2 (%)" = "STAGE2_PERCENTAGE",
- "N3 (%)" = "STAGE3_PERCENTAGE",
- "WASO (mins)" = "WAKE_AFTER_SLEEP_ON",
- "REM Latency (mins)" = "REM_LATENCY",
- "SOL (mins)" = "SLEEPLATENCY",
- "TIB (mins)" = "TIB_DURATION",
- "TST (mins)" = "SLEEP_DURATION",
- "Sleep Efficiency (%)" = "SLEEP_EFFICIENCY",
- "N3 Latency (mins)" = "SLEEPLATENCY_DEEPSLEEP",
- "Sleep Stage Transitions" = "STAGE_CHANGE_NUMBER",
- "Transitions to Wake" = "WAKE_NUMBER")
- # 4. Build the 'clean' dataframe
- clean <- merged %>%
- # Convert durations (using the helper function defined earlier)
- dplyr::mutate(dplyr::across(dplyr::any_of(duration_vars), convert_to_minutes)) %>%
- # Apply the renaming map
- dplyr::rename(dplyr::any_of(rename_map)) %>%
- # Ensure correct data types and factors
- dplyr::mutate(
- Real_ID = as.character(Real_ID),
- day = factor(day, levels = day_levels),
- # Ensure these are numeric even if Excel had them as text
- artefact_percentage = suppressWarnings(as.numeric(as.character(artefact_percentage))),
- scoreability = suppressWarnings(as.numeric(as.character(scoreability)))
- )
- # --- NOTE: Participant and Artefact filtering removed per request ---
- final_output_path <- here::here("Data", "processed", "EEG")
- # 5. Final Export of the Wide Table
- writexl::write_xlsx(clean, file.path(final_output_path, "clean_EEG_data.xlsx"))
- # --- DIAGNOSTICS ---
- cat("--- Data Clean Check ---\n")
- cat("Total rows kept:", nrow(clean), "\n")
- cat("Total participants:", dplyr::n_distinct(clean$Real_ID), "\n")
- cat("Columns renamed successfully:", sum(names(rename_map) %in% names(clean)), "out of", length(rename_map), "\n")
- if(any(!names(rename_map) %in% names(clean))) {
- cat("Missing from raw data:", paste(names(rename_map)[!names(rename_map) %in% names(clean)], collapse=", "), "\n")
- }
- ```
- # Long data + exports
- ```{r 4-long-data}
- # 1. Define the variables we want to keep for analysis/plotting
- # (Must match the NEW names from Chunk 3 exactly)
- needed_vars <- c(
- "TIB (mins)", "TST (mins)", "Wake (mins)", "Wake (%)",
- "REM (mins)", "REM (%)", "N1 (mins)", "N1 (%)",
- "N2 (mins)", "N2 (%)", "N3 (mins)", "N3 (%)",
- "WASO (mins)", "REM Latency (mins)", "SOL (mins)",
- "Sleep Efficiency (%)", "N3 Latency (mins)",
- "Sleep Stage Transitions", "Transitions to Wake", # Added these
- "artefact_percentage"
- )
- # 2. Pivot to Long Format
- EEG_long <- clean %>%
- # Select ID, Day, and only the columns in our 'needed_vars' list
- dplyr::select(Real_ID, day, dplyr::any_of(needed_vars)) %>%
- # Ensure all value columns are numeric before pivoting
- # (Prevents "Can't combine double and character" errors)
- dplyr::mutate(dplyr::across(-c(Real_ID, day), ~suppressWarnings(as.numeric(as.character(.x))))) %>%
- # Transform from Wide to Long
- tidyr::pivot_longer(
- cols = -c(Real_ID, day),
- names_to = "variable",
- values_to = "value"
- ) %>%
- # Remove any rows where the value is NA (optional, helps keep the file small)
- dplyr::filter(!is.na(value))
- # 3. Export to Excel
- # --- NOTE: Participant and Artefact filtering removed per request ---
- final_output_path <- here::here("Data", "processed", "EEG")
- # 5. Final Export of the Wide Table
- writexl::write_xlsx(clean, file.path(final_output_path, "EEG_data_long_clean.xlsx"))
- # --- DIAGNOSTICS ---
- cat("--- Long Data Check ---\n")
- cat("Total rows in long format:", nrow(EEG_long), "\n")
- cat("Variables found and pivoted:\n")
- print(unique(EEG_long$variable))
- # Quick Peek at the first few rows
- head(EEG_long)
- ```
- # Summary statistics (Mean, SD, CI) and wide tables
- ```{r summaries}
- # SAFETY CHECK: If EEG_long doesn't exist or is empty, stop and warn
- if (!exists("EEG_long") || nrow(EEG_long) == 0) {
- stop("EEG_long is missing or empty. Please run the 'long-data' chunk first.")
- }
- # 1) Calculate Statistics
- sum_long <- EEG_long %>%
- # Filter out rows where value is NA to avoid math errors
- filter(!is.na(value)) %>%
- group_by(variable, day) %>%
- summarise(
- n = n(),
- mean = mean(value, na.rm = TRUE),
- sd = sd(value, na.rm = TRUE),
- se = sd / sqrt(pmax(n, 1)),
- tcrit = qt(0.975, df = pmax(n-1, 1)),
- ci_low = mean - tcrit * se,
- ci_high = mean + tcrit * se,
- .groups = "drop"
- ) %>%
- # Format into strings for the table
- mutate(
- `Mean (SD)` = sprintf("%.2f (%.2f)", mean, sd),
- CI = paste0(sprintf("%.2f", ci_low), "–", sprintf("%.2f", ci_high))
- )
- # 2) Create Wide Tables
- # Table 1: Mean (SD)
- mean_sd_wide <- sum_long %>%
- select(variable, day, `Mean (SD)`) %>%
- pivot_wider(names_from = day, values_from = `Mean (SD)`) %>%
- arrange(variable)
- # Table 2: N (Sample Size)
- n_wide <- sum_long %>%
- select(variable, day, n) %>%
- pivot_wider(names_from = day, values_from = n) %>%
- arrange(variable)
- output_path <- here::here("Output", "EEG")
- # 4) Display in the Report
- cap <- "EEG Mean (SD) by Day (Rows = Variables; Columns = BSL, 1–7, FU)"
- knitr::kable(mean_sd_wide, caption = cap) %>%
- kableExtra::kable_styling(full_width = FALSE, bootstrap_options = c("striped", "hover", "condensed"))
- ```
- # Plots (overall and minutes-only)
- ```{r plots, fig.width=11, fig.height=9}
- library(ggprism)
- # 1. Prepare data for plotting
- plot_df <- EEG_long %>%
- group_by(day, variable) %>%
- summarise(
- mean = mean(value, na.rm = TRUE),
- sd = sd(value, na.rm = TRUE),
- n = sum(!is.na(value)),
- se = sd / sqrt(pmax(n, 1)),
- .groups = "drop"
- )
- # 2. Define the selection (Minutes + Sleep Efficiency)
- # Adding "Sleep Efficiency (%)" to your list
- mins_plus_se <- c(
- "TIB (mins)", "TST (mins)", "Sleep Efficiency (%)",
- "WASO (mins)", "SOL (mins)", "REM Latency (mins)",
- "N3 Latency (mins)", "N1 (mins)", "N2 (mins)",
- "N3 (mins)", "REM (mins)"
- )
- # 3. Filter and set factor levels for stable plotting order
- plot_filtered <- plot_df %>%
- dplyr::filter(variable %in% mins_plus_se) %>%
- dplyr::mutate(variable = factor(variable, levels = mins_plus_se))
- # 4. Generate the Plot (Fixed Theme Order)
- p_eeg <- ggplot(plot_filtered, aes(x = day, y = mean, group = 1)) +
- geom_point(size = 3, shape = 17, color = "#0091d4") +
- geom_line(linewidth = 1, color = "#0091d4") +
- geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.2, color = "#0091d4") +
- # 1. Faceting
- facet_wrap(~ variable, scales = "free_y", ncol = 3) +
- # 3. Custom Overrides (Must come AFTER theme_prism)
- theme(
- legend.position = "none",
- strip.text = element_text(size = 9),
- axis.title = element_text(face = "bold"),
- plot.title = element_text(hjust = 0.5)
- ) +
- # 4. Labels
- labs(
- x = "Study Night",
- y = "Mean ± SE",
- )
- # 4. Generate the Plot (Clean Publication Style)
- p_eeg <- ggplot(plot_filtered, aes(x = day, y = mean, group = 1)) +
- # Geoms
- geom_line(linewidth = 1, color = "#0091d4") +
- geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.2, color = "#0091d4") +
- geom_point(size = 3, shape = 17, color = "#0091d4") +
- # Faceting - "free_y" allows each metric to have its own scale
- facet_wrap(~ variable, scales = "free_y", ncol = 3, axes = "all") +
- # --- MANUAL CLEAN THEME ---
- theme_classic() + # Removes grid and gray background automatically
- theme(
- # 1. Force Black Axis Lines (Prism Style)
- axis.line = element_line(colour = "black", linewidth = 0.8),
- axis.ticks = element_line(colour = "black", linewidth = 0.8),
- # 2. Format Text
- axis.text = element_text(color = "black", size = 10),
- axis.title = element_text(face = "bold", size = 11),
- # 3. Clean Facet Labels (No gray boxes)
- strip.background = element_blank(),
- strip.text = element_text(size = 10, face = "bold"),
- # 4. Final Polish
- legend.position = "none",
- panel.spacing = unit(1, "lines") # Adds a bit of air between plots
- ) +
- # --- END THEME ---
- labs(
- x = "Study Night",
- y = "Mean ± SE"
- )
- # Display
- print(p_eeg)
- fig_dir <- here::here("Figures", "EEG")
- # Create the folder if it doesn't exist
- if (!dir.exists(fig_dir)) {
- dir.create(fig_dir, recursive = TRUE)
- }
- # Save the high-res TIFF
- ggsave(file.path(fig_dir, "Figure 4.tiff"), p_eeg, width = 10, height = 7, dpi = 300, compression = "lzw")
- cat("Plot successfully saved to:", file.path(fig_dir, "Figure_4.tiff"))
- # 5. Save Exports
- ggsave(file.path(fig_dir, "Figure 4.jpg"), p_eeg, width = 10, height = 7, dpi = 300)
- ```
- # LMM function
- ```{r}
- run_eeg_lmm <- function(df_long, varname) {
- # 1. Prepare subset and ensure Value is numeric
- df_sub <- df_long %>%
- filter(variable == varname) %>%
- mutate(
- day = factor(day),
- value = as.numeric(as.character(value))
- ) %>%
- filter(!is.na(value))
- # 2. Check levels
- levels_present <- unique(as.character(df_sub$day))
- if (!("BSL" %in% levels_present) | length(levels_present) < 2) return(NULL)
- # 3. Fit Model
- fit <- tryCatch({
- lmer(value ~ day + (1 | Real_ID), data = df_sub, REML = TRUE)
- }, error = function(e) return(NULL))
- if (is.null(fit)) return(NULL)
- # 4. emmeans
- em <- emmeans(fit, "day")
- ct <- contrast(em, method = "trt.vs.ctrl", ref = "BSL") %>%
- summary(infer = TRUE) %>%
- as.data.frame()
- # 5. Calculate Cohen's d (Safety update here)
- # Pivot and ensure columns are numeric for the subtraction
- wide_data <- df_sub %>%
- select(Real_ID, day, value) %>%
- pivot_wider(names_from = day, values_from = value) %>%
- mutate(across(-Real_ID, ~as.numeric(as.character(.x)))) # FORCE NUMERIC
- days_to_compare <- setdiff(levels_present, "BSL")
- d_results <- map_dfr(days_to_compare, function(d_val) {
- # Check if both columns exist and are numeric
- if ("BSL" %in% names(wide_data) && d_val %in% names(wide_data)) {
- # Use na.rm = TRUE to handle missing values within the pair
- diffs <- wide_data[[d_val]] - wide_data$BSL
- mean_diff <- mean(diffs, na.rm = TRUE)
- sd_diff <- sd(diffs, na.rm = TRUE)
- cohens_d <- if(!is.na(sd_diff) && sd_diff != 0) mean_diff / sd_diff else NA
- return(tibble(contrast = paste0(d_val, " - BSL"), Cohens_d = cohens_d))
- }
- return(NULL)
- })
- # 6. Combine
- res <- ct %>%
- left_join(d_results, by = "contrast") %>%
- mutate(variable = varname)
- return(res)
- }
- ```
- # Linear mixed models (contrasts vs BSL) with within-person d
- ```{r}
- suppressPackageStartupMessages({
- library(flextable)
- library(officer)
- library(dplyr)
- library(tidyr)
- })
- # 1. Prepare Data
- word_table_df <- sum_long %>%
- filter(variable %in% target_vars) %>%
- mutate(contrast = paste0(day, " - BSL")) %>%
- left_join(EEG_effects, by = c("variable", "contrast")) %>%
- mutate(
- LCL = dplyr::coalesce(!!!rlang::syms(intersect(names(.), c("lower.CL", "asymp.LCL")))),
- UCL = dplyr::coalesce(!!!rlang::syms(intersect(names(.), c("upper.CL", "asymp.UCL"))))
- ) %>%
- mutate(variable = factor(variable, levels = target_vars)) %>%
- arrange(variable, day) %>%
- mutate(
- `Mean (SD)` = sprintf("%.2f (%.2f)", mean, sd),
- `Diffadj` = ifelse(day == "BSL", "-", sprintf("%.2f", estimate)),
- `95% CI` = ifelse(day == "BSL" | is.na(LCL), "-",
- paste0(sprintf("%.2f", LCL), " to ", sprintf("%.2f", UCL))),
- `d` = ifelse(day == "BSL" | is.na(Cohens_d), "-", sprintf("%.2f", Cohens_d)),
- Day_Label = dplyr::case_when(
- day == "BSL" ~ "Baseline",
- day == "FU" ~ "Follow up",
- TRUE ~ paste("Night", day)
- )
- ) %>%
- select(variable, Day_Label, `Mean (SD)`, `Diffadj`, `95% CI`, `d`)
- # 2. Convert to Grouped Data
- grouped_data <- as_grouped_data(x = word_table_df, groups = c("variable"))
- # Calculate group IDs for shading (ignoring the header rows)
- grouped_data <- grouped_data %>%
- mutate(
- temp_var = if_else(!is.na(variable), as.character(variable), NA_character_)
- ) %>%
- fill(temp_var, .direction = "down") %>%
- mutate(group_num = as.numeric(factor(temp_var, levels = target_vars)))
- # 3. Create Flextable
- ft <- flextable(grouped_data, col_keys = c("variable", "Day_Label", "Mean (SD)", "Diffadj", "95% CI", "d")) %>%
- # Header labels
- set_header_labels(
- variable = "EEG (n = 17)",
- Day_Label = "",
- `Mean (SD)` = "Mean (SD)",
- Diffadj = "Diffadj",
- `95% CI` = "95% CI",
- d = "d"
- ) %>%
- # Remove Italics
- italic(italic = FALSE, part = "all") %>%
- # Bold variable headers
- bold(i = ~ !is.na(variable), j = 1, bold = TRUE) %>%
- # Apply Shading (even groups get grey)
- bg(i = ~ group_num %% 2 == 0, bg = "#F2F2F2", part = "body") %>%
- # FIX: Hide the variable names in data rows using compose
- compose(i = ~ is.na(variable), j = "variable", value = as_paragraph("")) %>%
- # Styling
- fontsize(size = 10, part = "all") %>%
- align(align = "left", part = "all") %>%
- autofit() %>%
- # Borders
- border_remove() %>%
- hline_top(border = fp_border(width = 1.5), part = "header") %>%
- hline(i = 1, border = fp_border(width = 1.5), part = "header") %>%
- # Footer
- add_footer_lines("95% CI, 95% confidence interval of the mean difference; Diffadj, mean difference adjusted for missing values derived from linear mixed model; d, effect size (Cohen’s d); SD, standard deviation.") %>%
- hline_bottom(border = fp_border(width = 1.5), part = "footer")
- # 4. Export
- doc <- read_docx() %>%
- body_add_flextable(ft)
- word_out <- here::here("Output", "EEG", "EEG_LMM_results.docx")
- print(doc, target = word_out)
- cat("Success! Table saved to:", word_out)
- ```
- # LMM for relative sleep stages for supplementary
- ```{r}
- # --- 1. Define Percentage Variables ---
- target_perc_vars <- c("N1 (%)", "N2 (%)", "N3 (%)", "REM (%)")
- # --- 2. Run the LMM Logic for Percentages ---
- res_list_perc <- lapply(target_perc_vars, \(v) if(v %in% unique(EEG_long$variable)) run_eeg_lmm(EEG_long, v) else NULL)
- EEG_effects_perc <- dplyr::bind_rows(purrr::compact(res_list_perc))
- # --- 3. Join and Format Data ---
- final_perc_table_df <- sum_long %>%
- filter(variable %in% target_perc_vars) %>%
- mutate(contrast = paste0(day, " - BSL")) %>%
- left_join(EEG_effects_perc, by = c("variable", "contrast")) %>%
- # Fix for emmeans column naming
- mutate(
- LCL = dplyr::coalesce(!!!rlang::syms(intersect(names(.), c("lower.CL", "asymp.LCL")))),
- UCL = dplyr::coalesce(!!!rlang::syms(intersect(names(.), c("upper.CL", "asymp.UCL"))))
- ) %>%
- mutate(variable = factor(variable, levels = target_perc_vars)) %>%
- arrange(variable, day) %>%
- mutate(
- `Mean (SD)` = sprintf("%.2f (%.2f)", mean, sd),
- `Diffadj` = ifelse(day == "BSL", "-", sprintf("%.2f", estimate)),
- `95% CI` = ifelse(day == "BSL" | is.na(LCL), "-",
- paste0(sprintf("%.2f", LCL), " to ", sprintf("%.2f", UCL))),
- `d` = ifelse(day == "BSL" | is.na(Cohens_d), "-", sprintf("%.2f", Cohens_d)),
- Day_Label = dplyr::case_when(
- day == "BSL" ~ "Baseline",
- day == "FU" ~ "Follow up",
- TRUE ~ paste("Night", day)
- )
- ) %>%
- select(variable, Day_Label, `Mean (SD)`, `Diffadj`, `95% CI`, `d`)
- # --- 4. Create the Formatted Word Table ---
- grouped_perc_data <- as_grouped_data(x = final_perc_table_df, groups = c("variable"))
- # Shading logic
- grouped_perc_data <- grouped_perc_data %>%
- mutate(temp_var = if_else(!is.na(variable), as.character(variable), NA_character_)) %>%
- fill(temp_var, .direction = "down") %>%
- mutate(group_num = as.numeric(factor(temp_var, levels = target_perc_vars)))
- ft_perc <- flextable(grouped_perc_data, col_keys = c("variable", "Day_Label", "Mean (SD)", "Diffadj", "95% CI", "d")) %>%
- set_header_labels(variable = "EEG % (n = 17)", Day_Label = "", `Mean (SD)` = "Mean (SD)",
- Diffadj = "Diffadj", `95% CI` = "95% CI", d = "d") %>%
- italic(italic = FALSE, part = "all") %>%
- bold(i = ~ !is.na(variable), j = 1, bold = TRUE) %>%
- bg(i = ~ group_num %% 2 == 0, bg = "#F2F2F2", part = "body") %>%
- compose(i = ~ is.na(variable), j = "variable", value = as_paragraph("")) %>%
- fontsize(size = 10, part = "all") %>%
- align(align = "left", part = "all") %>%
- autofit() %>%
- border_remove() %>%
- hline_top(border = fp_border(width = 1.5), part = "header") %>%
- hline(i = 1, border = fp_border(width = 1.5), part = "header") %>%
- add_footer_lines("95% CI, 95% confidence interval; Diffadj, mean difference adjusted via LMM; d, Cohen’s d; SD, standard deviation.") %>%
- hline_bottom(border = fp_border(width = 1.5), part = "footer")
- # --- 5. Export ---
- doc_perc <- read_docx() %>% body_add_flextable(ft_perc)
- word_perc_out <- here::here("Output", "EEG", "EEG_LMM_Results_Proportions_supplementary.docx")
- print(doc_perc, target = word_perc_out)
- cat("Percentage results saved to Word:", word_perc_out)
- ```
- # Create forest plots to show effect sizes
- ```{r, fig.width=6, fig.height=9}
- library(dplyr)
- library(stringr)
- library(ggplot2)
- # 1. Define the chronological order for the Y-axis (Top to Bottom)
- night_levels <- c("1", "2", "3", "4", "5", "6", "7", "FU")
- night_labels <- c("Night 1", "Night 2", "Night 3", "Night 4", "Night 5", "Night 6", "Night 7", "Follow up")
- # 2. Prepare the data
- plot_data_final <- EEG_effects %>%
- # Filter out any rows where LMM didn't run
- filter(!is.na(estimate)) %>%
- mutate(
- # FIX: Create the 'day' column by extracting word/number before the dash
- # This turns "1 - BSL" into "1" and "FU - BSL" into "FU"
- extracted_day = str_extract(contrast, "^[^ ]+"),
- # Now create the Day_Label factor for the Y-axis
- Day_Label = factor(extracted_day,
- levels = rev(night_levels),
- labels = rev(night_labels)),
- # Ensure math columns are numeric
- estimate = as.numeric(as.character(estimate)),
- low = as.numeric(lower.CL),
- high = as.numeric(upper.CL),
- abs_d = abs(as.numeric(as.character(Cohens_d))),
- # Cohen's d sizing bins
- d_size = case_when(
- abs_d < 0.2 ~ "Negligible (< 0.2)",
- abs_d >= 0.2 & abs_d < 0.5 ~ "Small (0.2)",
- abs_d >= 0.5 & abs_d < 0.8 ~ "Medium (0.5)",
- abs_d >= 0.8 ~ "Large (0.8)",
- TRUE ~ "Small (0.2)"
- ),
- d_size = factor(d_size, levels = c("Negligible (< 0.2)", "Small (0.2)", "Medium (0.5)", "Large (0.8)"))
- ) %>%
- filter(!is.na(Day_Label)) # Drops anything that didn't match our 1-7/FU list
- # 3. Split into your two requested groups
- df_summary_plot <- plot_data_final %>%
- filter(variable %in% c("TST (mins)", "TIB (mins)", "WASO (mins)", "SOL (mins)", "Sleep Efficiency (%)"))
- df_stages_plot <- plot_data_final %>%
- filter(variable %in% c("N1 (mins)", "N2 (mins)", "N3 (mins)", "REM (mins)"))
- library(ggplot2)
- make_final_eeg_plot <- function(data_subset, plot_title, theme_color) {
- # 1. Symmetric limits for the 0-anchor
- limit_val <- max(abs(c(data_subset$low, data_subset$high)), na.rm = TRUE) * 1.1
- if(is.infinite(limit_val) || is.na(limit_val)) limit_val <- 10
- ggplot(data_subset, aes(x = estimate, y = Day_Label)) +
- # Reference line at zero
- geom_vline(xintercept = 0, linetype = "dashed", color = "gray70") +
- # Error bars matching theme_color
- geom_errorbarh(aes(xmin = low, xmax = high),
- height = 0.3, linewidth = 0.8, color = theme_color, alpha = 0.5) +
- # Blobs (Points) sized by Cohen's d
- geom_point(aes(size = d_size), color = theme_color) +
- # Separate plots for each metric
- facet_wrap(~variable, scales = "free_x", ncol = 2) +
- expand_limits(x = c(-limit_val, limit_val)) +
- # Define the "Blob" sizes based on Cohen's conventions
- scale_size_manual(values = c(
- "Negligible (< 0.2)" = 1.5,
- "Small (0.2)" = 3,
- "Medium (0.5)" = 5,
- "Large (0.8)" = 8
- )) +
- labs(
- title = plot_title,
- subtitle = "(n = 17)",
- x = "Adjusted Mean Difference (95% CI)",
- y = NULL,
- size = "Effect Size (d)"
- ) +
- theme_minimal() +
- theme(
- strip.text = element_text(face = "bold", size = 9),
- panel.grid.minor = element_blank(),
- panel.spacing = unit(1.2, "lines"),
- legend.position = "bottom"
- )
- }
- # 4. Generate the Plots
- p_summary <- make_final_eeg_plot(df_summary_plot, "EEG Sleep Continuity", "#1B4F72")
- p_stages <- make_final_eeg_plot(df_stages_plot, "EEG Sleep Architecture", "#0E6251")
- # 5. Display
- print(p_summary)
- # 6. Save the Plots
- ggsave(file.path(fig_dir, "Sleep_continuity.jpg"), p_summary, width = 6, height = 9, dpi = 600)
- print(p_stages)
- ggsave(file.path(fig_dir, "Sleep_stages.jpg"), p_stages, width = 6, height = 7, dpi = 600)
- ```
- # Create heat map of sleep stage changes
- ```{r}
- library(tidyverse)
- library(lubridate)
- # 1. PATH SETUP (Now platform-independent)
- input_path <- here::here("Data", "raw", "EEG", "hypnograms_txt")
- # 2. Verify and Load
- if (!dir.exists(input_path)) {
- stop("Path not found! Check if 'Data/raw/EEG/hypnograms_txt' exists in your Project folder.")
- }
- all_files <- list.files(input_path, pattern = "\\.txt$", full.names = TRUE, ignore.case = TRUE)
- cat("Success! Found", length(all_files), "files in:", input_path)
- all_files <- list.files(input_path, pattern = "\\.txt$", full.names = TRUE)
- # --- 1. UPDATED LOADER (Ensures Real_ID exists) ---
- read_hypno_by_date <- function(f) {
- fname <- basename(f)
- # Extract ID (First 3 digits) and Date (YYYY_MM_DD)
- subj_id <- stringr::str_extract(fname, "^\\d{3}")
- date_str <- stringr::str_extract(fname, "\\d{4}_\\d{2}_\\d{2}")
- # Skip file if naming doesn't match expected pattern
- if(is.na(subj_id) | is.na(date_str)) return(NULL)
- # Read the numeric data
- df <- tryCatch({
- read.table(f, header = FALSE, sep = "")
- }, error = function(e) return(NULL))
- if(is.null(df) || ncol(df) < 1) return(NULL)
- # Standardize and add metadata
- df %>%
- rename(stage = V1, exclude = V2) %>%
- filter(stage != 6, exclude == 0) %>%
- mutate(
- stage_name = case_when(
- stage == 0 ~ "Wake", stage == 1 ~ "N1", stage == 2 ~ "N2",
- stage == 3 ~ "N3", stage %in% c(4, 5) ~ "REM", TRUE ~ NA_character_
- ),
- # ENSURE THESE COLUMNS ARE CREATED FOR EVERY ROW
- Real_ID = paste0("REST_", subj_id),
- file_date = lubridate::ymd(date_str),
- is_bsl = str_detect(fname, "(?i)Bsl|Baseline")
- )
- }
- # --- 2. RUN THE LOADING ---
- raw_master <- purrr::map_df(all_files, read_hypno_by_date)
- # Check if Real_ID actually exists now
- if(!"Real_ID" %in% names(raw_master)) stop("Real_ID column missing after load!")
- # --- 3. UNBLINDING AND LABELING ---
- unblinded_master <- raw_master %>%
- group_by(Real_ID) %>%
- # Ensure chronological order per participant
- arrange(file_date, .by_group = TRUE) %>%
- mutate(
- day_type = if_else(is_bsl, "BSL", "Intervention")
- ) %>%
- # Now, within each participant, rank the 'Intervention' nights
- group_by(Real_ID, day_type) %>%
- mutate(
- night_num = if_else(day_type == "Intervention", dense_rank(file_date), NA_integer_)
- ) %>%
- ungroup() %>%
- mutate(
- # Final Labeling
- day_label = case_when(
- day_type == "BSL" ~ "BSL",
- night_num == 8 ~ "FU", # Logic: If 8th night after BSL, it's Follow Up
- !is.na(night_num) ~ paste0("Night ", night_num),
- TRUE ~ "Other"
- ),
- day_label = factor(day_label, levels = c("BSL", paste0("Night ", 1:7), "FU"))
- )
- # --- 4. CALCULATE TRANSITIONS ---
- stage_order <- c("Wake", "N1", "N2", "N3", "REM")
- final_probs <- unblinded_master %>%
- group_by(Real_ID, day_label) %>% # Calculate transitions WITHIN each night
- mutate(
- current = factor(stage_name, levels = stage_order),
- next_s = factor(lead(stage_name), levels = stage_order)
- ) %>%
- filter(!is.na(current), !is.na(next_s)) %>%
- group_by(day_label, current, next_s) %>%
- summarise(count = n(), .groups = 'drop_last') %>%
- mutate(prob = (count / sum(count)) * 100) %>%
- ungroup()
- bsl_vals <- final_probs %>%
- filter(day_label == "BSL") %>%
- select(current, next_s, bsl_prob = prob)
- diff_matrix <- final_probs %>%
- filter(day_label != "BSL") %>%
- left_join(bsl_vals, by = c("current", "next_s")) %>%
- mutate(
- bsl_prob = replace_na(bsl_prob, 0),
- diff = prob - bsl_prob
- )
- # --- 3. GENERATE THE ORDERED HEATMAP ---
- ggplot(diff_matrix, aes(x = current, y = next_s, fill = diff)) +
- geom_tile(color = "white", linewidth = 0.3) +
- geom_text(aes(label = sprintf("%.1f%%", diff)),
- size = 2.5, fontface = "bold") +
- scale_fill_gradient2(
- low = "#4a1486", mid = "white", high = "#d95f0e",
- midpoint = 0, name = "Δ from BSL %"
- ) +
- facet_wrap(~day_label, ncol = 4) +
- theme_minimal() +
- labs(
- x = "From Stage (Epoch n)",
- y = "To Stage (Epoch n+1)"
- ) +
- theme(
- axis.text.x = element_text(angle = 45, hjust = 1),
- strip.text = element_text(face = "bold")
- )
- library(ggplot2)
- # 1. DEFINE FILENAME AND PATH
- # Use here() to point specifically to your Figures/EEG folder
- output_path <- here::here("Figures", "EEG", "Figure S3.jpg")
- # 2. SAVE THE PLOT
- ggsave(
- filename = output_path,
- plot = last_plot(),
- device = "jpeg", # Changed to 'jpeg' for consistency with .jpg extension
- dpi = 300,
- width = 12,
- height = 6,
- units = "in"
- )
- message("Success! Plot saved to: ", output_path)
- ```
- # Adaptation nights
- ```{r, fig.width=12, fig.height=20, warning=FALSE, message=FALSE}}
- # --- 1. SETUP HELPERS & MAPPINGS ---
- # Define the bridge between Adaptation (CAPS) and Clean (Pretty) names
- rename_map <- c(
- "TIB (mins)" = "TIB_DURATION",
- "TST (mins)" = "SLEEP_DURATION",
- "Wake (mins)" = "WAKE_DURATION",
- "N1 (mins)" = "STAGE1_DURATION",
- "N2 (mins)" = "STAGE2_DURATION",
- "N3 (mins)" = "STAGE3_DURATION",
- "REM (mins)" = "REM_DURATION",
- "WASO (mins)" = "WAKE_AFTER_SLEEP_ON",
- "Sleep Efficiency (%)" = "SLEEP_EFFICIENCY",
- "SOL (mins)" = "SLEEPLATENCY",
- "REM Latency (mins)" = "REM_LATENCY",
- "N3 Latency (mins)" = "SLEEPLATENCY_DEEPSLEEP"
- )
- # List of duration variables to be converted to minutes
- duration_vars <- c("TIB_DURATION", "SLEEP_DURATION", "WAKE_DURATION",
- "STAGE1_DURATION", "STAGE2_DURATION", "STAGE3_DURATION",
- "REM_DURATION", "WAKE_AFTER_SLEEP_ON", "SLEEPLATENCY",
- "REM_LATENCY", "SLEEPLATENCY_DEEPSLEEP")
- # Helper to handle duplicate columns
- dedup_cols <- function(df) {
- colnames(df) <- make.unique(colnames(df))
- df
- }
- # Custom loader for Adaptation files
- read_adapt_only <- function(f) {
- df <- readxl::read_excel(f, skip = 1, col_types = "text") %>%
- dedup_cols()
- df$Real_ID <- stringr::str_extract(basename(f), "^\\d{3}")
- df$day <- "AD"
- return(df)
- }
- # --- 2. EXECUTE DATA PROCESSING ---
- library(tidyverse)
- library(lubridate)
- library(here)
- # Load AD files
- adapt_path <- here::here("Data", "raw", "EEG", "adaptation_night_sleep_profile")
- adapt_files <- list.files(adapt_path, pattern = "\\.xlsx$", full.names = TRUE)
- adapt_raw_df <- purrr::map_dfr(adapt_files, read_adapt_only)
- ## 1. Improved Converter: Handles Excel Serial (0.05), Seconds (4000), and Minutes (70)
- convert_to_minutes_smart <- function(x) {
- x_num <- as.numeric(as.character(x))
- dplyr::case_when(
- is.na(x_num) ~ NA_real_,
- # If it's a tiny decimal (Excel serial), convert to minutes
- x_num > 0 & x_num < 2 ~ x_num * 1440,
- # If it's a massive number (Seconds), convert to minutes
- x_num > 1440 ~ x_num / 60,
- # Otherwise, assume it's already in minutes
- TRUE ~ x_num
- )
- }
- # 2. Clean the AD side
- adapt_prep <- adapt_raw_df %>%
- dplyr::rename(dplyr::any_of(rename_map)) %>%
- # Apply the smart converter to every column except ID/Day
- dplyr::mutate(across(-c(Real_ID, day), convert_to_minutes_smart)) %>%
- dplyr::mutate(Real_ID = as.character(Real_ID), day = "AD")
- # 3. Clean the BSL/Study side
- # This forces 'clean' to be numeric too, stopping the "Can't combine" error
- clean_prep <- clean %>%
- dplyr::mutate(across(where(is.character) & !c(Real_ID, day), ~as.numeric(as.character(.x)))) %>%
- dplyr::mutate(Real_ID = as.character(Real_ID), day = as.character(day))
- # 4. Bind them - Now they are both numeric and in the right units!
- full_timeline_df <- dplyr::bind_rows(adapt_prep, clean_prep) %>%
- dplyr::mutate(day = factor(day, levels = c("AD", "BSL", "1", "2", "3", "4", "5", "6", "7", "FU")))
- # --- 3. PIVOT & PLOT ---
- full_long <- full_timeline_df %>%
- dplyr::select(Real_ID, day, any_of(mins_plus_se)) %>%
- # Force all metric columns to numeric
- dplyr::mutate(across(-c(Real_ID, day), ~as.numeric(as.character(.x)))) %>%
- tidyr::pivot_longer(
- cols = -c(Real_ID, day),
- names_to = "variable",
- values_to = "value"
- ) %>%
- dplyr::filter(!is.na(value))
- full_summary <- full_long %>%
- group_by(day, variable) %>%
- summarise(
- mean = mean(value, na.rm = TRUE),
- se = sd(value, na.rm = TRUE) / sqrt(pmax(n(), 1)),
- .groups = "drop"
- )
- p_full_timeline <- ggplot(full_summary, aes(x = day, y = mean, group = 1)) +
- geom_line(linewidth = 0.8, color = "gray60", linetype = "dashed") +
- geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.2, color = "#2E4053") +
- geom_point(size = 3, aes(color = (day == "AD")), shape = 16) +
- scale_color_manual(values = c("TRUE" = "#E67E22", "FALSE" = "#2E4053")) +
- facet_wrap(~ variable, scales = "free", ncol = 3) +
- theme_classic() +
- theme(
- strip.background = element_blank(),
- strip.text = element_text(face = "bold", size = 10),
- axis.text.x = element_text(angle = 45, hjust = 1),
- legend.position = "none"
- ) +
- labs(x = "Study Night", y = "Mean ± SE")
- ## 1. Verify where 'here' thinks the root is
- print(paste("Project Root is:", here::here()))
- # 2. Save using the explicit path construction
- # This ensures it goes: Project Root -> Figures -> EEG -> Filename
- ggsave(
- filename = here::here("Figures", "EEG", "EEG_with_adaptation_night.png"),
- plot = p_full_timeline,
- width = 15,
- height = 9,
- dpi = 300
- )
- ```
- # SOREMPS
- ```{r}
- # 1. PREPARE THE DATA
- soremp_clean <- full_timeline_df %>%
- select(Real_ID, day, `REM Latency (mins)`) %>%
- mutate(
- rem_val = as.numeric(`REM Latency (mins)`),
- # Define SOREMP: REM within 15 minutes of sleep onset
- is_soremp = rem_val <= 15,
- soremp_label = if_else(is_soremp, "SOREMP (≤15m)", "Normal (>15m)")
- ) %>%
- # Filter out any NAs just in case, though you mentioned they all have REM
- filter(!is.na(rem_val))
- # 2. PLOT BY PARTICIPANT (To see individual drivers)
- ggplot(soremp_clean, aes(x = day, y = 1, fill = soremp_label)) +
- geom_tile(color = "white", linewidth = 0.5) + # Heatmap style tiles
- facet_wrap(~ Real_ID, ncol = 3) + # One small plot per participant
- scale_fill_manual(values = c("SOREMP (≤15m)" = "#d95f0e", "Normal (>15m)" = "#2E4053")) +
- theme_minimal() +
- labs(
- title = "Individual SOREMP Occurrences across Study Nights",
- subtitle = "Orange tiles indicate REM onset within 15 minutes",
- x = "Study Night",
- y = NULL,
- fill = "Status"
- ) +
- theme(
- axis.text.y = element_blank(), # Hide Y axis since it's just a presence tile
- axis.ticks.y = element_blank(),
- panel.grid = element_blank(),
- strip.text = element_text(face = "bold"),
- axis.text.x = element_text(angle = 45, hjust = 1),
- legend.position = "bottom"
- )
- # 3. SAVE INDIVIDUAL TRACKER
- ggsave(
- filename = here::here("Figures", "EEG", "SOREMP_Individual_Tracker.png"),
- width = 12,
- height = 8,
- dpi = 300
- )
- # 1. CREATE ANONYMOUS MAPPING
- # This creates a lookup table: Real_ID "101" -> "Participant A"
- id_mapping <- full_timeline_df %>%
- distinct(Real_ID) %>%
- arrange(Real_ID) %>%
- mutate(
- anon_id = paste("Participant", LETTERS[row_number()])
- )
- # 2. PREPARE DATA WITH ANONYMOUS LABELS
- soremp_anon <- full_timeline_df %>%
- left_join(id_mapping, by = "Real_ID") %>%
- select(anon_id, day, `REM Latency (mins)`) %>%
- mutate(
- rem_val = as.numeric(`REM Latency (mins)`),
- is_soremp = rem_val <= 15,
- soremp_label = if_else(is_soremp, "SOREMP (≤15m)", "REM Latency (>15m)")
- ) %>%
- filter(!is.na(rem_val))
- # 3. PLOT ANONYMIZED FACETS
- ggplot(soremp_anon, aes(x = day, y = 1, fill = soremp_label)) +
- geom_tile(color = "white", linewidth = 0.6) +
- facet_wrap(~ anon_id, ncol = 4) + # Grouped by letter A, B, C...
- scale_fill_manual(
- values = c("SOREMP (≤15m)" = "#d95f0e", "REM Latency (>15m)" = "#2E4053"),
- name = "Status"
- ) +
- theme_minimal() +
- labs(
- x = "Study Night",
- y = NULL
- ) +
- theme(
- axis.text.y = element_blank(),
- axis.ticks.y = element_blank(),
- panel.grid = element_blank(),
- strip.text = element_text(face = "bold", size = 10),
- axis.text.x = element_text(angle = 45, hjust = 1),
- legend.position = "bottom",
- strip.background = element_rect(fill = "#f0f0f0", color = NA)
- )
- # 4. SAVE
- ggsave(
- filename = here::here("Figures", "EEG", "SOREMs.png"),
- width = 10,
- height = 6,
- dpi = 300
- )
- ```
- # Session info
- ```{r session-info}
- sessioninfo::session_info()
- ```
06_EEG.Rmd at commit e76059b, under CC-BY-4.0 · at the source
Overview
- Sir Jules Thorn Sleep and Circadian Neuroscience Institute, Dorothy Crowfoot Hodgkin Building, Nuffield Department of Clinical Neurosciences, University of Oxford, Oxford, OX1 3QU
- Institute of Biomedical Engineering, Department of Engineering Science, University of Oxford, Oxford, United Kingdom
Abstract
Objectives: Self-applied, low-density EEG offers opportunities to examine sleep in the home environment, yet its feasibility during behavioural sleep interventions remains unexplored. This pilot study aimed to evaluate the feasibility and acceptability of a self-applied, low-density EEG device during sleep restriction therapy (SRT) and explore effects on sleep and affect.
Methods: Seventeen adults with insomnia and depressive symptoms completed a 2-week baseline and 4 weeks of SRT. The primary outcome was the proportion of expected EEG recordings completed and scoreable. Secondary outcomes included clinical measures, sleep continuity (sleep diary, actigraphy), sleep architecture (low-density EEG for 9 nights), power spectral density, and affect. Data were analysed with linear mixed models. Cohen’s d and 95% confidence intervals were reported.
Results: Feasibility was demonstrated (92% of expected EEG nights completed). SRT was associated with reductions in insomnia severity, depressive symptoms, negative affect, and increases in positive affect. Robust improvements were observed across treatment in sleep continuity (SOL, WASO, SE) from diary, which were paralleled by actigraphy. EEG revealed reduced TIB, TST, N1, N2, REM sleep, and REM latency during week one. Reductions in EEG-derived TIB and N1 sleep were maintained at night 28. There were no reliable differences for spectral or spindle measures.
Conclusions: These findings suggest that self-applied, low-density EEG during SRT is feasible, acceptable, and may capture sleep changes during treatment. They highlight the potential for multi-night monitoring of sleep interventions at home and elucidating mechanisms underlying therapeutic change.
Reproduced under the paper's license (CC BY), from the paper cited above.
Repository
Its files are read in the Code ↔ Paper reader above, with 7 matches between paragraphs and lines of code.
emstanyer/The-RESTORE-Study
e76059bfe5b8d8e7bab346c28f7a305a244f8171, 22 June 2026Availability: 1 check, the latest on 28 September 2026: the link answers
- 28 September 2026: the link answers
16 files
- Scripts/
01_demographics.Rmd , R, 173 lines - Scripts/
02_follow_up.Rmd , R, 196 lines, 1 match - Scripts/
03_feasibility.Rmd , R, 180 lines - Scripts/
04_PANAS.Rmd , R, 483 lines - Scripts/
05_sleep_diary_actigraph , R, 702 lines, 1 matchy.Rmd - Scripts/
06_EEG.Rmd , R, 1,171 lines, 2 matches - Scripts/
07_power_spectral_analys , R, 389 linesis.Rmd - Scripts/
08_spindles_and_SOs.Rmd , R, 324 lines, 1 match - Scripts/
09_adherence.Rmd , R, 524 lines - Scripts/
10_NPCRA_light_exposure. , R, 2,032 linesRmd - Scripts/
11_hypnogram_trim.Rmd , R, 147 lines - Scripts/
12_hypnogram_renaming.Rm , R, 63 linesd - Scripts/
RESTORE_Spectral_Analysi , MATLAB, 271 lines, 1 matchs_NREM.m - Scripts/
RESTORE_Spectral_Analysi , MATLAB, 265 lines, 1 matchs_REM.m - LICENSE, License, 6 lines
- README.md, Text, 35 lines
Tracing map
Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.
What the map holds:
- 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
- 14 scripts, each with its path and the digest of its content;
- 7 matches between paragraphs of the paper and lines of the code (method lexical-v1);
- neither the text of the paper nor the code itself.
Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.
Data
No dataset and no data link were found in the paper.
Data availability statement
The data that support the findings of this study will be made available on reasonable request from the authors.
Reproduced under the paper's license (CC BY), from the paper cited above.
Versions
The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.
Version 1, 28 September 2026: the first record
Recorded: type, journal, dates, 8 authors, 6 keywords, 2 funders, 52 references.
Cite
This paper
Stanyer, E. C., Le Roux, M., Sharman, R., Pereira, S. I. R., Davidson, S., Tarassenko, L., Espie, C. A., & Kyle, S. D. (2026). Insights from nine nights of self-applied, low-density sleep EEG during sleep restriction therapy: a proof-of-concept evaluation. medRxiv (preprint). https://
BibTeX
@article{stanyer2026insi
author = {Stanyer, Emily C. and Le Roux, Maëlwenn and Sharman, Rachel and Pereira, Sofia I. Ribeiro and Davidson, Shaun and Tarassenko, Lionel and Espie, Colin A. and Kyle, Simon D.},
title = {{Insights from nine nights of self-applied, low-density sleep EEG during sleep restriction therapy: a proof-of-concept evaluation}},
journal = {medRxiv (preprint)},
year = {2026},
month = may,
publisher = {medRxiv},
doi = {10.64898/
url = {https://
}
RIS
TY - JOUR
AU - Stanyer, Emily C.
AU - Le Roux, Maëlwenn
AU - Sharman, Rachel
AU - Pereira, Sofia I. Ribeiro
AU - Davidson, Shaun
AU - Tarassenko, Lionel
AU - Espie, Colin A.
AU - Kyle, Simon D.
TI - Insights from nine nights of self-applied, low-density sleep EEG during sleep restriction therapy: a proof-of-concept evaluation
T2 - medRxiv (preprint)
J2 - medRxiv
PY - 2026
DA - 2026/
PB - medRxiv
DO - 10.64898/
UR - https://
ER -
CSL-JSON
{
"id": "10.64898/
"type": "article",
"title": "Insights from nine nights of self-applied, low-density sleep EEG during sleep restriction therapy: a proof-of-concept evaluation",
"container-title": "medRxiv (preprint)",
"author": [
{
"family": "Stanyer",
"given": "Emily C."
},
{
"family": "Le Roux",
"given": "Maëlwenn"
},
{
"family": "Sharman",
"given": "Rachel"
},
{
"family": "Pereira",
"given": "Sofia I. Ribeiro"
},
{
"family": "Davidson",
"given": "Shaun"
},
{
"family": "Tarassenko",
"given": "Lionel"
},
{
"family": "Espie",
"given": "Colin A."
},
{
"family": "Kyle",
"given": "Simon D."
}
],
"container-title-short":
"DOI": "10.64898/
"publisher": "medRxiv",
"URL": "https://
"issued": {
"date-parts": [
[
2026,
5,
15
]
]
}
}
The tracing map gets a citation of its own once an author has validated it and it has a DOI.
Similar papers
The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.
- [1] doi:10.1016/j.neuroimage.2026.122115 [code]
- Midfrontal theta power relates to response speeding following frustrative nonreward.Journal: NeuroImageIn common: rstatix, easystats, emmeans, 8 other tools, EEG
- [2] doi:10.1073/pnas.2603114123 [code]
- The human hippocampus can pattern separate memories by meaning.Journal: Proceedings of the National Academy of Sciences of the United States of AmericaIn common: rstatix, easystats, broom, 7 other tools
- [3] doi:10.1126/sciadv.aeb8106 [code]
- A thyroid hormone-mediated opsin switch initiates metamorphosis in a proto-vertebrate.Journal: Science advancesIn common: rstatix, easystats, broom, 7 other tools
- [4] doi:10.1038/s41398-026-04010-9 [code]
- Bullying victimization and brain development: a longitudinal structural magnetic resonance imaging study from adolescence to early adulthood.Journal: Translational psychiatryIn common: rstatix, easystats, broom, 7 other tools
- [5] doi:10.7554/elife.107088 [code]
- Development of auditory and spontaneous movement responses to music over the first postnatal year.Journal: eLifeIn common: easystats, FieldTrip, emmeans, 6 other tools, EEG, 1 reference
- [6] doi:10.1038/s41467-026-73865-9 [code]
- Histamine shapes the neurocomputational dynamics of human learning.Journal: Nature communicationsIn common: rstatix, easystats, broom, 6 other tools
- [7] doi:10.1016/j.celrep.2026.117646 [code]
- Medial entorhinal-hippocampal desynchronization parallels the emergence of memory impairment in a mouse model of Alzheimer's disease pathology.Journal: Cell reportsIn common: rstatix, broom, FieldTrip, 6 other tools
- [8] doi:10.1002/hbm.70605 [code]
- BrainEnrich: Revealing Biological Insights for Imaging-Derived Phenotypes Through Transcriptomic Enrichment.Journal: Human brain mappingIn common: easystats, broom, emmeans, 6 other tools, depression
- [9] doi:10.1016/j.isci.2026.116601 [code]
- Random auditory stimulation during sleep disturbs traveling slow waves and declarative memory.Journal: iScienceIn common: rstatix, FieldTrip, lmerTest, 6 other tools, 1 reference
- [10] doi:10.1126/sciadv.aec9291 [code]
- Computational mechanisms of perception in autism revealed using games inspired by rodent operant tasks.Journal: Science advancesIn common: rstatix, easystats, broom, 6 other tools
Contribute
The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.
Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 1 repository of the authors' code, each at its verified commit and with its license, 14 scripts, and 7 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:fb43e9323aeb1f78…
Add the badge to its README
The badge links the code to this page. Copy one of these into the README of the paper's code: only you decide where it goes, and nothing is changed for you.
Markdown
[, paste the snippet at the top, then “Commit changes…” and, to review it first, “Create a new branch and start a pull request”. You open the pull request; OSCR asks for no permission.
Request its removal
To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).
Discussion, reproductions, activity
Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.
Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.
Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.
