OSCR

Insights from nine nights of self-applied, low-density sleep EEG during sleep restriction therapy: a proof-of-concept evaluation

Code ↔ Paper

7 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 7 matches
  1. [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. [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. [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. [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. [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. [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. [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

  1. ---
  2. title: "EEG Sleep Analysis"
  3. author: "Emily Stanyer"
  4. date: "`r Sys.Date()`"
  5. output:
  6. html_document:
  7. toc: true
  8. df_print: paged
  9. pdf_document:
  10. toc: true
  11. number_sections: true
  12. params:
  13. # ---- INPUTS ----
  14. profile_dir: "Data/raw/EEG/sleep_profile/" # folder of nightly EEG profile .xlsx files
  15. blinding_file: "Data/raw/EEG/blinding_files/blinding.xlsx" # has ParticipantID, Timepoint, Original_filename, Real_ID (if available)
  16. notes_file: "Data/raw/EEG/scoring_notes/Scoring_Notes_Final_110725.xlsx" # has ParticipantID, artefact_percentage, scoreability, impedances
  17. # ---- OUTPUTS ----
  18. proc_dir: "Data/processed/EEG"
  19. fig_dir: "Figures/EEG"
  20. out_dir: "Output/EEG"
  21. apply_artifact_filter: false # set true to drop nights with artefact % > threshold
  22. artifact_threshold: 10 # percent
  23. ---
  24. ```{r setup, include=FALSE}
  25. knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE,
  26. fig.width = 10, fig.height = 4.8)
  27. suppressPackageStartupMessages({
  28. library(readr)
  29. library(readxl)
  30. library(dplyr)
  31. library(tidyr)
  32. library(lubridate)
  33. library(stringr)
  34. library(ggplot2)
  35. library(ggprism)
  36. library(writexl)
  37. library(knitr)
  38. library(kableExtra)
  39. library(sessioninfo)
  40. library(lme4)
  41. library(emmeans)
  42. library(purrr)
  43. })
  44. # ---- paths ----
  45. out_dir <- params$out_dir
  46. fig_dir <- params$fig_dir
  47. dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)
  48. dir.create(fig_dir, recursive = TRUE, showWarnings = FALSE)
  49. set.seed(2025)
  50. ```
  51. # Read and combine raw EEG profile files
  52. ```{r 1-read-eeg}
  53. # Helper: deduplicate readxl-style column suffixes like NAME...1, NAME...2 (keep first)
  54. library(here)
  55. dedup_cols <- function(df){
  56. base <- gsub("\\.{3}\\d+$", "", names(df))
  57. keep <- !duplicated(base)
  58. names(df) <- base
  59. df[, keep, drop = FALSE]
  60. }
  61. # Helper: safer numeric coercion from character
  62. numify <- function(x){ suppressWarnings(as.numeric(x)) }
  63. # 1) List files
  64. # Instead of a hardcoded string, use:
  65. profile_dir <- here::here(params$profile_dir)
  66. stopifnot(dir.exists(profile_dir))
  67. file_list <- list.files(profile_dir, pattern = "\\.xlsx$", full.names = TRUE)
  68. cat("Found", length(file_list), "profile files in", profile_dir, "\n")
  69. if(length(file_list) == 0) stop("No .xlsx files found in ", profile_dir)
  70. # 2) Read each file (skip=1 to match your export), tag ParticipantID from filename (digits before first underscore)
  71. raw_list <- map(file_list, \(pth){
  72. df <- readxl::read_excel(pth, skip = 1, col_types = "text")
  73. df$ParticipantID <- stringr::str_extract(basename(pth), "^\\d+")
  74. dedup_cols(df)
  75. })
  76. raw <- bind_rows(raw_list)
  77. cat("Combined rows:", nrow(raw), " Cols:", ncol(raw), "\n")
  78. ```
  79. # Join blinding & artefact/notes; derive day/date/Real_ID
  80. ```{r 2-join-blinding-notes}
  81. # --- 1. Define missing helper function ---
  82. load_external <- function(path) {
  83. # Use here::here() to ensure path is absolute relative to project root
  84. abs_path <- here::here(path)
  85. if(!file.exists(abs_path)) stop("External file not found at: ", abs_path)
  86. readxl::read_xlsx(abs_path) %>%
  87. dplyr::mutate(ParticipantID = trimws(as.character(ParticipantID)))
  88. }
  89. # --- 2. Load Blinding and Notes ---
  90. blinding <- load_external(params$blinding_file)
  91. notes <- load_external(params$notes_file)
  92. expected_cols <- c(
  93. "ParticipantID", "Timepoint", "Day",
  94. # Durations
  95. "TIB_DURATION", "SLEEP_DURATION", "WAKE_DURATION", "NONREM_DURATION",
  96. "STAGE1_DURATION", "STAGE2_DURATION", "STAGE3_DURATION", "STAGE4_DURATION",
  97. "REM_DURATION", "WAKE_AFTER_SLEEP_ON", "MOVEMENT_DURATION",
  98. # Percentages
  99. "WAKE_PERCENTAGE", "REM_PERCENTAGE", "NONREM_PERCENTAGE", "SLEEP_EFFICIENCY",
  100. "STAGE1_PERCENTAGE", "STAGE2_PERCENTAGE", "STAGE3_PERCENTAGE", "DEEPSLEEP_PERCENTAGE",
  101. # Latencies
  102. "SLEEPLATENCY", "REM_LATENCY", "SLEEPLATENCY_DEEPSLEEP", "SLEEPLATENCY_LPS",
  103. # Other Metrics
  104. "FIRST_REM", "STAGE_CHANGE_NUMBER", "WAKE_NUMBER", "ARTEFACT_PERCENTAGE",
  105. "REM_DENSITY", "SLEEP_PROFILE_MANUALLY_SCORED"
  106. )
  107. # --- 3. Join and Derive ---
  108. merged <- raw %>%
  109. dplyr::mutate(ParticipantID = trimws(as.character(ParticipantID))) %>%
  110. # Ensure expected_cols contains all the software headers we discussed!
  111. dplyr::select(dplyr::any_of(c(expected_cols, "ParticipantID", "Original_filename", "Timepoint"))) %>%
  112. dplyr::left_join(blinding, by = "ParticipantID", suffix = c("", ".bl")) %>%
  113. dplyr::left_join(notes %>% dplyr::select(ParticipantID, dplyr::any_of(c("artefact_percentage", "scoreability"))),
  114. by = "ParticipantID") %>%
  115. dplyr::mutate(
  116. # 1. Handle filename and ID derivation
  117. Original_filename = dplyr::coalesce(
  118. !!!rlang::syms(intersect(names(.), c("Original_filename", "Original_filename.x", "Original_filename.bl"))),
  119. ParticipantID
  120. ),
  121. Real_ID = dplyr::coalesce(
  122. !!!rlang::syms(intersect(names(.), c("Real_ID", "Real_ID.bl", "Real_ID.y"))),
  123. stringr::str_extract(Original_filename, "(?<=REST_)\\d{3}"),
  124. ParticipantID
  125. ),
  126. # 2. Extract Date
  127. date_str = stringr::str_extract(Original_filename, "\\d{8}"),
  128. date = suppressWarnings(lubridate::dmy(date_str)),
  129. # 3. IMPROVED Day Extraction (Fixes the NA issue)
  130. # This looks for 'N' or 'NIGHT' followed by the number
  131. day_num = stringr::str_extract(Original_filename, "(?i)(?<=NIGHT|N|N_)\\d+"),
  132. day = dplyr::case_when(
  133. stringr::str_detect(Original_filename, "(?i)BSL|Baseline") ~ "BSL",
  134. stringr::str_detect(Original_filename, "(?i)FU|Follow") ~ "FU",
  135. !is.na(day_num) ~ as.character(day_num), # Maps 'N1' to '1', 'N2' to '2', etc.
  136. TRUE ~ as.character(Timepoint) # Fallback to Timepoint column
  137. )
  138. ) %>%
  139. dplyr::select(-dplyr::any_of(c("Original_filename.x","Original_filename.y","Original_filename.bl",
  140. "Real_ID.x","Real_ID.y","Real_ID.bl", "date_str", "day_num")))
  141. ```
  142. # Clean the data
  143. ```{r 3-clean-transform, message=FALSE, warning=FALSE}
  144. # 1. Settings
  145. day_levels <- c("BSL","1","2","3","4","5","6","7","FU")
  146. # Heuristic unit converter: day-fraction -> minutes; hours -> minutes; else assume minutes
  147. convert_to_minutes <- function(x){
  148. x <- numify(x)
  149. if (all(is.na(x))) return(x)
  150. xp <- x[!is.na(x)]
  151. if (length(xp) > 0 && max(xp, na.rm = TRUE) <= 1.1) {
  152. x * 24 * 60
  153. } else if (length(xp) > 0 && stats::median(xp, na.rm = TRUE) <= 20) {
  154. x * 60
  155. } else x
  156. }
  157. # Columns that represent durations (will be converted to minutes)
  158. duration_vars <- c(
  159. "TIB_DURATION","SLEEP_DURATION","WAKE_DURATION","REM_DURATION","NONREM_DURATION",
  160. "REM_LATENCY","SLEEPLATENCY","SLEEPLATENCY_NONREM","WAKE_AFTER_SLEEP_ON",
  161. "STAGE1_DURATION","STAGE2_DURATION","STAGE3_DURATION","STAGE4_DURATION",
  162. "SLEEPLATENCY_DEEPSLEEP","SLEEPLATENCY_LPS","MOVEMENT_DURATION",
  163. "MOVEMENT_DURATION_SPT","WAKE_DURATION_SPT","REM_DURATION_SPT",
  164. "STAGE1_DURATION_SPT","STAGE2_DURATION_SPT","STAGE3_DURATION_SPT","STAGE4_DURATION_SPT",
  165. "ARTEFACT_DURATION_SPT","FIRST_REM","REM_LATENCY_MSLT","REM_LATENCY_MWT"
  166. )
  167. # 2. Clean the raw column names
  168. # Removes Excel suffixes like "...15" and trims hidden spaces
  169. names(merged) <- trimws(gsub("\\.{3}\\d+$", "", names(merged)))
  170. # 3. Define the Rename Map (NEW NAME = OLD SOFTWARE NAME)
  171. rename_map <- c(
  172. "Wake (mins)" = "WAKE_DURATION",
  173. "REM (mins)" = "REM_DURATION",
  174. "N1 (mins)" = "STAGE1_DURATION",
  175. "N2 (mins)" = "STAGE2_DURATION",
  176. "N3 (mins)" = "STAGE3_DURATION",
  177. "Wake (%)" = "WAKE_PERCENTAGE",
  178. "REM (%)" = "REM_PERCENTAGE",
  179. "N1 (%)" = "STAGE1_PERCENTAGE",
  180. "N2 (%)" = "STAGE2_PERCENTAGE",
  181. "N3 (%)" = "STAGE3_PERCENTAGE",
  182. "WASO (mins)" = "WAKE_AFTER_SLEEP_ON",
  183. "REM Latency (mins)" = "REM_LATENCY",
  184. "SOL (mins)" = "SLEEPLATENCY",
  185. "TIB (mins)" = "TIB_DURATION",
  186. "TST (mins)" = "SLEEP_DURATION",
  187. "Sleep Efficiency (%)" = "SLEEP_EFFICIENCY",
  188. "N3 Latency (mins)" = "SLEEPLATENCY_DEEPSLEEP",
  189. "Sleep Stage Transitions" = "STAGE_CHANGE_NUMBER",
  190. "Transitions to Wake" = "WAKE_NUMBER")
  191. # 4. Build the 'clean' dataframe
  192. clean <- merged %>%
  193. # Convert durations (using the helper function defined earlier)
  194. dplyr::mutate(dplyr::across(dplyr::any_of(duration_vars), convert_to_minutes)) %>%
  195. # Apply the renaming map
  196. dplyr::rename(dplyr::any_of(rename_map)) %>%
  197. # Ensure correct data types and factors
  198. dplyr::mutate(
  199. Real_ID = as.character(Real_ID),
  200. day = factor(day, levels = day_levels),
  201. # Ensure these are numeric even if Excel had them as text
  202. artefact_percentage = suppressWarnings(as.numeric(as.character(artefact_percentage))),
  203. scoreability = suppressWarnings(as.numeric(as.character(scoreability)))
  204. )
  205. # --- NOTE: Participant and Artefact filtering removed per request ---
  206. final_output_path <- here::here("Data", "processed", "EEG")
  207. # 5. Final Export of the Wide Table
  208. writexl::write_xlsx(clean, file.path(final_output_path, "clean_EEG_data.xlsx"))
  209. # --- DIAGNOSTICS ---
  210. cat("--- Data Clean Check ---\n")
  211. cat("Total rows kept:", nrow(clean), "\n")
  212. cat("Total participants:", dplyr::n_distinct(clean$Real_ID), "\n")
  213. cat("Columns renamed successfully:", sum(names(rename_map) %in% names(clean)), "out of", length(rename_map), "\n")
  214. if(any(!names(rename_map) %in% names(clean))) {
  215. cat("Missing from raw data:", paste(names(rename_map)[!names(rename_map) %in% names(clean)], collapse=", "), "\n")
  216. }
  217. ```
  218. # Long data + exports
  219. ```{r 4-long-data}
  220. # 1. Define the variables we want to keep for analysis/plotting
  221. # (Must match the NEW names from Chunk 3 exactly)
  222. needed_vars <- c(
  223. "TIB (mins)", "TST (mins)", "Wake (mins)", "Wake (%)",
  224. "REM (mins)", "REM (%)", "N1 (mins)", "N1 (%)",
  225. "N2 (mins)", "N2 (%)", "N3 (mins)", "N3 (%)",
  226. "WASO (mins)", "REM Latency (mins)", "SOL (mins)",
  227. "Sleep Efficiency (%)", "N3 Latency (mins)",
  228. "Sleep Stage Transitions", "Transitions to Wake", # Added these
  229. "artefact_percentage"
  230. )
  231. # 2. Pivot to Long Format
  232. EEG_long <- clean %>%
  233. # Select ID, Day, and only the columns in our 'needed_vars' list
  234. dplyr::select(Real_ID, day, dplyr::any_of(needed_vars)) %>%
  235. # Ensure all value columns are numeric before pivoting
  236. # (Prevents "Can't combine double and character" errors)
  237. dplyr::mutate(dplyr::across(-c(Real_ID, day), ~suppressWarnings(as.numeric(as.character(.x))))) %>%
  238. # Transform from Wide to Long
  239. tidyr::pivot_longer(
  240. cols = -c(Real_ID, day),
  241. names_to = "variable",
  242. values_to = "value"
  243. ) %>%
  244. # Remove any rows where the value is NA (optional, helps keep the file small)
  245. dplyr::filter(!is.na(value))
  246. # 3. Export to Excel
  247. # --- NOTE: Participant and Artefact filtering removed per request ---
  248. final_output_path <- here::here("Data", "processed", "EEG")
  249. # 5. Final Export of the Wide Table
  250. writexl::write_xlsx(clean, file.path(final_output_path, "EEG_data_long_clean.xlsx"))
  251. # --- DIAGNOSTICS ---
  252. cat("--- Long Data Check ---\n")
  253. cat("Total rows in long format:", nrow(EEG_long), "\n")
  254. cat("Variables found and pivoted:\n")
  255. print(unique(EEG_long$variable))
  256. # Quick Peek at the first few rows
  257. head(EEG_long)
  258. ```
  259. # Summary statistics (Mean, SD, CI) and wide tables
  260. ```{r summaries}
  261. # SAFETY CHECK: If EEG_long doesn't exist or is empty, stop and warn
  262. if (!exists("EEG_long") || nrow(EEG_long) == 0) {
  263. stop("EEG_long is missing or empty. Please run the 'long-data' chunk first.")
  264. }
  265. # 1) Calculate Statistics
  266. sum_long <- EEG_long %>%
  267. # Filter out rows where value is NA to avoid math errors
  268. filter(!is.na(value)) %>%
  269. group_by(variable, day) %>%
  270. summarise(
  271. n = n(),
  272. mean = mean(value, na.rm = TRUE),
  273. sd = sd(value, na.rm = TRUE),
  274. se = sd / sqrt(pmax(n, 1)),
  275. tcrit = qt(0.975, df = pmax(n-1, 1)),
  276. ci_low = mean - tcrit * se,
  277. ci_high = mean + tcrit * se,
  278. .groups = "drop"
  279. ) %>%
  280. # Format into strings for the table
  281. mutate(
  282. `Mean (SD)` = sprintf("%.2f (%.2f)", mean, sd),
  283. CI = paste0(sprintf("%.2f", ci_low), "–", sprintf("%.2f", ci_high))
  284. )
  285. # 2) Create Wide Tables
  286. # Table 1: Mean (SD)
  287. mean_sd_wide <- sum_long %>%
  288. select(variable, day, `Mean (SD)`) %>%
  289. pivot_wider(names_from = day, values_from = `Mean (SD)`) %>%
  290. arrange(variable)
  291. # Table 2: N (Sample Size)
  292. n_wide <- sum_long %>%
  293. select(variable, day, n) %>%
  294. pivot_wider(names_from = day, values_from = n) %>%
  295. arrange(variable)
  296. output_path <- here::here("Output", "EEG")
  297. # 4) Display in the Report
  298. cap <- "EEG Mean (SD) by Day (Rows = Variables; Columns = BSL, 1–7, FU)"
  299. knitr::kable(mean_sd_wide, caption = cap) %>%
  300. kableExtra::kable_styling(full_width = FALSE, bootstrap_options = c("striped", "hover", "condensed"))
  301. ```
  302. # Plots (overall and minutes-only)
  303. ```{r plots, fig.width=11, fig.height=9}
  304. library(ggprism)
  305. # 1. Prepare data for plotting
  306. plot_df <- EEG_long %>%
  307. group_by(day, variable) %>%
  308. summarise(
  309. mean = mean(value, na.rm = TRUE),
  310. sd = sd(value, na.rm = TRUE),
  311. n = sum(!is.na(value)),
  312. se = sd / sqrt(pmax(n, 1)),
  313. .groups = "drop"
  314. )
  315. # 2. Define the selection (Minutes + Sleep Efficiency)
  316. # Adding "Sleep Efficiency (%)" to your list
  317. mins_plus_se <- c(
  318. "TIB (mins)", "TST (mins)", "Sleep Efficiency (%)",
  319. "WASO (mins)", "SOL (mins)", "REM Latency (mins)",
  320. "N3 Latency (mins)", "N1 (mins)", "N2 (mins)",
  321. "N3 (mins)", "REM (mins)"
  322. )
  323. # 3. Filter and set factor levels for stable plotting order
  324. plot_filtered <- plot_df %>%
  325. dplyr::filter(variable %in% mins_plus_se) %>%
  326. dplyr::mutate(variable = factor(variable, levels = mins_plus_se))
  327. # 4. Generate the Plot (Fixed Theme Order)
  328. p_eeg <- ggplot(plot_filtered, aes(x = day, y = mean, group = 1)) +
  329. geom_point(size = 3, shape = 17, color = "#0091d4") +
  330. geom_line(linewidth = 1, color = "#0091d4") +
  331. geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.2, color = "#0091d4") +
  332. # 1. Faceting
  333. facet_wrap(~ variable, scales = "free_y", ncol = 3) +
  334. # 3. Custom Overrides (Must come AFTER theme_prism)
  335. theme(
  336. legend.position = "none",
  337. strip.text = element_text(size = 9),
  338. axis.title = element_text(face = "bold"),
  339. plot.title = element_text(hjust = 0.5)
  340. ) +
  341. # 4. Labels
  342. labs(
  343. x = "Study Night",
  344. y = "Mean ± SE",
  345. )
  346. # 4. Generate the Plot (Clean Publication Style)
  347. p_eeg <- ggplot(plot_filtered, aes(x = day, y = mean, group = 1)) +
  348. # Geoms
  349. geom_line(linewidth = 1, color = "#0091d4") +
  350. geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.2, color = "#0091d4") +
  351. geom_point(size = 3, shape = 17, color = "#0091d4") +
  352. # Faceting - "free_y" allows each metric to have its own scale
  353. facet_wrap(~ variable, scales = "free_y", ncol = 3, axes = "all") +
  354. # --- MANUAL CLEAN THEME ---
  355. theme_classic() + # Removes grid and gray background automatically
  356. theme(
  357. # 1. Force Black Axis Lines (Prism Style)
  358. axis.line = element_line(colour = "black", linewidth = 0.8),
  359. axis.ticks = element_line(colour = "black", linewidth = 0.8),
  360. # 2. Format Text
  361. axis.text = element_text(color = "black", size = 10),
  362. axis.title = element_text(face = "bold", size = 11),
  363. # 3. Clean Facet Labels (No gray boxes)
  364. strip.background = element_blank(),
  365. strip.text = element_text(size = 10, face = "bold"),
  366. # 4. Final Polish
  367. legend.position = "none",
  368. panel.spacing = unit(1, "lines") # Adds a bit of air between plots
  369. ) +
  370. # --- END THEME ---
  371. labs(
  372. x = "Study Night",
  373. y = "Mean ± SE"
  374. )
  375. # Display
  376. print(p_eeg)
  377. fig_dir <- here::here("Figures", "EEG")
  378. # Create the folder if it doesn't exist
  379. if (!dir.exists(fig_dir)) {
  380. dir.create(fig_dir, recursive = TRUE)
  381. }
  382. # Save the high-res TIFF
  383. ggsave(file.path(fig_dir, "Figure 4.tiff"), p_eeg, width = 10, height = 7, dpi = 300, compression = "lzw")
  384. cat("Plot successfully saved to:", file.path(fig_dir, "Figure_4.tiff"))
  385. # 5. Save Exports
  386. ggsave(file.path(fig_dir, "Figure 4.jpg"), p_eeg, width = 10, height = 7, dpi = 300)
  387. ```
  388. # LMM function
  389. ```{r}
  390. run_eeg_lmm <- function(df_long, varname) {
  391. # 1. Prepare subset and ensure Value is numeric
  392. df_sub <- df_long %>%
  393. filter(variable == varname) %>%
  394. mutate(
  395. day = factor(day),
  396. value = as.numeric(as.character(value))
  397. ) %>%
  398. filter(!is.na(value))
  399. # 2. Check levels
  400. levels_present <- unique(as.character(df_sub$day))
  401. if (!("BSL" %in% levels_present) | length(levels_present) < 2) return(NULL)
  402. # 3. Fit Model
  403. fit <- tryCatch({
  404. lmer(value ~ day + (1 | Real_ID), data = df_sub, REML = TRUE)
  405. }, error = function(e) return(NULL))
  406. if (is.null(fit)) return(NULL)
  407. # 4. emmeans
  408. em <- emmeans(fit, "day")
  409. ct <- contrast(em, method = "trt.vs.ctrl", ref = "BSL") %>%
  410. summary(infer = TRUE) %>%
  411. as.data.frame()
  412. # 5. Calculate Cohen's d (Safety update here)
  413. # Pivot and ensure columns are numeric for the subtraction
  414. wide_data <- df_sub %>%
  415. select(Real_ID, day, value) %>%
  416. pivot_wider(names_from = day, values_from = value) %>%
  417. mutate(across(-Real_ID, ~as.numeric(as.character(.x)))) # FORCE NUMERIC
  418. days_to_compare <- setdiff(levels_present, "BSL")
  419. d_results <- map_dfr(days_to_compare, function(d_val) {
  420. # Check if both columns exist and are numeric
  421. if ("BSL" %in% names(wide_data) && d_val %in% names(wide_data)) {
  422. # Use na.rm = TRUE to handle missing values within the pair
  423. diffs <- wide_data[[d_val]] - wide_data$BSL
  424. mean_diff <- mean(diffs, na.rm = TRUE)
  425. sd_diff <- sd(diffs, na.rm = TRUE)
  426. cohens_d <- if(!is.na(sd_diff) && sd_diff != 0) mean_diff / sd_diff else NA
  427. return(tibble(contrast = paste0(d_val, " - BSL"), Cohens_d = cohens_d))
  428. }
  429. return(NULL)
  430. })
  431. # 6. Combine
  432. res <- ct %>%
  433. left_join(d_results, by = "contrast") %>%
  434. mutate(variable = varname)
  435. return(res)
  436. }
  437. ```
  438. # Linear mixed models (contrasts vs BSL) with within-person d
  439. ```{r}
  440. suppressPackageStartupMessages({
  441. library(flextable)
  442. library(officer)
  443. library(dplyr)
  444. library(tidyr)
  445. })
  446. # 1. Prepare Data
  447. word_table_df <- sum_long %>%
  448. filter(variable %in% target_vars) %>%
  449. mutate(contrast = paste0(day, " - BSL")) %>%
  450. left_join(EEG_effects, by = c("variable", "contrast")) %>%
  451. mutate(
  452. LCL = dplyr::coalesce(!!!rlang::syms(intersect(names(.), c("lower.CL", "asymp.LCL")))),
  453. UCL = dplyr::coalesce(!!!rlang::syms(intersect(names(.), c("upper.CL", "asymp.UCL"))))
  454. ) %>%
  455. mutate(variable = factor(variable, levels = target_vars)) %>%
  456. arrange(variable, day) %>%
  457. mutate(
  458. `Mean (SD)` = sprintf("%.2f (%.2f)", mean, sd),
  459. `Diffadj` = ifelse(day == "BSL", "-", sprintf("%.2f", estimate)),
  460. `95% CI` = ifelse(day == "BSL" | is.na(LCL), "-",
  461. paste0(sprintf("%.2f", LCL), " to ", sprintf("%.2f", UCL))),
  462. `d` = ifelse(day == "BSL" | is.na(Cohens_d), "-", sprintf("%.2f", Cohens_d)),
  463. Day_Label = dplyr::case_when(
  464. day == "BSL" ~ "Baseline",
  465. day == "FU" ~ "Follow up",
  466. TRUE ~ paste("Night", day)
  467. )
  468. ) %>%
  469. select(variable, Day_Label, `Mean (SD)`, `Diffadj`, `95% CI`, `d`)
  470. # 2. Convert to Grouped Data
  471. grouped_data <- as_grouped_data(x = word_table_df, groups = c("variable"))
  472. # Calculate group IDs for shading (ignoring the header rows)
  473. grouped_data <- grouped_data %>%
  474. mutate(
  475. temp_var = if_else(!is.na(variable), as.character(variable), NA_character_)
  476. ) %>%
  477. fill(temp_var, .direction = "down") %>%
  478. mutate(group_num = as.numeric(factor(temp_var, levels = target_vars)))
  479. # 3. Create Flextable
  480. ft <- flextable(grouped_data, col_keys = c("variable", "Day_Label", "Mean (SD)", "Diffadj", "95% CI", "d")) %>%
  481. # Header labels
  482. set_header_labels(
  483. variable = "EEG (n = 17)",
  484. Day_Label = "",
  485. `Mean (SD)` = "Mean (SD)",
  486. Diffadj = "Diffadj",
  487. `95% CI` = "95% CI",
  488. d = "d"
  489. ) %>%
  490. # Remove Italics
  491. italic(italic = FALSE, part = "all") %>%
  492. # Bold variable headers
  493. bold(i = ~ !is.na(variable), j = 1, bold = TRUE) %>%
  494. # Apply Shading (even groups get grey)
  495. bg(i = ~ group_num %% 2 == 0, bg = "#F2F2F2", part = "body") %>%
  496. # FIX: Hide the variable names in data rows using compose
  497. compose(i = ~ is.na(variable), j = "variable", value = as_paragraph("")) %>%
  498. # Styling
  499. fontsize(size = 10, part = "all") %>%
  500. align(align = "left", part = "all") %>%
  501. autofit() %>%
  502. # Borders
  503. border_remove() %>%
  504. hline_top(border = fp_border(width = 1.5), part = "header") %>%
  505. hline(i = 1, border = fp_border(width = 1.5), part = "header") %>%
  506. # Footer
  507. 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.") %>%
  508. hline_bottom(border = fp_border(width = 1.5), part = "footer")
  509. # 4. Export
  510. doc <- read_docx() %>%
  511. body_add_flextable(ft)
  512. word_out <- here::here("Output", "EEG", "EEG_LMM_results.docx")
  513. print(doc, target = word_out)
  514. cat("Success! Table saved to:", word_out)
  515. ```
  516. # LMM for relative sleep stages for supplementary
  517. ```{r}
  518. # --- 1. Define Percentage Variables ---
  519. target_perc_vars <- c("N1 (%)", "N2 (%)", "N3 (%)", "REM (%)")
  520. # --- 2. Run the LMM Logic for Percentages ---
  521. res_list_perc <- lapply(target_perc_vars, \(v) if(v %in% unique(EEG_long$variable)) run_eeg_lmm(EEG_long, v) else NULL)
  522. EEG_effects_perc <- dplyr::bind_rows(purrr::compact(res_list_perc))
  523. # --- 3. Join and Format Data ---
  524. final_perc_table_df <- sum_long %>%
  525. filter(variable %in% target_perc_vars) %>%
  526. mutate(contrast = paste0(day, " - BSL")) %>%
  527. left_join(EEG_effects_perc, by = c("variable", "contrast")) %>%
  528. # Fix for emmeans column naming
  529. mutate(
  530. LCL = dplyr::coalesce(!!!rlang::syms(intersect(names(.), c("lower.CL", "asymp.LCL")))),
  531. UCL = dplyr::coalesce(!!!rlang::syms(intersect(names(.), c("upper.CL", "asymp.UCL"))))
  532. ) %>%
  533. mutate(variable = factor(variable, levels = target_perc_vars)) %>%
  534. arrange(variable, day) %>%
  535. mutate(
  536. `Mean (SD)` = sprintf("%.2f (%.2f)", mean, sd),
  537. `Diffadj` = ifelse(day == "BSL", "-", sprintf("%.2f", estimate)),
  538. `95% CI` = ifelse(day == "BSL" | is.na(LCL), "-",
  539. paste0(sprintf("%.2f", LCL), " to ", sprintf("%.2f", UCL))),
  540. `d` = ifelse(day == "BSL" | is.na(Cohens_d), "-", sprintf("%.2f", Cohens_d)),
  541. Day_Label = dplyr::case_when(
  542. day == "BSL" ~ "Baseline",
  543. day == "FU" ~ "Follow up",
  544. TRUE ~ paste("Night", day)
  545. )
  546. ) %>%
  547. select(variable, Day_Label, `Mean (SD)`, `Diffadj`, `95% CI`, `d`)
  548. # --- 4. Create the Formatted Word Table ---
  549. grouped_perc_data <- as_grouped_data(x = final_perc_table_df, groups = c("variable"))
  550. # Shading logic
  551. grouped_perc_data <- grouped_perc_data %>%
  552. mutate(temp_var = if_else(!is.na(variable), as.character(variable), NA_character_)) %>%
  553. fill(temp_var, .direction = "down") %>%
  554. mutate(group_num = as.numeric(factor(temp_var, levels = target_perc_vars)))
  555. ft_perc <- flextable(grouped_perc_data, col_keys = c("variable", "Day_Label", "Mean (SD)", "Diffadj", "95% CI", "d")) %>%
  556. set_header_labels(variable = "EEG % (n = 17)", Day_Label = "", `Mean (SD)` = "Mean (SD)",
  557. Diffadj = "Diffadj", `95% CI` = "95% CI", d = "d") %>%
  558. italic(italic = FALSE, part = "all") %>%
  559. bold(i = ~ !is.na(variable), j = 1, bold = TRUE) %>%
  560. bg(i = ~ group_num %% 2 == 0, bg = "#F2F2F2", part = "body") %>%
  561. compose(i = ~ is.na(variable), j = "variable", value = as_paragraph("")) %>%
  562. fontsize(size = 10, part = "all") %>%
  563. align(align = "left", part = "all") %>%
  564. autofit() %>%
  565. border_remove() %>%
  566. hline_top(border = fp_border(width = 1.5), part = "header") %>%
  567. hline(i = 1, border = fp_border(width = 1.5), part = "header") %>%
  568. add_footer_lines("95% CI, 95% confidence interval; Diffadj, mean difference adjusted via LMM; d, Cohen’s d; SD, standard deviation.") %>%
  569. hline_bottom(border = fp_border(width = 1.5), part = "footer")
  570. # --- 5. Export ---
  571. doc_perc <- read_docx() %>% body_add_flextable(ft_perc)
  572. word_perc_out <- here::here("Output", "EEG", "EEG_LMM_Results_Proportions_supplementary.docx")
  573. print(doc_perc, target = word_perc_out)
  574. cat("Percentage results saved to Word:", word_perc_out)
  575. ```
  576. # Create forest plots to show effect sizes
  577. ```{r, fig.width=6, fig.height=9}
  578. library(dplyr)
  579. library(stringr)
  580. library(ggplot2)
  581. # 1. Define the chronological order for the Y-axis (Top to Bottom)
  582. night_levels <- c("1", "2", "3", "4", "5", "6", "7", "FU")
  583. night_labels <- c("Night 1", "Night 2", "Night 3", "Night 4", "Night 5", "Night 6", "Night 7", "Follow up")
  584. # 2. Prepare the data
  585. plot_data_final <- EEG_effects %>%
  586. # Filter out any rows where LMM didn't run
  587. filter(!is.na(estimate)) %>%
  588. mutate(
  589. # FIX: Create the 'day' column by extracting word/number before the dash
  590. # This turns "1 - BSL" into "1" and "FU - BSL" into "FU"
  591. extracted_day = str_extract(contrast, "^[^ ]+"),
  592. # Now create the Day_Label factor for the Y-axis
  593. Day_Label = factor(extracted_day,
  594. levels = rev(night_levels),
  595. labels = rev(night_labels)),
  596. # Ensure math columns are numeric
  597. estimate = as.numeric(as.character(estimate)),
  598. low = as.numeric(lower.CL),
  599. high = as.numeric(upper.CL),
  600. abs_d = abs(as.numeric(as.character(Cohens_d))),
  601. # Cohen's d sizing bins
  602. d_size = case_when(
  603. abs_d < 0.2 ~ "Negligible (< 0.2)",
  604. abs_d >= 0.2 & abs_d < 0.5 ~ "Small (0.2)",
  605. abs_d >= 0.5 & abs_d < 0.8 ~ "Medium (0.5)",
  606. abs_d >= 0.8 ~ "Large (0.8)",
  607. TRUE ~ "Small (0.2)"
  608. ),
  609. d_size = factor(d_size, levels = c("Negligible (< 0.2)", "Small (0.2)", "Medium (0.5)", "Large (0.8)"))
  610. ) %>%
  611. filter(!is.na(Day_Label)) # Drops anything that didn't match our 1-7/FU list
  612. # 3. Split into your two requested groups
  613. df_summary_plot <- plot_data_final %>%
  614. filter(variable %in% c("TST (mins)", "TIB (mins)", "WASO (mins)", "SOL (mins)", "Sleep Efficiency (%)"))
  615. df_stages_plot <- plot_data_final %>%
  616. filter(variable %in% c("N1 (mins)", "N2 (mins)", "N3 (mins)", "REM (mins)"))
  617. library(ggplot2)
  618. make_final_eeg_plot <- function(data_subset, plot_title, theme_color) {
  619. # 1. Symmetric limits for the 0-anchor
  620. limit_val <- max(abs(c(data_subset$low, data_subset$high)), na.rm = TRUE) * 1.1
  621. if(is.infinite(limit_val) || is.na(limit_val)) limit_val <- 10
  622. ggplot(data_subset, aes(x = estimate, y = Day_Label)) +
  623. # Reference line at zero
  624. geom_vline(xintercept = 0, linetype = "dashed", color = "gray70") +
  625. # Error bars matching theme_color
  626. geom_errorbarh(aes(xmin = low, xmax = high),
  627. height = 0.3, linewidth = 0.8, color = theme_color, alpha = 0.5) +
  628. # Blobs (Points) sized by Cohen's d
  629. geom_point(aes(size = d_size), color = theme_color) +
  630. # Separate plots for each metric
  631. facet_wrap(~variable, scales = "free_x", ncol = 2) +
  632. expand_limits(x = c(-limit_val, limit_val)) +
  633. # Define the "Blob" sizes based on Cohen's conventions
  634. scale_size_manual(values = c(
  635. "Negligible (< 0.2)" = 1.5,
  636. "Small (0.2)" = 3,
  637. "Medium (0.5)" = 5,
  638. "Large (0.8)" = 8
  639. )) +
  640. labs(
  641. title = plot_title,
  642. subtitle = "(n = 17)",
  643. x = "Adjusted Mean Difference (95% CI)",
  644. y = NULL,
  645. size = "Effect Size (d)"
  646. ) +
  647. theme_minimal() +
  648. theme(
  649. strip.text = element_text(face = "bold", size = 9),
  650. panel.grid.minor = element_blank(),
  651. panel.spacing = unit(1.2, "lines"),
  652. legend.position = "bottom"
  653. )
  654. }
  655. # 4. Generate the Plots
  656. p_summary <- make_final_eeg_plot(df_summary_plot, "EEG Sleep Continuity", "#1B4F72")
  657. p_stages <- make_final_eeg_plot(df_stages_plot, "EEG Sleep Architecture", "#0E6251")
  658. # 5. Display
  659. print(p_summary)
  660. # 6. Save the Plots
  661. ggsave(file.path(fig_dir, "Sleep_continuity.jpg"), p_summary, width = 6, height = 9, dpi = 600)
  662. print(p_stages)
  663. ggsave(file.path(fig_dir, "Sleep_stages.jpg"), p_stages, width = 6, height = 7, dpi = 600)
  664. ```
  665. # Create heat map of sleep stage changes
  666. ```{r}
  667. library(tidyverse)
  668. library(lubridate)
  669. # 1. PATH SETUP (Now platform-independent)
  670. input_path <- here::here("Data", "raw", "EEG", "hypnograms_txt")
  671. # 2. Verify and Load
  672. if (!dir.exists(input_path)) {
  673. stop("Path not found! Check if 'Data/raw/EEG/hypnograms_txt' exists in your Project folder.")
  674. }
  675. all_files <- list.files(input_path, pattern = "\\.txt$", full.names = TRUE, ignore.case = TRUE)
  676. cat("Success! Found", length(all_files), "files in:", input_path)
  677. all_files <- list.files(input_path, pattern = "\\.txt$", full.names = TRUE)
  678. # --- 1. UPDATED LOADER (Ensures Real_ID exists) ---
  679. read_hypno_by_date <- function(f) {
  680. fname <- basename(f)
  681. # Extract ID (First 3 digits) and Date (YYYY_MM_DD)
  682. subj_id <- stringr::str_extract(fname, "^\\d{3}")
  683. date_str <- stringr::str_extract(fname, "\\d{4}_\\d{2}_\\d{2}")
  684. # Skip file if naming doesn't match expected pattern
  685. if(is.na(subj_id) | is.na(date_str)) return(NULL)
  686. # Read the numeric data
  687. df <- tryCatch({
  688. read.table(f, header = FALSE, sep = "")
  689. }, error = function(e) return(NULL))
  690. if(is.null(df) || ncol(df) < 1) return(NULL)
  691. # Standardize and add metadata
  692. df %>%
  693. rename(stage = V1, exclude = V2) %>%
  694. filter(stage != 6, exclude == 0) %>%
  695. mutate(
  696. stage_name = case_when(
  697. stage == 0 ~ "Wake", stage == 1 ~ "N1", stage == 2 ~ "N2",
  698. stage == 3 ~ "N3", stage %in% c(4, 5) ~ "REM", TRUE ~ NA_character_
  699. ),
  700. # ENSURE THESE COLUMNS ARE CREATED FOR EVERY ROW
  701. Real_ID = paste0("REST_", subj_id),
  702. file_date = lubridate::ymd(date_str),
  703. is_bsl = str_detect(fname, "(?i)Bsl|Baseline")
  704. )
  705. }
  706. # --- 2. RUN THE LOADING ---
  707. raw_master <- purrr::map_df(all_files, read_hypno_by_date)
  708. # Check if Real_ID actually exists now
  709. if(!"Real_ID" %in% names(raw_master)) stop("Real_ID column missing after load!")
  710. # --- 3. UNBLINDING AND LABELING ---
  711. unblinded_master <- raw_master %>%
  712. group_by(Real_ID) %>%
  713. # Ensure chronological order per participant
  714. arrange(file_date, .by_group = TRUE) %>%
  715. mutate(
  716. day_type = if_else(is_bsl, "BSL", "Intervention")
  717. ) %>%
  718. # Now, within each participant, rank the 'Intervention' nights
  719. group_by(Real_ID, day_type) %>%
  720. mutate(
  721. night_num = if_else(day_type == "Intervention", dense_rank(file_date), NA_integer_)
  722. ) %>%
  723. ungroup() %>%
  724. mutate(
  725. # Final Labeling
  726. day_label = case_when(
  727. day_type == "BSL" ~ "BSL",
  728. night_num == 8 ~ "FU", # Logic: If 8th night after BSL, it's Follow Up
  729. !is.na(night_num) ~ paste0("Night ", night_num),
  730. TRUE ~ "Other"
  731. ),
  732. day_label = factor(day_label, levels = c("BSL", paste0("Night ", 1:7), "FU"))
  733. )
  734. # --- 4. CALCULATE TRANSITIONS ---
  735. stage_order <- c("Wake", "N1", "N2", "N3", "REM")
  736. final_probs <- unblinded_master %>%
  737. group_by(Real_ID, day_label) %>% # Calculate transitions WITHIN each night
  738. mutate(
  739. current = factor(stage_name, levels = stage_order),
  740. next_s = factor(lead(stage_name), levels = stage_order)
  741. ) %>%
  742. filter(!is.na(current), !is.na(next_s)) %>%
  743. group_by(day_label, current, next_s) %>%
  744. summarise(count = n(), .groups = 'drop_last') %>%
  745. mutate(prob = (count / sum(count)) * 100) %>%
  746. ungroup()
  747. bsl_vals <- final_probs %>%
  748. filter(day_label == "BSL") %>%
  749. select(current, next_s, bsl_prob = prob)
  750. diff_matrix <- final_probs %>%
  751. filter(day_label != "BSL") %>%
  752. left_join(bsl_vals, by = c("current", "next_s")) %>%
  753. mutate(
  754. bsl_prob = replace_na(bsl_prob, 0),
  755. diff = prob - bsl_prob
  756. )
  757. # --- 3. GENERATE THE ORDERED HEATMAP ---
  758. ggplot(diff_matrix, aes(x = current, y = next_s, fill = diff)) +
  759. geom_tile(color = "white", linewidth = 0.3) +
  760. geom_text(aes(label = sprintf("%.1f%%", diff)),
  761. size = 2.5, fontface = "bold") +
  762. scale_fill_gradient2(
  763. low = "#4a1486", mid = "white", high = "#d95f0e",
  764. midpoint = 0, name = "Δ from BSL %"
  765. ) +
  766. facet_wrap(~day_label, ncol = 4) +
  767. theme_minimal() +
  768. labs(
  769. x = "From Stage (Epoch n)",
  770. y = "To Stage (Epoch n+1)"
  771. ) +
  772. theme(
  773. axis.text.x = element_text(angle = 45, hjust = 1),
  774. strip.text = element_text(face = "bold")
  775. )
  776. library(ggplot2)
  777. # 1. DEFINE FILENAME AND PATH
  778. # Use here() to point specifically to your Figures/EEG folder
  779. output_path <- here::here("Figures", "EEG", "Figure S3.jpg")
  780. # 2. SAVE THE PLOT
  781. ggsave(
  782. filename = output_path,
  783. plot = last_plot(),
  784. device = "jpeg", # Changed to 'jpeg' for consistency with .jpg extension
  785. dpi = 300,
  786. width = 12,
  787. height = 6,
  788. units = "in"
  789. )
  790. message("Success! Plot saved to: ", output_path)
  791. ```
  792. # Adaptation nights
  793. ```{r, fig.width=12, fig.height=20, warning=FALSE, message=FALSE}}
  794. # --- 1. SETUP HELPERS & MAPPINGS ---
  795. # Define the bridge between Adaptation (CAPS) and Clean (Pretty) names
  796. rename_map <- c(
  797. "TIB (mins)" = "TIB_DURATION",
  798. "TST (mins)" = "SLEEP_DURATION",
  799. "Wake (mins)" = "WAKE_DURATION",
  800. "N1 (mins)" = "STAGE1_DURATION",
  801. "N2 (mins)" = "STAGE2_DURATION",
  802. "N3 (mins)" = "STAGE3_DURATION",
  803. "REM (mins)" = "REM_DURATION",
  804. "WASO (mins)" = "WAKE_AFTER_SLEEP_ON",
  805. "Sleep Efficiency (%)" = "SLEEP_EFFICIENCY",
  806. "SOL (mins)" = "SLEEPLATENCY",
  807. "REM Latency (mins)" = "REM_LATENCY",
  808. "N3 Latency (mins)" = "SLEEPLATENCY_DEEPSLEEP"
  809. )
  810. # List of duration variables to be converted to minutes
  811. duration_vars <- c("TIB_DURATION", "SLEEP_DURATION", "WAKE_DURATION",
  812. "STAGE1_DURATION", "STAGE2_DURATION", "STAGE3_DURATION",
  813. "REM_DURATION", "WAKE_AFTER_SLEEP_ON", "SLEEPLATENCY",
  814. "REM_LATENCY", "SLEEPLATENCY_DEEPSLEEP")
  815. # Helper to handle duplicate columns
  816. dedup_cols <- function(df) {
  817. colnames(df) <- make.unique(colnames(df))
  818. df
  819. }
  820. # Custom loader for Adaptation files
  821. read_adapt_only <- function(f) {
  822. df <- readxl::read_excel(f, skip = 1, col_types = "text") %>%
  823. dedup_cols()
  824. df$Real_ID <- stringr::str_extract(basename(f), "^\\d{3}")
  825. df$day <- "AD"
  826. return(df)
  827. }
  828. # --- 2. EXECUTE DATA PROCESSING ---
  829. library(tidyverse)
  830. library(lubridate)
  831. library(here)
  832. # Load AD files
  833. adapt_path <- here::here("Data", "raw", "EEG", "adaptation_night_sleep_profile")
  834. adapt_files <- list.files(adapt_path, pattern = "\\.xlsx$", full.names = TRUE)
  835. adapt_raw_df <- purrr::map_dfr(adapt_files, read_adapt_only)
  836. ## 1. Improved Converter: Handles Excel Serial (0.05), Seconds (4000), and Minutes (70)
  837. convert_to_minutes_smart <- function(x) {
  838. x_num <- as.numeric(as.character(x))
  839. dplyr::case_when(
  840. is.na(x_num) ~ NA_real_,
  841. # If it's a tiny decimal (Excel serial), convert to minutes
  842. x_num > 0 & x_num < 2 ~ x_num * 1440,
  843. # If it's a massive number (Seconds), convert to minutes
  844. x_num > 1440 ~ x_num / 60,
  845. # Otherwise, assume it's already in minutes
  846. TRUE ~ x_num
  847. )
  848. }
  849. # 2. Clean the AD side
  850. adapt_prep <- adapt_raw_df %>%
  851. dplyr::rename(dplyr::any_of(rename_map)) %>%
  852. # Apply the smart converter to every column except ID/Day
  853. dplyr::mutate(across(-c(Real_ID, day), convert_to_minutes_smart)) %>%
  854. dplyr::mutate(Real_ID = as.character(Real_ID), day = "AD")
  855. # 3. Clean the BSL/Study side
  856. # This forces 'clean' to be numeric too, stopping the "Can't combine" error
  857. clean_prep <- clean %>%
  858. dplyr::mutate(across(where(is.character) & !c(Real_ID, day), ~as.numeric(as.character(.x)))) %>%
  859. dplyr::mutate(Real_ID = as.character(Real_ID), day = as.character(day))
  860. # 4. Bind them - Now they are both numeric and in the right units!
  861. full_timeline_df <- dplyr::bind_rows(adapt_prep, clean_prep) %>%
  862. dplyr::mutate(day = factor(day, levels = c("AD", "BSL", "1", "2", "3", "4", "5", "6", "7", "FU")))
  863. # --- 3. PIVOT & PLOT ---
  864. full_long <- full_timeline_df %>%
  865. dplyr::select(Real_ID, day, any_of(mins_plus_se)) %>%
  866. # Force all metric columns to numeric
  867. dplyr::mutate(across(-c(Real_ID, day), ~as.numeric(as.character(.x)))) %>%
  868. tidyr::pivot_longer(
  869. cols = -c(Real_ID, day),
  870. names_to = "variable",
  871. values_to = "value"
  872. ) %>%
  873. dplyr::filter(!is.na(value))
  874. full_summary <- full_long %>%
  875. group_by(day, variable) %>%
  876. summarise(
  877. mean = mean(value, na.rm = TRUE),
  878. se = sd(value, na.rm = TRUE) / sqrt(pmax(n(), 1)),
  879. .groups = "drop"
  880. )
  881. p_full_timeline <- ggplot(full_summary, aes(x = day, y = mean, group = 1)) +
  882. geom_line(linewidth = 0.8, color = "gray60", linetype = "dashed") +
  883. geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.2, color = "#2E4053") +
  884. geom_point(size = 3, aes(color = (day == "AD")), shape = 16) +
  885. scale_color_manual(values = c("TRUE" = "#E67E22", "FALSE" = "#2E4053")) +
  886. facet_wrap(~ variable, scales = "free", ncol = 3) +
  887. theme_classic() +
  888. theme(
  889. strip.background = element_blank(),
  890. strip.text = element_text(face = "bold", size = 10),
  891. axis.text.x = element_text(angle = 45, hjust = 1),
  892. legend.position = "none"
  893. ) +
  894. labs(x = "Study Night", y = "Mean ± SE")
  895. ## 1. Verify where 'here' thinks the root is
  896. print(paste("Project Root is:", here::here()))
  897. # 2. Save using the explicit path construction
  898. # This ensures it goes: Project Root -> Figures -> EEG -> Filename
  899. ggsave(
  900. filename = here::here("Figures", "EEG", "EEG_with_adaptation_night.png"),
  901. plot = p_full_timeline,
  902. width = 15,
  903. height = 9,
  904. dpi = 300
  905. )
  906. ```
  907. # SOREMPS
  908. ```{r}
  909. # 1. PREPARE THE DATA
  910. soremp_clean <- full_timeline_df %>%
  911. select(Real_ID, day, `REM Latency (mins)`) %>%
  912. mutate(
  913. rem_val = as.numeric(`REM Latency (mins)`),
  914. # Define SOREMP: REM within 15 minutes of sleep onset
  915. is_soremp = rem_val <= 15,
  916. soremp_label = if_else(is_soremp, "SOREMP (≤15m)", "Normal (>15m)")
  917. ) %>%
  918. # Filter out any NAs just in case, though you mentioned they all have REM
  919. filter(!is.na(rem_val))
  920. # 2. PLOT BY PARTICIPANT (To see individual drivers)
  921. ggplot(soremp_clean, aes(x = day, y = 1, fill = soremp_label)) +
  922. geom_tile(color = "white", linewidth = 0.5) + # Heatmap style tiles
  923. facet_wrap(~ Real_ID, ncol = 3) + # One small plot per participant
  924. scale_fill_manual(values = c("SOREMP (≤15m)" = "#d95f0e", "Normal (>15m)" = "#2E4053")) +
  925. theme_minimal() +
  926. labs(
  927. title = "Individual SOREMP Occurrences across Study Nights",
  928. subtitle = "Orange tiles indicate REM onset within 15 minutes",
  929. x = "Study Night",
  930. y = NULL,
  931. fill = "Status"
  932. ) +
  933. theme(
  934. axis.text.y = element_blank(), # Hide Y axis since it's just a presence tile
  935. axis.ticks.y = element_blank(),
  936. panel.grid = element_blank(),
  937. strip.text = element_text(face = "bold"),
  938. axis.text.x = element_text(angle = 45, hjust = 1),
  939. legend.position = "bottom"
  940. )
  941. # 3. SAVE INDIVIDUAL TRACKER
  942. ggsave(
  943. filename = here::here("Figures", "EEG", "SOREMP_Individual_Tracker.png"),
  944. width = 12,
  945. height = 8,
  946. dpi = 300
  947. )
  948. # 1. CREATE ANONYMOUS MAPPING
  949. # This creates a lookup table: Real_ID "101" -> "Participant A"
  950. id_mapping <- full_timeline_df %>%
  951. distinct(Real_ID) %>%
  952. arrange(Real_ID) %>%
  953. mutate(
  954. anon_id = paste("Participant", LETTERS[row_number()])
  955. )
  956. # 2. PREPARE DATA WITH ANONYMOUS LABELS
  957. soremp_anon <- full_timeline_df %>%
  958. left_join(id_mapping, by = "Real_ID") %>%
  959. select(anon_id, day, `REM Latency (mins)`) %>%
  960. mutate(
  961. rem_val = as.numeric(`REM Latency (mins)`),
  962. is_soremp = rem_val <= 15,
  963. soremp_label = if_else(is_soremp, "SOREMP (≤15m)", "REM Latency (>15m)")
  964. ) %>%
  965. filter(!is.na(rem_val))
  966. # 3. PLOT ANONYMIZED FACETS
  967. ggplot(soremp_anon, aes(x = day, y = 1, fill = soremp_label)) +
  968. geom_tile(color = "white", linewidth = 0.6) +
  969. facet_wrap(~ anon_id, ncol = 4) + # Grouped by letter A, B, C...
  970. scale_fill_manual(
  971. values = c("SOREMP (≤15m)" = "#d95f0e", "REM Latency (>15m)" = "#2E4053"),
  972. name = "Status"
  973. ) +
  974. theme_minimal() +
  975. labs(
  976. x = "Study Night",
  977. y = NULL
  978. ) +
  979. theme(
  980. axis.text.y = element_blank(),
  981. axis.ticks.y = element_blank(),
  982. panel.grid = element_blank(),
  983. strip.text = element_text(face = "bold", size = 10),
  984. axis.text.x = element_text(angle = 45, hjust = 1),
  985. legend.position = "bottom",
  986. strip.background = element_rect(fill = "#f0f0f0", color = NA)
  987. )
  988. # 4. SAVE
  989. ggsave(
  990. filename = here::here("Figures", "EEG", "SOREMs.png"),
  991. width = 10,
  992. height = 6,
  993. dpi = 300
  994. )
  995. ```
  996. # Session info
  997. ```{r session-info}
  998. sessioninfo::session_info()
  999. ```

06_EEG.Rmd at commit e76059b, under CC-BY-4.0 · at the source

Overview

  1. Sir Jules Thorn Sleep and Circadian Neuroscience Institute, Dorothy Crowfoot Hodgkin Building, Nuffield Department of Clinical Neurosciences, University of Oxford, Oxford, OX1 3QU
  2. Institute of Biomedical Engineering, Department of Engineering Science, University of Oxford, Oxford, United Kingdom
Institutions: University of Oxford (United Kingdom)
Dates: published online 15 May 2026
Type: Preprint
License: CC BY
Identifiers: DOI 10.64898/2026.05.08.26348885 · OpenAlex W7161289311
Open access: green, a free copy (OpenAlex)
Status: code verified
Categories: EEG (modality), other (modality), depression (population), sleep disorders (population), clinical / translational (subfield)
Methods: Spectral & time-frequency, Statistics, Preprocessing, Evoked potentials, Single-unit activity, calcium imaging, Physiology & signal measures, Connectivity, Machine learning
Keywords: depression, mental health, EEG, wearables, CBT-I, sleep disorders
Topic: Sleep and related disorders (Experimental and Cognitive Psychology, Psychology), according to OpenAlex
Funding: Wellcome Trust (227684/Z/22/Z, 227093/Z/23/Z); National Institute for Health Research (NIHR) (NIHR203316, NIHR203667)
Citations: not cited yet (Europe PMC); 58 references in the paper

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

License: CC-BY-4.0
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: e76059bfe5b8d8e7bab346c28f7a305a244f8171, 22 June 2026
Languages: R (12), MATLAB (2)
Size: 44 files, 14 scripts
Software Heritage: not archived
Found in: the end of the paper
Holds: README, license file, environment (renv.lock), 12 notebooks
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: tidyverse (12 files), emmeans (6 files), ggplot2 (5 files), lme4 (5 files), patchwork (4 files), FieldTrip (2 files), ggpubr (2 files), Signal Processing Toolbox (2 files), Statistics and Machine Learning Toolbox (2 files), rstatix (2 files), broom (1 file), easystats (1 file), lmerTest (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
16 files

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://doi.org/10.64898/2026.05.08.26348885

BibTeX

@article{stanyer2026insights,
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/2026.05.08.26348885},
url = {https://doi.org/10.64898/2026.05.08.26348885}
}

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/05/15
PB - medRxiv
DO - 10.64898/2026.05.08.26348885
UR - https://doi.org/10.64898/2026.05.08.26348885
ER -

CSL-JSON

{
"id": "10.64898/2026.05.08.26348885",
"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": "medRxiv",
"DOI": "10.64898/2026.05.08.26348885",
"publisher": "medRxiv",
"URL": "https://doi.org/10.64898/2026.05.08.26348885",
"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: NeuroImage
In 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 America
In 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 advances
In 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 psychiatry
In 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: eLife
In 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 communications
In 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 reports
In 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 mapping
In 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: iScience
In 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 advances
In 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.

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.