H02: Daily patterns of personal light exposure

Question and analysis sequence

This analysis models 30-minute near-eye light exposure across the day. It separates the common daily curve from variation between sites, participants, and participant-days. Chest measurements, placement-matched observations, alternative preprocessing, and alternative smooth specifications assess the sensitivity of the findings.

The inputs are generated by the preparation pages. This notebook constructs the fitted samples, estimates the models, evaluates temporal assumptions, and exports numerical estimates and the data underlying the figures.

Data and model guide

The analysis datasets provide 30-minute arithmetic-mean melanopic EDI. A primary bin requires at least 15 valid one-minute observations. Exact zeros remain observations; unsupported bins remain missing. The response is log10(melEDI + 0.1). All-available and matched-bin samples are constructed separately for the two sensor positions and preprocessing datasets.

The model separates a shared local-clock curve, sum-to-zero site deviations, participant-specific curves and participant-day intercepts. The shared curve is cyclic at midnight. An additional sensitivity also makes the site and participant deviations cyclic. Local wall time describes daily shape, while true elapsed time orders residual sequences. AR(1) sequences restart at participant-day boundaries, missing half-hours and ambiguous elapsed-time links. Each scenario estimates its own AR parameter from a preliminary fit; the final model uses the verified boundaries.

Fitted-curve dispersion and Shapley allocation answer different questions. Dispersion compares the spread of fitted site, participant and day components. Shapley allocation averages contributions across component subsets to allocate in-sample R². Ratios of participant to site contributions are reported for each measure. Hierarchical bootstrap intervals condition on the fitted model; they are not joint model-refit intervals. Clock-specific curve intervals are pointwise, not simultaneous bands. The subsequent sections show exact formulas, sample support, residual checks, matched-placement comparisons and alternative preprocessing.

The executable sections below write fitted objects to results/models/H02/, reader tables to results/tables/H02/, and the supporting numerical and figure outputs under results/. Start with the calculations below or jump to findings and interpretation.

Libraries and shared settings

library(dplyr)

Attaching package: 'dplyr'
The following objects are masked from 'package:stats':

    filter, lag
The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union
library(tidyr)
library(tibble)
library(readr)
library(ggplot2)
library(mgcv)
Loading required package: nlme

Attaching package: 'nlme'
The following object is masked from 'package:dplyr':

    collapse
This is mgcv 1.9-4. For overview type '?mgcv'.
library(gt)
source("scripts/project.R")
analysis_setup()
root <- getOption("nh.root")
source("scripts/pipeline/paths_io.R")
source("scripts/pipeline/assertions.R")
source("scripts/pipeline/multiplicity.R")
source("scripts/hypotheses/H02/h02_contract.R")
source("scripts/hypotheses/H02/h02_data.R")
source("scripts/hypotheses/H02/h02_modeling.R")
source("scripts/hypotheses/H02/h02_dominance.R")
source("scripts/hypotheses/H02/h02_result_helpers.R")
paths <- pipeline_paths(root)
producer <- "analyses/H02-daily-patterns.qmd"
for (directory in file.path(c(paths$models, paths$diagnostics, paths$tables,
                             paths$figures, paths$source_data), "H02")) {
  dir.create(directory, recursive = TRUE, showWarnings = FALSE)
}
h02_validate_inputs(root)
# A tibble: 5 × 2
  input_id                  path                                                
  <chr>                     <chr>                                               
1 main_glasses              /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/r…
2 main_chest                /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/r…
3 alternative_preprocessing /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/r…
4 wall_outcome_links        /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/r…
5 true_utc_source_bins      /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/r…

Construct the analysis samples

Join local-clock bins to their true elapsed-time sequence. Gaps and discontinuities define separate autocorrelation sequences. The alternative preprocessing and placement-matched samples use the same model-frame rules.

links <- h02_temporal_links(root)
h02_assert_temporal_links(links)

input <- h02_input_contract(root)
input_path <- function(id) input$path[match(id, input$input_id)]

main_gr_grid <- h02_main_grid(
  input_path("main_glasses"),
  "main",
  links
)
main_ch_grid <- h02_main_grid(
  input_path("main_chest"),
  "main",
  links
)
alternative_all <- h02_alternative_grid(
  input_path("alternative_preprocessing"),
  links
)
mp_gr_grid <- dplyr::filter(alternative_all, .data$position == "glasses")
mp_ch_grid <- dplyr::filter(alternative_all, .data$position == "chest")

frames <- list(
  main = list(
    glasses = h02_model_frame(main_gr_grid),
    chest = h02_model_frame(main_ch_grid)
  ),
  alternative_preprocessing = list(
    glasses = h02_model_frame(mp_gr_grid),
    chest = h02_model_frame(mp_ch_grid)
  )
)
for (scenario in names(frames)) {
  paired <- h02_paired_frames(
    frames[[scenario]]$glasses,
    frames[[scenario]]$chest
  )
  frames[[scenario]]$paired_common_sample <- paired
}

registry <- h02_run_registry()
frame_for_run <- function(scenario, placement, sample) {
  if (sample == "all_available") {
    frames[[scenario]][[placement]]
  } else {
    frames[[scenario]]$paired_common_sample[[placement]]
  }
}

model_data_dir <- file.path(paths$model_data, "H02")
dir.create(model_data_dir, recursive = TRUE, showWarnings = FALSE)
for (i in seq_len(nrow(registry))) {
  run <- registry[i, ]
  frame <- frame_for_run(run$data_scenario_id, run$placement, run$sample_scenario)
  write_h02_rds(frame, file.path(model_data_dir, paste0(run$run_id, ".rds")))
}
sample_counts <- purrr::pmap_dfr(
  registry[c("data_scenario_id", "placement", "sample_scenario", "run_id")],
  function(data_scenario_id, placement, sample_scenario, run_id) {
    h02_sample_counts(
      frame_for_run(data_scenario_id, placement, sample_scenario),
      run_id
    )
  }
)
support_audit <- dplyr::bind_rows(
  h02_support_audit(main_gr_grid, "main", "glasses"),
  h02_support_audit(main_ch_grid, "main", "chest"),
  h02_support_audit(
    mp_gr_grid,
    "alternative_preprocessing",
    "glasses"
  ),
  h02_support_audit(
    mp_ch_grid,
    "alternative_preprocessing",
    "chest"
  )
)

comparison <- dplyr::full_join(
  frames$main$glasses |>
    dplyr::select(
      dplyr::all_of(h02_key),
      main_metric_value_lx = "metric_value_lx"
    ),
  frames$alternative_preprocessing$glasses |>
    dplyr::select(
      dplyr::all_of(h02_key),
      alternative_metric_value_lx = "metric_value_lx"
    ),
  by = h02_key,
  relationship = "one-to-one"
) |>
  dplyr::mutate(position = "glasses")
comparison_chest <- dplyr::full_join(
  frames$main$chest |>
    dplyr::select(
      dplyr::all_of(h02_key),
      main_metric_value_lx = "metric_value_lx"
    ),
  frames$alternative_preprocessing$chest |>
    dplyr::select(
      dplyr::all_of(h02_key),
      alternative_metric_value_lx = "metric_value_lx"
    ),
  by = h02_key,
  relationship = "one-to-one"
) |>
  dplyr::mutate(position = "chest")
scenario_input_comparison <- dplyr::bind_rows(comparison, comparison_chest) |>
  dplyr::group_by(.data$position) |>
  dplyr::summarise(
    main_finite = sum(is.finite(.data$main_metric_value_lx)),
    alternative_finite = sum(is.finite(.data$alternative_metric_value_lx)),
    common_finite = sum(
      is.finite(.data$main_metric_value_lx) &
        is.finite(.data$alternative_metric_value_lx)
    ),
    exactly_equal_common = sum(
      is.finite(.data$main_metric_value_lx) &
        is.finite(.data$alternative_metric_value_lx) &
        .data$main_metric_value_lx == .data$alternative_metric_value_lx
    ),
    mean_main_minus_alternative_lx = mean(
      .data$main_metric_value_lx - .data$alternative_metric_value_lx,
      na.rm = TRUE
    ),
    max_absolute_difference_lx = max(
      abs(.data$main_metric_value_lx - .data$alternative_metric_value_lx),
      na.rm = TRUE
    ),
    .groups = "drop"
  )

tables <- list(sample_counts = sample_counts, support_audit = support_audit,
               scenario_input_comparison = scenario_input_comparison,
               temporal_model_specification = h02_specification_table())
for (name in names(tables)) {
  write_h02_csv(tables[[name]], file.path(model_data_dir, paste0(name, ".csv")))
}
sample_counts |> gt()
run_id site participants participant_days observations_30_minute sites exact_zero_observations ar_sequences
alternative_preprocessing__chest__all_available ALL_SITES 154 894 41664 8 13716 1282
alternative_preprocessing__chest__all_available BAUA 20 113 5287 1 1776 155
alternative_preprocessing__chest__all_available FUSPCEU 22 122 5745 1 2407 155
alternative_preprocessing__chest__all_available IZTECH 17 101 4711 1 1304 140
alternative_preprocessing__chest__all_available KNUST 15 83 3819 1 1417 148
alternative_preprocessing__chest__all_available RISE 16 96 4487 1 1443 134
alternative_preprocessing__chest__all_available THUAS 15 93 4316 1 1462 132
alternative_preprocessing__chest__all_available TUM 10 60 2772 1 800 91
alternative_preprocessing__chest__all_available UCR 39 226 10527 1 3107 327
alternative_preprocessing__glasses__all_available ALL_SITES 141 809 37603 9 12028 1179
alternative_preprocessing__glasses__all_available BAUA 18 106 4937 1 1509 150
alternative_preprocessing__glasses__all_available FUSPCEU 23 127 5977 1 2474 162
alternative_preprocessing__glasses__all_available IZTECH 17 100 4665 1 1286 138
alternative_preprocessing__glasses__all_available KNUST 15 80 3680 1 1482 141
alternative_preprocessing__glasses__all_available MPI 26 150 6935 1 1946 224
alternative_preprocessing__glasses__all_available RISE 13 78 3631 1 1101 113
alternative_preprocessing__glasses__all_available THUAS 13 78 3606 1 1214 116
alternative_preprocessing__glasses__all_available TUM 10 60 2772 1 659 91
alternative_preprocessing__glasses__all_available UCR 6 30 1400 1 357 44
alternative_preprocessing__chest__paired_common_sample ALL_SITES 112 637 29634 8 9993 928
alternative_preprocessing__chest__paired_common_sample BAUA 16 93 4331 1 1462 133
alternative_preprocessing__chest__paired_common_sample FUSPCEU 22 122 5745 1 2407 155
alternative_preprocessing__chest__paired_common_sample IZTECH 17 100 4665 1 1286 138
alternative_preprocessing__chest__paired_common_sample KNUST 15 77 3532 1 1321 139
alternative_preprocessing__chest__paired_common_sample RISE 13 78 3631 1 1129 113
alternative_preprocessing__chest__paired_common_sample THUAS 13 77 3558 1 1230 115
alternative_preprocessing__chest__paired_common_sample TUM 10 60 2772 1 800 91
alternative_preprocessing__chest__paired_common_sample UCR 6 30 1400 1 358 44
alternative_preprocessing__glasses__paired_common_sample ALL_SITES 112 637 29634 8 9725 928
alternative_preprocessing__glasses__paired_common_sample BAUA 16 93 4331 1 1322 133
alternative_preprocessing__glasses__paired_common_sample FUSPCEU 22 122 5745 1 2374 155
alternative_preprocessing__glasses__paired_common_sample IZTECH 17 100 4665 1 1286 138
alternative_preprocessing__glasses__paired_common_sample KNUST 15 77 3532 1 1430 139
alternative_preprocessing__glasses__paired_common_sample RISE 13 78 3631 1 1101 113
alternative_preprocessing__glasses__paired_common_sample THUAS 13 77 3558 1 1196 115
alternative_preprocessing__glasses__paired_common_sample TUM 10 60 2772 1 659 91
alternative_preprocessing__glasses__paired_common_sample UCR 6 30 1400 1 357 44
main__chest__all_available ALL_SITES 154 902 41842 8 13799 1507
main__chest__all_available BAUA 20 114 5308 1 1784 171
main__chest__all_available FUSPCEU 22 123 5783 1 2420 165
main__chest__all_available IZTECH 17 102 4749 1 1311 165
main__chest__all_available KNUST 15 84 3838 1 1426 178
main__chest__all_available RISE 16 96 4472 1 1440 148
main__chest__all_available THUAS 15 93 4307 1 1461 146
main__chest__all_available TUM 10 60 2757 1 799 116
main__chest__all_available UCR 39 230 10628 1 3158 418
main__glasses__all_available ALL_SITES 141 816 37756 9 12107 1358
main__glasses__all_available BAUA 18 107 4959 1 1516 167
main__glasses__all_available FUSPCEU 23 129 6052 1 2503 173
main__glasses__all_available IZTECH 17 101 4702 1 1292 163
main__glasses__all_available KNUST 15 81 3701 1 1500 170
main__glasses__all_available MPI 26 150 6900 1 1944 257
main__glasses__all_available RISE 13 78 3619 1 1101 123
main__glasses__all_available THUAS 13 78 3596 1 1214 130
main__glasses__all_available TUM 10 60 2758 1 659 115
main__glasses__all_available UCR 6 32 1469 1 378 60
main__chest__paired_common_sample ALL_SITES 112 643 29786 8 10049 1072
main__chest__paired_common_sample BAUA 16 94 4355 1 1470 148
main__chest__paired_common_sample FUSPCEU 22 123 5783 1 2420 165
main__chest__paired_common_sample IZTECH 17 101 4702 1 1293 163
main__chest__paired_common_sample KNUST 15 78 3553 1 1331 168
main__chest__paired_common_sample RISE 13 78 3619 1 1128 123
main__chest__paired_common_sample THUAS 13 77 3548 1 1229 129
main__chest__paired_common_sample TUM 10 60 2757 1 799 116
main__chest__paired_common_sample UCR 6 32 1469 1 379 60
main__glasses__paired_common_sample ALL_SITES 112 643 29786 8 9793 1072
main__glasses__paired_common_sample BAUA 16 94 4355 1 1329 148
main__glasses__paired_common_sample FUSPCEU 22 123 5783 1 2390 165
main__glasses__paired_common_sample IZTECH 17 101 4702 1 1292 163
main__glasses__paired_common_sample KNUST 15 78 3553 1 1448 168
main__glasses__paired_common_sample RISE 13 78 3619 1 1101 123
main__glasses__paired_common_sample THUAS 13 77 3548 1 1196 129
main__glasses__paired_common_sample TUM 10 60 2757 1 659 116
main__glasses__paired_common_sample UCR 6 32 1469 1 378 60

Fit daily patterns and quantify variation

Select the temporal structure using the primary near-eye sample, estimate residual autocorrelation, and fit the selected structure to each sensitivity sample. Bootstrap intervals resample sites, participants, and participant-days from fitted contributions; full reproduction uses 2,000 replicates.

registry <- h02_run_registry() |>
  dplyr::mutate(
    run_order = dplyr::case_when(
      .data$analytical_role == "primary" ~ 1L,
      .data$analytical_role == "alternative_preprocessing_sensitivity" ~ 2L,
      .data$data_scenario_id == "main" &
        .data$placement == "chest" &
        .data$sample_scenario == "all_available" ~
        3L,
      .data$data_scenario_id == "alternative_preprocessing" &
        .data$placement == "chest" &
        .data$sample_scenario == "all_available" ~
        4L,
      .data$data_scenario_id == "main" ~ 5L,
      TRUE ~ 6L
    )
  ) |>
  dplyr::arrange(.data$run_order, .data$run_id)

model_tables <- list()
comparison_tables <- list()
residual_acf_tables <- list()
residual_summary_tables <- list()
boundary_tables <- list()
k_check_tables <- list()
influence_score_tables <- list()
contribution_influence_tables <- list()
site_prediction_tables <- list()
variation_tables <- list()
selected_model_id <- NULL
primary_rho <- NA_real_

for (i in seq_len(nrow(registry))) {
  run <- registry[i, ]
  run_id <- run$run_id
  message(
    "Fitting H02 run ",
    i,
    "/",
    nrow(registry),
    ": ",
    run_id
  )
  frame_path <- file.path(
    paths$model_data,
    "H02",
    paste0(run_id, ".rds")
  )
  if (!file.exists(frame_path)) {
    h02_abort(
      "Missing H02 model frame %s; execute the preceding analysis-sample section first",
      frame_path
    )
  }
  frame <- readRDS(frame_path)

  if (run$analytical_role == "primary") {
    fitted <- h02_fit_primary_structure(frame, run_id)
    selected_model_id <- fitted$selected_model_id
    primary_rho <- fitted$rho
    model_tables[[run_id]] <- dplyr::bind_rows(
      fitted$model_table,
      h02_model_row(
        fitted$final,
        paste0(selected_model_id, "_final_fREML"),
        run_id,
        fitted$rho
      )
    )
    comparison_tables[[run_id]] <- fitted$comparisons
  } else {
    if (is.null(selected_model_id)) {
      h02_abort("Primary H02 model must be selected before sensitivities")
    }
    fitted <- h02_fit_selected_run(
      frame,
      run_id,
      selected_model_id
    )
    model_tables[[run_id]] <- fitted$model_table
  }

  residual_acf_tables[[run_id]] <- dplyr::bind_rows(
    h02_residual_acf(
      fitted$preliminary,
      fitted$data,
      "preliminary_no_AR1",
      run_id
    ),
    h02_residual_acf(
      fitted$final,
      fitted$data,
      "final_AR1_standardized",
      run_id
    )
  )
  residual_summary_tables[[run_id]] <- h02_residual_summary(
    fitted$final,
    fitted$data,
    run_id
  )
  boundary_tables[[run_id]] <- h02_boundary_audit(
    fitted$data,
    run_id
  )
  k_check_tables[[run_id]] <- h02_k_check(fitted$final, run_id)
  influence_score_tables[[run_id]] <- h02_influence_scores(
    fitted$final,
    fitted$data,
    run_id
  )

  site_predictions <- h02_site_predictions(
    fitted$final,
    fitted$data,
    run_id
  )
  variation <- h02_variation_summary(
    fitted$final,
    fitted$data,
    site_predictions,
    run_id
  )
  site_prediction_tables[[run_id]] <- site_predictions
  variation_tables[[run_id]] <- variation$summary

  if (run$analytical_role == "primary") {
    contribution_influence_tables[[run_id]] <-
      h02_contribution_influence(
        variation$contributions,
        variation$summary,
        run_id
      )
  }

  model_diagnostics <- list(
    run_id = run_id,
    selected_model_id = selected_model_id,
    rho = fitted$rho,
    model_table = model_tables[[run_id]],
    comparisons = comparison_tables[[run_id]],
    residual_acf = residual_acf_tables[[run_id]],
    residual_summary = residual_summary_tables[[run_id]],
    boundary_audit = boundary_tables[[run_id]],
    k_check = k_check_tables[[run_id]],
    influence_scores = influence_score_tables[[run_id]],
    contribution_influence = contribution_influence_tables[[run_id]],
    site_predictions = site_prediction_tables[[run_id]],
    variation_summary = variation_tables[[run_id]]
  )
  write_h02_rds(
    model_diagnostics,
    file.path(
      paths$diagnostics,
      "H02",
      paste0(run_id, "__model_diagnostics.rds")
    ),
    paste0(run_id, "__model_diagnostics")
  )
  model_path <- file.path(
    paths$models,
    "H02",
    paste0(run_id, "__selected_model.rds")
  )
  write_h02_rds(
    fitted$final,
    model_path,
    paste0(run_id, "__model"),
    metadata = list(
      run_id = run_id,
      selected_model_id = selected_model_id,
      rho = fitted$rho,
      participants = dplyr::n_distinct(fitted$data$participant),
      participant_days = dplyr::n_distinct(fitted$data$participant_day),
      observations = nrow(fitted$data),
      sites = dplyr::n_distinct(fitted$data$site)
    )
  )
  write_h02_rds(
    variation$contributions,
    file.path(
      paths$source_data,
      "H02",
      paste0(run_id, "__fitted_contributions.rds")
    ),
    paste0(run_id, "__contributions")
  )
  write_h02_rds(
    variation$bootstrap,
    file.path(
      paths$diagnostics,
      "H02",
      paste0(run_id, "__variation_bootstrap.rds")
    ),
    paste0(run_id, "__variation_bootstrap")
  )
  rm(variation, site_predictions, frame)
  if (!is.null(fitted$candidates)) {
    fitted$candidates <- NULL
  }
  rm(fitted)
  invisible(gc())
}
Fitting H02 run 1/8: main__glasses__all_available
  fitting comparable fREML structure: no_site
  fitting comparable fREML structure: site_pattern
  fitting comparable fREML structure: no_participant_pattern
  fitting comparable fREML structure: no_participant_day
Fitting H02 run 2/8: alternative_preprocessing__glasses__all_available
Fitting H02 run 3/8: main__chest__all_available
Fitting H02 run 4/8: alternative_preprocessing__chest__all_available
Fitting H02 run 5/8: main__chest__paired_common_sample
Fitting H02 run 6/8: main__glasses__paired_common_sample
Fitting H02 run 7/8: alternative_preprocessing__chest__paired_common_sample
Fitting H02 run 8/8: alternative_preprocessing__glasses__paired_common_sample
bind_rows(variation_tables) |> filter(run_id == "main__glasses__all_available") |> gt()
run_id summary_id definition estimate lower_95 upper_95 unit confidence_interval_method bootstrap_seed bootstrap_replicates
main__glasses__all_available site_curve_variation Mean across 48 clock bins of the sample variance across equal-weight site fitted curves 0.1003789 0.040728060 0.13886837 squared log10(melEDI + 0.1 lx) prediction units percentile hierarchical cluster bootstrap of fitted contributions; sites, participants within sites, and days within participants; 2000 replicates; conditional on fitted smoothing structure 20302601 2000
main__glasses__all_available participant_curve_variation Equal-site mean across 48 clock bins of within-site sample variance among fitted participant deviation curves 0.1803993 0.125849559 0.21360544 squared log10(melEDI + 0.1 lx) prediction units percentile hierarchical cluster bootstrap of fitted contributions; sites, participants within sites, and days within participants; 2000 replicates; conditional on fitted smoothing structure 20302601 2000
main__glasses__all_available participant_day_intercept_variation Equal-site mean of participant-specific sample variance among fitted participant-day intercept contributions 0.0194624 0.009758323 0.02444059 squared log10(melEDI + 0.1 lx) prediction units percentile hierarchical cluster bootstrap of fitted contributions; sites, participants within sites, and days within participants; 2000 replicates; conditional on fitted smoothing structure 20302601 2000
main__glasses__all_available participant_plus_day_variation Sum of fitted participant-curve and participant-day-intercept variation summaries 0.1998617 0.139911721 0.22998838 squared log10(melEDI + 0.1 lx) prediction units percentile hierarchical cluster bootstrap of fitted contributions; sites, participants within sites, and days within participants; 2000 replicates; conditional on fitted smoothing structure 20302601 2000
main__glasses__all_available participant_to_site_ratio Participant-curve variation divided by site-curve variation 1.7971848 1.156882455 4.33197882 ratio percentile hierarchical cluster bootstrap of fitted contributions; sites, participants within sites, and days within participants; 2000 replicates; conditional on fitted smoothing structure 20302601 2000
main__glasses__all_available participant_plus_day_to_site_ratio Participant-curve plus participant-day-intercept variation divided by site-curve variation 1.9910743 1.290535283 4.76697631 ratio percentile hierarchical cluster bootstrap of fitted contributions; sites, participants within sites, and days within participants; 2000 replicates; conditional on fitted smoothing structure 20302601 2000

Compare sites and preprocessing choices

Apply the registered one-test omnibus family and compare the variation estimates between the two preprocessing variants. Simultaneous intervals describe where site curves differ from the equal-site mean.

model_table <- dplyr::bind_rows(model_tables)
comparison_table <- dplyr::bind_rows(comparison_tables) |>
  dplyr::mutate(
    family_id = dplyr::if_else(
      .data$comparison_id == "site_pattern_vs_no_site",
      "H02-F1-site-pattern",
      NA_character_
    ),
    family_n = dplyr::if_else(
      .data$comparison_id == "site_pattern_vs_no_site",
      1L,
      NA_integer_
    ),
    adjustment_method = dplyr::if_else(
      .data$comparison_id == "site_pattern_vs_no_site",
      "BH",
      NA_character_
    ),
    inferential_role = dplyr::if_else(
      .data$comparison_id == "site_pattern_vs_no_site",
      "single registered omnibus site-pattern family",
      paste(
        "restricted-likelihood structure diagnostic;",
        "no multiplicity claim"
      )
    )
  )
comparison_table$p_adjusted <- NA_real_
family_rows <- which(
  comparison_table$comparison_id == "site_pattern_vs_no_site"
)
comparison_table$p_adjusted[family_rows] <- adjust_p_family(
  comparison_table$p_raw[family_rows],
  method = "BH",
  n = 1L
)
residual_acf <- dplyr::bind_rows(residual_acf_tables)
residual_summary <- dplyr::bind_rows(residual_summary_tables)
boundary_audit <- dplyr::bind_rows(boundary_tables)
k_check <- dplyr::bind_rows(k_check_tables)
influence_scores <- dplyr::bind_rows(influence_score_tables)
contribution_influence <- dplyr::bind_rows(
  contribution_influence_tables
)
site_predictions <- dplyr::bind_rows(site_prediction_tables)
variation_summary <- dplyr::bind_rows(variation_tables)
site_windows <- dplyr::bind_rows(lapply(
  unique(site_predictions$run_id),
  function(run_id) h02_site_windows(site_predictions, run_id)
))

primary_id <- "main__glasses__all_available"
alternative_id <-
  "alternative_preprocessing__glasses__all_available"
primary_variation <- dplyr::filter(
  variation_summary,
  .data$run_id == primary_id
)
alternative_variation <- dplyr::filter(
  variation_summary,
  .data$run_id == alternative_id
)
stability <- dplyr::inner_join(
  primary_variation |>
    dplyr::transmute(
      summary_id = .data$summary_id,
      main_estimate = .data$estimate,
      main_lower_95 = .data$lower_95,
      main_upper_95 = .data$upper_95
    ),
  alternative_variation |>
    dplyr::transmute(
      summary_id = .data$summary_id,
      alternative_estimate = .data$estimate,
      alternative_lower_95 = .data$lower_95,
      alternative_upper_95 = .data$upper_95
    ),
  by = "summary_id",
  relationship = "one-to-one"
) |>
  dplyr::mutate(
    relative_change = (.data$alternative_estimate - .data$main_estimate) /
      .data$main_estimate,
    confidence_intervals_overlap = pmax(
      .data$main_lower_95,
      .data$alternative_lower_95
    ) <=
      pmin(.data$main_upper_95, .data$alternative_upper_95),
    ratio_summary = grepl("ratio$", .data$summary_id),
    main_relation_to_one = dplyr::case_when(
      !.data$ratio_summary ~ "not_applicable",
      .data$main_lower_95 > 1 ~ "above_one",
      .data$main_upper_95 < 1 ~ "below_one",
      TRUE ~ "includes_one"
    ),
    alternative_relation_to_one = dplyr::case_when(
      !.data$ratio_summary ~ "not_applicable",
      .data$alternative_lower_95 > 1 ~ "above_one",
      .data$alternative_upper_95 < 1 ~ "below_one",
      TRUE ~ "includes_one"
    ),
    stability_classification = dplyr::case_when(
      .data$ratio_summary &
        sign(.data$main_estimate - 1) != sign(.data$alternative_estimate - 1) ~
        "unstable",
      .data$ratio_summary &
        .data$main_relation_to_one != .data$alternative_relation_to_one ~
        "inference-sensitive",
      abs(.data$relative_change) <= 0.20 &
        .data$confidence_intervals_overlap ~
        "stable",
      abs(.data$relative_change) <= 0.50 &
        .data$confidence_intervals_overlap ~
        "directionally stable",
      TRUE ~ "magnitude-sensitive"
    ),
    scenario_change = paste(
      "Only the prepared dataset changed; formula, transformation, basis",
      "dimensions, structure, selection result, rho-estimation algorithm,",
      "AR-boundary algorithm, summaries, and CI algorithm were identical."
    )
  )

primary_window_summary <- site_windows |>
  dplyr::filter(.data$run_id == primary_id) |>
  dplyr::mutate(
    window_text = dplyr::if_else(
      .data$direction == "no_simultaneous_difference",
      "No 30-minute bin had a simultaneous 95% interval excluding 1",
      sprintf(
        "%s %s-%s; point-ratio range %.2f-%.2f",
        .data$direction,
        .data$start_local_clock,
        .data$end_local_clock,
        .data$minimum_point_ratio,
        .data$maximum_point_ratio
      )
    )
  ) |>
  dplyr::group_by(.data$site) |>
  dplyr::summarise(
    new_H02_simultaneous_result = paste(
      .data$window_text,
      collapse = "; "
    ),
    .groups = "drop"
  )
selected_specification <- h02_selected_model_specification(
  readRDS(file.path(
    paths$models,
    "H02",
    paste0(primary_id, "__selected_model.rds")
  )),
  selected_model_id,
  primary_rho,
  primary_id
)
comparison_table |> gt()
run_id comparison_id reduced_model full_model reduced_AIC full_AIC delta_AIC_reduced_minus_full test_method test_statistic test_df p_raw log_p_raw family_id family_n adjustment_method inferential_role p_adjusted
main__glasses__all_available site_pattern_vs_no_site no_site site_pattern 64860.51 64819.64 40.86805 joint approximate Bayesian Wald chi-square test of all sum-to-zero site-smooth coefficients 474.9313 96.0000 1.606128e-51 -116.9580 H02-F1-site-pattern 1 BH single registered omnibus site-pattern family 1.606128e-51
main__glasses__all_available participant_pattern no_participant_pattern site_pattern 67309.33 64819.64 2489.68402 restricted-likelihood diagnostic from fitted log likelihoods 3459.5771 484.9466 0.000000e+00 -1016.3679 NA NA NA restricted-likelihood structure diagnostic; no multiplicity claim NA
main__glasses__all_available participant_day no_participant_day site_pattern 65163.83 64819.64 344.19175 restricted-likelihood diagnostic from fitted log likelihoods 1030.7909 343.2996 7.167574e-70 -159.2114 NA NA NA restricted-likelihood structure diagnostic; no multiplicity claim NA
stability |> select(summary_id, main_estimate, alternative_estimate, stability_classification) |> gt()
summary_id main_estimate alternative_estimate stability_classification
site_curve_variation 0.1003789 0.09931330 stable
participant_curve_variation 0.1803993 0.16935096 stable
participant_day_intercept_variation 0.0194624 0.02099211 stable
participant_plus_day_variation 0.1998617 0.19034306 stable
participant_to_site_ratio 1.7971848 1.70521938 stable
participant_plus_day_to_site_ratio 1.9910743 1.91659195 stable

Export numerical results and inspect the primary curves

Save estimates, diagnostics, and exact curve data. The figures display the fitted site curves and the residual correlation remaining after accounting for elapsed-time sequences.

tables <- list(
  model_fit_summary = model_table,
  model_structure_comparisons = comparison_table,
  residual_acf = residual_acf,
  residual_summary = residual_summary,
  ar_boundary_audit = boundary_audit,
  basis_dimension_checks = k_check,
  influence_scores = influence_scores,
  conditional_deletion_influence = contribution_influence,
  variation_summary = variation_summary,
  alternative_preprocessing_stability = stability,
  simultaneous_site_windows = site_windows,
  selected_temporal_model_specification = selected_specification
)
table_locations <- c(
  model_fit_summary = file.path(
    paths$tables,
    "H02",
    "model_fit_summary.csv"
  ),
  model_structure_comparisons = file.path(
    paths$tables,
    "H02",
    "model_structure_comparisons.csv"
  ),
  residual_acf = file.path(
    paths$diagnostics,
    "H02",
    "residual_acf.csv"
  ),
  residual_summary = file.path(
    paths$diagnostics,
    "H02",
    "residual_summary.csv"
  ),
  ar_boundary_audit = file.path(
    paths$diagnostics,
    "H02",
    "ar_boundary_audit.csv"
  ),
  basis_dimension_checks = file.path(
    paths$diagnostics,
    "H02",
    "basis_dimension_checks.csv"
  ),
  influence_scores = file.path(
    paths$diagnostics,
    "H02",
    "influence_scores.csv"
  ),
  conditional_deletion_influence = file.path(
    paths$diagnostics,
    "H02",
    "conditional_deletion_influence.csv"
  ),
  variation_summary = file.path(
    paths$tables,
    "H02",
    "variation_summary.csv"
  ),
  alternative_preprocessing_stability = file.path(
    paths$tables,
    "H02",
    "alternative_preprocessing_stability.csv"
  ),
  simultaneous_site_windows = file.path(
    paths$tables,
    "H02",
    "simultaneous_site_windows.csv"
  ),
  selected_temporal_model_specification = file.path(
    paths$models,
    "H02",
    "selected_temporal_model_specification.csv"
  )
)
for (name in names(tables)) {
  write_h02_csv(
    tables[[name]],
    table_locations[[name]],
    paste0("table__", name)
  )
}

write_h02_csv(
  site_predictions,
  file.path(paths$source_data, "H02", "site_curve_predictions.csv"),
  "source__site_curve_predictions"
)

primary_curves <- dplyr::filter(
  site_predictions,
  .data$run_id == primary_id
)
curve_plot <- ggplot(
  primary_curves,
  aes(x = .data$time_hour, y = .data$estimate_melEDI_lx)
) +
  geom_ribbon(
    aes(
      ymin = .data$lower_melEDI_lx,
      ymax = .data$upper_melEDI_lx
    ),
    fill = "#6B7280",
    alpha = 0.22
  ) +
  geom_line(colour = "#005A8D", linewidth = 0.8) +
  facet_wrap(vars(.data$site), ncol = 3) +
  scale_x_continuous(
    breaks = seq(0, 24, by = 6),
    limits = c(0, 24)
  ) +
  scale_y_continuous(
    trans = scales::pseudo_log_trans(base = 10, sigma = 0.1),
    breaks = c(0, 1, 10, 100, 1000),
    labels = scales::label_number(big.mark = ",")
  ) +
  labs(
    x = "Local wall-clock time (hours)",
    y = "Fitted melEDI (lx; pseudo-log scale)",
    title = "H02 primary near-eye site curves",
    subtitle = paste(
      "Back-transformed conditional means with simultaneous 95%",
      "confidence bands"
    )
  ) +
  theme_minimal(base_size = 11) +
  theme(panel.grid.minor = element_blank())
write_h02_plot(
  curve_plot,
  file.path(paths$figures, "H02", "primary_site_curves.png"),
  "figure__primary_site_curves",
  width = 10,
  height = 8
)

acf_plot_data <- residual_acf |>
  dplyr::filter(.data$run_id == primary_id)
acf_plot <- ggplot(
  acf_plot_data,
  aes(
    x = .data$lag_30_minute_bins,
    y = .data$correlation,
    colour = .data$stage
  )
) +
  geom_hline(yintercept = 0, colour = "grey70") +
  geom_line(linewidth = 0.8) +
  geom_point(size = 2) +
  scale_x_continuous(breaks = seq_len(6L)) +
  scale_colour_manual(
    values = c(
      preliminary_no_AR1 = "#A61C3C",
      final_AR1_standardized = "#005A8D"
    ),
    breaks = c(
      "preliminary_no_AR1",
      "final_AR1_standardized"
    ),
    labels = c(
      "Preliminary residuals",
      "AR-standardized residuals"
    )
  ) +
  labs(
    x = "Lag (30-minute bins, never crossing an AR boundary)",
    y = "Residual correlation",
    colour = NULL,
    title = "H02 primary residual temporal dependence"
  ) +
  theme_minimal(base_size = 11) +
  theme(legend.position = "bottom", panel.grid.minor = element_blank())
write_h02_plot(
  acf_plot,
  file.path(paths$figures, "H02", "primary_residual_acf.png"),
  "figure__primary_residual_acf",
  width = 7,
  height = 4.5
)
curve_plot

acf_plot

Sensitivity to the site-smooth specification

Replace the primary sum-to-zero site smooths with ordered cyclic difference smooths. The response, sample, autocorrelation algorithm, and summaries are held fixed.

formula_id <- "cyclic_ordered_sensitivity"
scenarios <- tibble::tribble(
  ~data_scenario_id,
  ~base_run_id,
  "main",
  "main__glasses__all_available",
  "alternative_preprocessing",
  "alternative_preprocessing__glasses__all_available"
) |>
  dplyr::mutate(
    sensitivity_run_id = paste0(
      .data$base_run_id,
      "__",
      formula_id
    )
  )

model_tables <- list()
variation_tables <- list()
residual_acf_tables <- list()
residual_summary_tables <- list()
k_check_tables <- list()
site_prediction_tables <- list()

for (i in seq_len(nrow(scenarios))) {
  scenario <- scenarios[i, ]
  message(
    "Fitting H02 formula sensitivity ",
    i,
    "/",
    nrow(scenarios),
    ": ",
    scenario$sensitivity_run_id
  )
  frame <- readRDS(file.path(
    paths$model_data,
    "H02",
    paste0(scenario$base_run_id, ".rds")
  ))
  fitted <- h02_fit_selected_run(
    frame,
    scenario$sensitivity_run_id,
    formula_id
  )
  predictions <- h02_site_predictions(
    fitted$final,
    fitted$data,
    scenario$sensitivity_run_id
  )
  variation <- h02_variation_summary(
    fitted$final,
    fitted$data,
    predictions,
    scenario$sensitivity_run_id
  )

  model_tables[[scenario$sensitivity_run_id]] <- fitted$model_table |>
    dplyr::mutate(
      data_scenario_id = scenario$data_scenario_id,
      formula_role = "model-form sensitivity",
      .before = 1L
    )
  variation_tables[[scenario$sensitivity_run_id]] <- variation$summary |>
    dplyr::mutate(
      data_scenario_id = scenario$data_scenario_id,
      formula_role = "model-form sensitivity",
      .before = 1L
    )
  residual_acf_tables[[scenario$sensitivity_run_id]] <- dplyr::bind_rows(
    h02_residual_acf(
      fitted$preliminary,
      fitted$data,
      "preliminary_no_AR1",
      scenario$sensitivity_run_id
    ),
    h02_residual_acf(
      fitted$final,
      fitted$data,
      "final_AR1_standardized",
      scenario$sensitivity_run_id
    )
  ) |>
    dplyr::mutate(
      data_scenario_id = scenario$data_scenario_id,
      .before = 1L
    )
  residual_summary_tables[[scenario$sensitivity_run_id]] <-
    h02_residual_summary(
      fitted$final,
      fitted$data,
      scenario$sensitivity_run_id
    ) |>
    dplyr::mutate(
      data_scenario_id = scenario$data_scenario_id,
      .before = 1L
    )
  k_check_tables[[scenario$sensitivity_run_id]] <- h02_k_check(
    fitted$final,
    scenario$sensitivity_run_id
  ) |>
    dplyr::mutate(
      data_scenario_id = scenario$data_scenario_id,
      .before = 1L
    )
  site_prediction_tables[[scenario$sensitivity_run_id]] <- predictions |>
    dplyr::mutate(
      data_scenario_id = scenario$data_scenario_id,
      .before = 1L
    )

  write_h02_rds(
    fitted$final,
    file.path(
      paths$models,
      "H02",
      paste0(scenario$sensitivity_run_id, "__selected_model.rds")
    ),
    paste0(scenario$sensitivity_run_id, "__model"),
    metadata = list(
      base_run_id = scenario$base_run_id,
      formula_id = formula_id,
      rho = fitted$rho,
      participants = dplyr::n_distinct(fitted$data$participant),
      participant_days = dplyr::n_distinct(fitted$data$participant_day),
      observations = nrow(fitted$data),
      sites = dplyr::n_distinct(fitted$data$site)
    )
  )
  write_h02_rds(
    variation$contributions,
    file.path(
      paths$source_data,
      "H02",
      paste0(scenario$sensitivity_run_id, "__fitted_contributions.rds")
    ),
    paste0(scenario$sensitivity_run_id, "__contributions")
  )
  write_h02_rds(
    variation$bootstrap,
    file.path(
      paths$diagnostics,
      "H02",
      paste0(scenario$sensitivity_run_id, "__variation_bootstrap.rds")
    ),
    paste0(scenario$sensitivity_run_id, "__variation_bootstrap")
  )
  rm(frame, fitted, predictions, variation)
  invisible(gc())
}
Fitting H02 formula sensitivity 1/2: main__glasses__all_available__cyclic_ordered_sensitivity
Fitting H02 formula sensitivity 2/2: alternative_preprocessing__glasses__all_available__cyclic_ordered_sensitivity
model_summary <- dplyr::bind_rows(model_tables)
variation_summary <- dplyr::bind_rows(variation_tables)
residual_acf <- dplyr::bind_rows(residual_acf_tables)
residual_summary <- dplyr::bind_rows(residual_summary_tables)
k_check <- dplyr::bind_rows(k_check_tables)
site_predictions <- dplyr::bind_rows(site_prediction_tables)

primary_variation <- readr::read_csv(
  file.path(paths$tables, "H02", "variation_summary.csv"),
  show_col_types = FALSE
) |>
  dplyr::filter(.data$run_id %in% scenarios$base_run_id) |>
  dplyr::inner_join(
    scenarios |>
      dplyr::select("data_scenario_id", "base_run_id"),
    by = c("run_id" = "base_run_id"),
    relationship = "many-to-one"
  )

comparison <- dplyr::inner_join(
  primary_variation |>
    dplyr::transmute(
      data_scenario_id = .data$data_scenario_id,
      summary_id = .data$summary_id,
      primary_sz_estimate = .data$estimate,
      primary_sz_lower_95 = .data$lower_95,
      primary_sz_upper_95 = .data$upper_95
    ),
  variation_summary |>
    dplyr::transmute(
      data_scenario_id = .data$data_scenario_id,
      summary_id = .data$summary_id,
      cyclic_sensitivity_estimate = .data$estimate,
      cyclic_sensitivity_lower_95 = .data$lower_95,
      cyclic_sensitivity_upper_95 = .data$upper_95
    ),
  by = c("data_scenario_id", "summary_id"),
  relationship = "one-to-one"
) |>
  dplyr::mutate(
    relative_change = (.data$cyclic_sensitivity_estimate -
      .data$primary_sz_estimate) /
      .data$primary_sz_estimate,
    confidence_intervals_overlap = pmax(
      .data$primary_sz_lower_95,
      .data$cyclic_sensitivity_lower_95
    ) <=
      pmin(
        .data$primary_sz_upper_95,
        .data$cyclic_sensitivity_upper_95
      ),
    ratio_summary = grepl("ratio$", .data$summary_id),
    primary_relation_to_one = dplyr::case_when(
      !.data$ratio_summary ~ "not_applicable",
      .data$primary_sz_lower_95 > 1 ~ "above_one",
      .data$primary_sz_upper_95 < 1 ~ "below_one",
      TRUE ~ "includes_one"
    ),
    sensitivity_relation_to_one = dplyr::case_when(
      !.data$ratio_summary ~ "not_applicable",
      .data$cyclic_sensitivity_lower_95 > 1 ~ "above_one",
      .data$cyclic_sensitivity_upper_95 < 1 ~ "below_one",
      TRUE ~ "includes_one"
    ),
    stability_classification = dplyr::case_when(
      .data$ratio_summary &
        sign(.data$primary_sz_estimate - 1) !=
          sign(.data$cyclic_sensitivity_estimate - 1) ~
        "unstable",
      .data$ratio_summary &
        .data$primary_relation_to_one != .data$sensitivity_relation_to_one ~
        "inference-sensitive",
      abs(.data$relative_change) <= 0.20 &
        .data$confidence_intervals_overlap ~
        "stable",
      abs(.data$relative_change) <= 0.50 &
        .data$confidence_intervals_overlap ~
        "directionally stable",
      TRUE ~ "magnitude-sensitive"
    ),
    scenario_change = paste(
      "Only the model formula and associated basis dimensions changed:",
      "primary overall cc(k=12) + site sz(k=12) + participant fs(k=10)",
      "versus cyclic sensitivity with parametric site + overall cc(k=12) +",
      "ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8);",
      "data, transform, rho algorithm, AR boundaries, summaries, and",
      "conditional interval algorithm were identical."
    )
  )

tables <- list(
  formula_sensitivity_model_fit_summary = model_summary,
  formula_sensitivity_variation_summary = variation_summary,
  formula_sensitivity_comparison = comparison,
  formula_sensitivity_residual_acf = residual_acf,
  formula_sensitivity_residual_summary = residual_summary,
  formula_sensitivity_basis_dimension_checks = k_check
)
table_locations <- c(
  formula_sensitivity_model_fit_summary = file.path(
    paths$tables,
    "H02",
    "formula_sensitivity_model_fit_summary.csv"
  ),
  formula_sensitivity_variation_summary = file.path(
    paths$tables,
    "H02",
    "formula_sensitivity_variation_summary.csv"
  ),
  formula_sensitivity_comparison = file.path(
    paths$tables,
    "H02",
    "formula_sensitivity_comparison.csv"
  ),
  formula_sensitivity_residual_acf = file.path(
    paths$diagnostics,
    "H02",
    "formula_sensitivity_residual_acf.csv"
  ),
  formula_sensitivity_residual_summary = file.path(
    paths$diagnostics,
    "H02",
    "formula_sensitivity_residual_summary.csv"
  ),
  formula_sensitivity_basis_dimension_checks = file.path(
    paths$diagnostics,
    "H02",
    "formula_sensitivity_basis_dimension_checks.csv"
  )
)
for (name in names(tables)) {
  write_h02_csv(
    tables[[name]],
    table_locations[[name]],
    paste0("table__", name)
  )
}
write_h02_csv(
  site_predictions,
  file.path(
    paths$source_data,
    "H02",
    "formula_sensitivity_site_curve_predictions.csv"
  ),
  "source__formula_sensitivity_site_curve_predictions"
)
comparison |> gt()
data_scenario_id summary_id primary_sz_estimate primary_sz_lower_95 primary_sz_upper_95 cyclic_sensitivity_estimate cyclic_sensitivity_lower_95 cyclic_sensitivity_upper_95 relative_change confidence_intervals_overlap ratio_summary primary_relation_to_one sensitivity_relation_to_one stability_classification scenario_change
main site_curve_variation 0.10037885 0.040728060 0.13886837 0.10641863 0.046639979 0.14292709 0.060169851 TRUE FALSE not_applicable not_applicable stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
main participant_curve_variation 0.18039935 0.125849559 0.21360544 0.18405409 0.127532915 0.21823575 0.020259173 TRUE FALSE not_applicable not_applicable stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
main participant_day_intercept_variation 0.01946240 0.009758323 0.02444059 0.01701563 0.008754796 0.02136500 -0.125717665 TRUE FALSE not_applicable not_applicable stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
main participant_plus_day_variation 0.19986175 0.139911721 0.22998838 0.20106972 0.140437496 0.23291137 0.006044049 TRUE FALSE not_applicable not_applicable stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
main participant_to_site_ratio 1.79718481 1.156882455 4.33197882 1.72952880 1.116287879 3.88077949 -0.037645551 TRUE TRUE above_one above_one stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
main participant_plus_day_to_site_ratio 1.99107426 1.290535283 4.76697631 1.88942216 1.212192202 4.13148930 -0.051053897 TRUE TRUE above_one above_one stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
alternative_preprocessing site_curve_variation 0.09931330 0.040273217 0.13719386 0.10470920 0.046364223 0.13870653 0.054332151 TRUE FALSE not_applicable not_applicable stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
alternative_preprocessing participant_curve_variation 0.16935096 0.118869916 0.20085380 0.18167442 0.126911856 0.21454530 0.072768760 TRUE FALSE not_applicable not_applicable stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
alternative_preprocessing participant_day_intercept_variation 0.02099211 0.010611818 0.02654689 0.01725900 0.008779587 0.02161704 -0.177833841 TRUE FALSE not_applicable not_applicable stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
alternative_preprocessing participant_plus_day_variation 0.19034306 0.133574844 0.21697346 0.19893342 0.140039460 0.22925583 0.045130892 TRUE FALSE not_applicable not_applicable stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
alternative_preprocessing participant_to_site_ratio 1.70521938 1.068379478 4.02895010 1.73503774 1.124768201 3.77254050 0.017486528 TRUE TRUE above_one above_one stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.
alternative_preprocessing participant_plus_day_to_site_ratio 1.91659195 1.201279335 4.48867384 1.89986566 1.227085852 4.08608361 -0.008727098 TRUE TRUE above_one above_one stable Only the model formula and associated basis dimensions changed: primary overall cc(k=12) + site sz(k=12) + participant fs(k=10) versus cyclic sensitivity with parametric site + overall cc(k=12) + ordered-factor site cc(k=12,id=1) + participant fs(cc,k=8); data, transform, rho algorithm, AR boundaries, summaries, and conditional interval algorithm were identical.

Check boundaries, dependence, and smooth identifiability

Evaluate midnight continuity, the site-smooth constraint, concurvity, and residual dependence within participants and participant-days. These diagnostics use the fitted models from this render.

diagnostic_directory <- file.path(paths$diagnostics, "H02")
diagnostic_runs <- tibble::tribble(
  ~run_id,
  ~placement,
  "main__glasses__all_available",
  "glasses",
  "main__chest__all_available",
  "chest"
)

endpoint_tables <- list()
constraint_tables <- list()
dependence_tables <- list()
concurvity_tables <- list()
cluster_tables <- list()

for (i in seq_len(nrow(diagnostic_runs))) {
  run_id <- diagnostic_runs$run_id[[i]]
  message("Computing extended H02 temporal diagnostics: ", run_id)
  model_path <- file.path(
    paths$models,
    "H02",
    paste0(run_id, "__selected_model.rds")
  )
  frame_path <- file.path(paths$model_data, "H02", paste0(run_id, ".rds"))
  if (!file.exists(model_path) || !file.exists(frame_path)) {
    h02_abort("Missing selected model or model frame for %s", run_id)
  }
  fit <- readRDS(model_path)
  data <- h02_prepare_fit_data(readRDS(frame_path))
  if (stats::nobs(fit) != nrow(data)) {
    h02_abort("Model/frame row mismatch for %s", run_id)
  }
  endpoint_tables[[run_id]] <- endpoint_diagnostics(fit, data, run_id)
  constraint_tables[[run_id]] <- site_constraint_diagnostic(
    fit,
    data,
    run_id
  )
  dependence_tables[[run_id]] <- fitted_term_dependence(fit, data, run_id)
  cluster_tables[[run_id]] <- cluster_residual_acf(fit, data, run_id)
  message("  computing formal full-model concurvity via gratia/mgcv")
  concurvity_tables[[run_id]] <- formal_concurvity(fit, run_id)
  rm(fit, data)
  invisible(gc())
}
Computing extended H02 temporal diagnostics: main__glasses__all_available
  computing formal full-model concurvity via gratia/mgcv
Computing extended H02 temporal diagnostics: main__chest__all_available
  computing formal full-model concurvity via gratia/mgcv
endpoint_table <- dplyr::bind_rows(endpoint_tables)
constraint_table <- dplyr::bind_rows(constraint_tables)
dependence_table <- dplyr::bind_rows(dependence_tables)
concurvity_table <- dplyr::bind_rows(concurvity_tables)
cluster_detail <- dplyr::bind_rows(cluster_tables)
cluster_summary <- summarise_cluster_residual_acf(cluster_detail)

tables <- list(
  midnight_continuity = endpoint_table,
  sz_constraint_identifiability = constraint_table,
  formal_concurvity = concurvity_table,
  fitted_term_dependence = dependence_table,
  cluster_residual_acf_detail = cluster_detail,
  cluster_residual_acf_summary = cluster_summary
)
table_paths <- file.path(
  diagnostic_directory,
  paste0(names(tables), ".csv")
)
names(table_paths) <- names(tables)
for (name in names(tables)) {
  write_h02_csv(
    tables[[name]],
    table_paths[[name]],
    paste0("diagnostic__", name)
  )
}
constraint_table |> gt()
run_id sites clock_bins maximum_absolute_sum_site_deviation_eta root_mean_square_sum_site_deviation_eta numerical_tolerance sum_to_zero_constraint_verified coefficient_rank coefficients full_coefficient_rank diagnostic_definition
main__glasses__all_available 9 48 2.567391e-15 6.401067e-16 1e-08 TRUE 2333 2333 TRUE sum of fitted sz site-deviation contributions across all factor levels at each of the 48 equal-clock bins
main__chest__all_available 8 48 1.165734e-15 5.128540e-16 1e-08 TRUE 2537 2537 TRUE sum of fitted sz site-deviation contributions across all factor levels at each of the 48 equal-clock bins
cluster_summary |> head()
# A tibble: 6 × 12
  run_id     stage pair_definition cluster_level clusters clusters_with_estima…¹
  <chr>      <chr> <chr>           <chr>            <int>                  <int>
1 main__che… fina… lag-1 30-minut… AR_sequence       1464                   1407
2 main__che… fina… lag-1 30-minut… participant        154                    154
3 main__che… fina… lag-1 30-minut… participant_…      902                    902
4 main__gla… fina… lag-1 30-minut… AR_sequence       1314                   1265
5 main__gla… fina… lag-1 30-minut… participant        141                    141
6 main__gla… fina… lag-1 30-minut… participant_…      816                    816
# ℹ abbreviated name: ¹​clusters_with_estimable_correlation
# ℹ 6 more variables: eligible_pairs <int>, median_lag1_correlation <dbl>,
#   q05_lag1_correlation <dbl>, q25_lag1_correlation <dbl>,
#   q75_lag1_correlation <dbl>, q95_lag1_correlation <dbl>

Sensitivity to a non-cyclic common daily curve

Refit the common curve using a thin-plate spline while retaining the other model components. This is a diagnostic comparison of residual behavior and midnight continuity, not a replacement of the primary model.

runs <- tibble::tribble(
  ~base_run_id, ~placement, ~placement_label,
  "main__glasses__all_available", "glasses", "Near eye",
  "main__chest__all_available", "chest", "Chest"
) |>
  dplyr::mutate(
    sensitivity_run_id = paste0(
      .data$base_run_id,
      "__global_tp_diagnostic"
    )
  )

global_tp_formula <- stats::as.formula(paste(
  "response ~",
  "s(time_hour, bs = 'tp', k = 12) +",
  "s(time_hour, site, bs = 'sz', k = 12) +",
  "s(time_hour, participant, bs = 'fs', k = 10) +",
  "s(participant_day, bs = 're')"
))

model_fit_summary <- readr::read_csv(
  file.path(paths$tables, "H02", "model_fit_summary.csv"),
  show_col_types = FALSE
)

model_tables <- list()
metric_tables <- list()
acf_tables <- list()
cluster_tables <- list()
midnight_tables <- list()
runtime_tables <- list()

for (i in seq_len(nrow(runs))) {
  run <- runs[i, ]
  message(
    "Fitting non-cyclic global-time diagnostic ",
    i,
    "/",
    nrow(runs),
    ": ",
    run$placement_label
  )
  frame_path <- file.path(
    paths$model_data,
    "H02",
    paste0(run$base_run_id, ".rds")
  )
  primary_model_path <- file.path(
    paths$models,
    "H02",
    paste0(run$base_run_id, "__selected_model.rds")
  )
  if (!file.exists(frame_path) || !file.exists(primary_model_path)) {
    h02_abort("Missing model frame or fitted model for %s", run$base_run_id)
  }
  frame <- readRDS(frame_path)
  data <- h02_prepare_fit_data(frame)
  primary <- readRDS(primary_model_path)
  if (stats::nobs(primary) != nrow(data)) {
    h02_abort("Fitted model/frame row mismatch for %s", run$base_run_id)
  }
  primary_row <- model_fit_summary |>
    dplyr::filter(
      .data$run_id == run$base_run_id,
      .data$model_id == "site_pattern"
    )
  if (nrow(primary_row) != 1L) {
    h02_abort("Expected one fitted model-summary row for %s", run$base_run_id)
  }
  primary_rho <- primary_row$rho[[1L]]

  preliminary_time <- system.time({
    preliminary <- h02_fit_bam(
      global_tp_formula,
      data,
      method = "fREML",
      rho = 0
    )
  })
  alternative_rho <- h02_estimate_rho(preliminary, data)
  final_time <- system.time({
    alternative <- h02_fit_bam(
      global_tp_formula,
      data,
      method = "fREML",
      rho = alternative_rho
    )
  })
  if (stats::nobs(alternative) != nrow(data)) {
    h02_abort("Alternative model/frame row mismatch for %s", run$base_run_id)
  }

  primary_model_row <- h02_model_row(
    primary,
    "cyclic_global_primary",
    run$base_run_id,
    primary_rho
  )
  alternative_model_row <- h02_model_row(
    alternative,
    "noncyclic_global_tp_diagnostic",
    run$base_run_id,
    alternative_rho
  )
  model_tables[[run$base_run_id]] <- dplyr::bind_rows(
    primary_model_row,
    alternative_model_row
  ) |>
    dplyr::mutate(
      placement = run$placement,
      formula = c(
        paste(deparse(stats::formula(primary)), collapse = " "),
        paste(deparse(global_tp_formula), collapse = " ")
      ),
      global_basis = c("cc", "tp"),
      diagnostic_role = c(
        "primary model reference",
        "residual-diagnostic sensitivity only"
      ),
      .after = "run_id"
    )

  metric_tables[[run$base_run_id]] <- dplyr::bind_rows(
    h02_sensitivity_residual_metrics(
      primary,
      data,
      run$base_run_id,
      run$placement,
      "cyclic_global_primary",
      primary_rho
    ),
    h02_sensitivity_residual_metrics(
      alternative,
      data,
      run$base_run_id,
      run$placement,
      "noncyclic_global_tp_diagnostic",
      alternative_rho
    )
  )
  acf_tables[[run$base_run_id]] <- dplyr::bind_rows(
    h02_residual_acf(
      primary,
      data,
      "cyclic_global_primary",
      run$base_run_id
    ),
    h02_residual_acf(
      alternative,
      data,
      "noncyclic_global_tp_diagnostic",
      run$base_run_id
    )
  ) |>
    dplyr::rename(model_variant = "stage") |>
    dplyr::mutate(placement = run$placement, .after = "run_id")
  cluster_tables[[run$base_run_id]] <- dplyr::bind_rows(
    h02_sensitivity_cluster_residual_summary(
      primary,
      data,
      run$base_run_id,
      run$placement,
      "cyclic_global_primary"
    ),
    h02_sensitivity_cluster_residual_summary(
      alternative,
      data,
      run$base_run_id,
      run$placement,
      "noncyclic_global_tp_diagnostic"
    )
  )
  midnight_tables[[run$base_run_id]] <- dplyr::bind_rows(
    h02_sensitivity_global_midnight_diagnostic(
      primary,
      data,
      run$base_run_id,
      run$placement,
      "cyclic_global_primary",
      TRUE
    ),
    h02_sensitivity_global_midnight_diagnostic(
      alternative,
      data,
      run$base_run_id,
      run$placement,
      "noncyclic_global_tp_diagnostic",
      FALSE
    )
  )
  runtime_tables[[run$base_run_id]] <- tibble::tibble(
    base_run_id = run$base_run_id,
    placement = run$placement,
    sensitivity_run_id = run$sensitivity_run_id,
    preliminary_elapsed_seconds = unname(preliminary_time["elapsed"]),
    final_elapsed_seconds = unname(final_time["elapsed"]),
    total_fit_elapsed_seconds =
      unname(preliminary_time["elapsed"] + final_time["elapsed"]),
    bootstrap_replicates = 0L,
    inferential_role = "diagnostic-only model-form sensitivity"
  )


  alternative_model_path <- file.path(
    paths$models,
    "H02",
    paste0(run$sensitivity_run_id, "__model.rds")
  )
  write_h02_rds(
    alternative,
    alternative_model_path,
    paste0(run$sensitivity_run_id, "__model"),
    metadata = list(
      base_run_id = run$base_run_id,
      placement = run$placement,
      global_basis = "tp",
      analytical_role = "residual-diagnostic sensitivity only",
      rho = alternative_rho,
      participants = dplyr::n_distinct(data$participant),
      participant_days = dplyr::n_distinct(data$participant_day),
      observations = nrow(data),
      sites = dplyr::n_distinct(data$site)
    )
  )
  rm(frame, data, primary, preliminary, alternative)
  invisible(gc())
}
Fitting non-cyclic global-time diagnostic 1/2: Near eye
Fitting non-cyclic global-time diagnostic 2/2: Chest
model_comparison <- dplyr::bind_rows(model_tables)
residual_metric_table <- dplyr::bind_rows(metric_tables)
residual_acf_table <- dplyr::bind_rows(acf_tables)
cluster_table <- dplyr::bind_rows(cluster_tables)
midnight_table <- dplyr::bind_rows(midnight_tables)
runtime_table <- dplyr::bind_rows(runtime_tables)

tables <- list(
  global_time_basis_model_comparison = list(
    data = model_comparison,
    path = file.path(
      paths$tables,
      "H02",
      "global_time_basis_model_comparison.csv"
    )
  ),
  global_time_basis_residual_metrics = list(
    data = residual_metric_table,
    path = file.path(
      paths$diagnostics,
      "H02",
      "global_time_basis_residual_metrics.csv"
    )
  ),
  global_time_basis_residual_acf = list(
    data = residual_acf_table,
    path = file.path(
      paths$diagnostics,
      "H02",
      "global_time_basis_residual_acf.csv"
    )
  ),
  global_time_basis_cluster_residual_acf = list(
    data = cluster_table,
    path = file.path(
      paths$diagnostics,
      "H02",
      "global_time_basis_cluster_residual_acf.csv"
    )
  ),
  global_time_basis_midnight_continuity = list(
    data = midnight_table,
    path = file.path(
      paths$diagnostics,
      "H02",
      "global_time_basis_midnight_continuity.csv"
    )
  ),
  global_time_basis_runtime = list(
    data = runtime_table,
    path = file.path(
      paths$diagnostics,
      "H02",
      "global_time_basis_runtime.csv"
    )
  )
)
for (id in names(tables)) {
  write_h02_csv(tables[[id]]$data, tables[[id]]$path, id)
}
model_comparison |> gt()
run_id placement formula global_basis diagnostic_role model_id method n log_likelihood likelihood_df AIC total_edf rank coefficients scale rho convergence warnings
main__glasses__all_available glasses response ~ s(time_hour, bs = "cc", k = 12) + s(time_hour, site, bs = "sz", k = 12) + s(time_hour, participant, bs = "fs", k = 10) + s(participant_day, bs = "re") cc primary model reference cyclic_global_primary fREML 37756 -31265.84 1143.978 64819.64 1135.400 2333 2333 0.5075438 0.6226946 TRUE
main__glasses__all_available glasses response ~ s(time_hour, bs = "tp", k = 12) + s(time_hour, site, bs = "sz", k = 12) + s(time_hour, participant, bs = "fs", k = 10) + s(participant_day, bs = "re") tp residual-diagnostic sensitivity only noncyclic_global_tp_diagnostic fREML 37756 -31267.58 1145.233 64825.63 1136.534 2334 2334 0.5075718 0.6226600 TRUE
main__chest__all_available chest response ~ s(time_hour, bs = "cc", k = 12) + s(time_hour, site, bs = "sz", k = 12) + s(time_hour, participant, bs = "fs", k = 10) + s(participant_day, bs = "re") cc primary model reference cyclic_global_primary fREML 41842 -39436.13 1160.818 81193.89 1152.291 2537 2537 0.6170497 0.6065245 TRUE
main__chest__all_available chest response ~ s(time_hour, bs = "tp", k = 12) + s(time_hour, site, bs = "sz", k = 12) + s(time_hour, participant, bs = "fs", k = 10) + s(participant_day, bs = "re") tp residual-diagnostic sensitivity only noncyclic_global_tp_diagnostic fREML 41842 -39437.55 1161.767 81198.63 1153.224 2538 2538 0.6170481 0.6064741 TRUE

Allocate represented variation among model components

Fit the complete set of subset models and calculate the exact conditional Shapley allocation. The common daily curve is mandatory; the allocation compares site, participant, and participant-day components. Hierarchical bootstrap intervals condition on the fitted predictions and use 2,000 replicates in a full render.

bootstrap_replicates <- bootstrap_count(2000L)
execution_mode <- if (is.null(getOption("nh.bootstrap_replicates"))) "full" else "quick"
execution_id <- paste0("dominance_", bootstrap_replicates, "rep")
output_directories <- list(diagnostics = file.path(paths$diagnostics, "H02"),
                           tables = file.path(paths$tables, "H02"),
                           source_data = file.path(paths$source_data, "H02"))
registry <- h02_dominance_registry()
design <- h02_dominance_design()
component_names <- names(h02_dominance_terms())
dominance_map <- h02_dominance_map(design, component_names)
full_mask <- max(design$mask)
expected_full_formula <- formula_text(
  h02_formula_set()$site_pattern
)
observed_design_formula <- design$formula_text[
  design$mask == full_mask
]
if (!identical(observed_design_formula, expected_full_formula)) {
  h02_abort(
    "Dominance full formula does not equal the selected H02 formula"
  )
}

run_inputs <- list()
subset_models <- list()
marginal_contributions <- list()
conditional_summaries <- list()
allocation_summaries <- list()
comparison_summaries <- list()
execution_started_elapsed <- proc.time()[["elapsed"]]

component_definitions <- c(
  common_time = paste(
    "In-sample R2 of the mandatory common cyclic time-of-day curve",
    "relative to the row-weighted response mean"
  ),
  site_pattern = paste(
    "Exact conditional Shapley allocation to the sum-to-zero",
    "site-pattern block beyond the common time curve"
  ),
  participant_pattern = paste(
    "Exact conditional Shapley allocation to the participant",
    "factor-smooth block beyond the common time curve"
  ),
  participant_day = paste(
    "Exact conditional Shapley allocation to the participant-day",
    "random-intercept block beyond the common time curve"
  )
)
inferential_role_text <- paste("descriptive allocation of in-sample model fit;",
                                "not a confirmatory test or predictive validation")
for (i in seq_len(nrow(registry))) {
  run <- registry[i, ]
  run_id <- run$run_id
  run_started_elapsed <- proc.time()[["elapsed"]]
  message(
    "Running H02 dominance analysis ",
    i,
    "/",
    nrow(registry),
    ": ",
    run_id
  )
  frame_path <- file.path(
    paths$model_data,
    "H02",
    paste0(run_id, ".rds")
  )
  selected_model_path <- file.path(
    paths$models,
    "H02",
    paste0(run_id, "__selected_model.rds")
  )
  if (!file.exists(frame_path) || !file.exists(selected_model_path)) {
    h02_abort(
      "Missing H02 dominance input for %s; execute the preceding sample-construction and model-fitting cells first",
      run_id
    )
  }
  frame <- readRDS(frame_path)
  data <- h02_prepare_fit_data(frame)
  full_model <- readRDS(selected_model_path)
  observed_full_formula <- formula_text(stats::formula(full_model))
  if (!identical(observed_full_formula, expected_full_formula)) {
    h02_abort(
      "Selected model for %s does not use the specified H02 sz formula",
      run_id
    )
  }
  if (
    stats::nobs(full_model) != nrow(data) ||
      length(full_model$y) != nrow(data) ||
      !isTRUE(all.equal(
        as.numeric(full_model$y),
        data$response,
        tolerance = 1e-12,
        check.attributes = FALSE
      ))
  ) {
    h02_abort(
      "Selected model for %s is not aligned to its H02 model frame",
      run_id
    )
  }
  rho <- full_model$AR1.rho
  if (length(rho) != 1L || !is.finite(rho) || abs(rho) > 0.95) {
    h02_abort("Selected model for %s has an invalid AR(1) rho", run_id)
  }

  predictions <- matrix(
    NA_real_,
    nrow = nrow(data),
    ncol = nrow(design),
    dimnames = list(NULL, design$subset_id)
  )
  run_model_rows <- vector("list", nrow(design))
  subset_fit_started_elapsed <- proc.time()[["elapsed"]]
  for (j in seq_len(nrow(design))) {
    subset_id <- design$subset_id[j]
    message("  fitting dominance subset: ", subset_id)
    fit <- if (design$mask[j] == full_mask) {
      full_model
    } else {
      h02_fit_bam(
        design$formula[[j]],
        data,
        method = "fREML",
        rho = rho
      )
    }
    fitted_values <- as.numeric(stats::fitted(fit))
    if (
      length(fitted_values) != nrow(data) ||
        any(!is.finite(fitted_values))
    ) {
      h02_abort(
        "Dominance subset %s for %s returned invalid fitted values",
        subset_id,
        run_id
      )
    }
    predictions[, j] <- fitted_values
    run_model_rows[[j]] <- h02_model_row(
      fit,
      paste0("dominance__", subset_id),
      run_id,
      rho
    ) |>
      dplyr::mutate(
        placement = run$placement,
        analytical_role = run$analytical_role,
        mask = design$mask[j],
        subset_size = design$subset_size[j],
        subset_id = subset_id,
        included_components = design$included_components[j],
        formula = design$formula_text[j],
        reused_selected_full_model = design$mask[j] == full_mask,
        hierarchy_rule = paste(
          "The common cyclic time curve is present in every subset;",
          "Shapley permutations apply only to site, participant, and",
          "participant-day heterogeneity blocks."
        ),
        .before = 1L
      )
    if (design$mask[j] != full_mask) {
      rm(fit)
      invisible(gc())
    }
  }
  subset_fit_elapsed_seconds <-
    proc.time()[["elapsed"]] - subset_fit_started_elapsed

  values <- h02_dominance_values(
    data$response,
    predictions,
    design
  )
  decomposition <- h02_dominance_decomposition(
    values,
    design,
    dominance_map
  )
  bootstrap_seed <- h02_seed(run_id, 50L)
  bootstrap_started_elapsed <- proc.time()[["elapsed"]]
  bootstrap <- h02_bootstrap_dominance(
    response = data$response,
    predictions = predictions,
    data = data,
    design = design,
    dominance_map = dominance_map,
    replicates = bootstrap_replicates,
    seed = bootstrap_seed
  )
  bootstrap_elapsed_seconds <-
    proc.time()[["elapsed"]] - bootstrap_started_elapsed
  bootstrap_failed_replicates <- sum(
    !apply(is.finite(bootstrap$allocated_R2), 1L, all) |
      !apply(is.finite(bootstrap$share_full), 1L, all) |
      !apply(is.finite(bootstrap$comparisons), 1L, all)
  )
  gc_profile <- gc()
  maximum_r_heap_megabytes <- sum(gc_profile[, ncol(gc_profile)])
  run_elapsed_seconds <- proc.time()[["elapsed"]] - run_started_elapsed

  sample_metadata <- list(
    run_id = run_id,
    placement = run$placement,
    participants = dplyr::n_distinct(data$participant),
    participant_days = dplyr::n_distinct(data$participant_day),
    observations_30_minute = nrow(data),
    sites = dplyr::n_distinct(data$site),
    rho = rho,
    execution_mode = execution_mode,
    execution_id = execution_id,
    subset_fit_elapsed_seconds = subset_fit_elapsed_seconds,
    bootstrap_elapsed_seconds = bootstrap_elapsed_seconds,
    run_elapsed_seconds = run_elapsed_seconds,
    maximum_r_heap_megabytes = maximum_r_heap_megabytes,
    bootstrap_replicates = bootstrap_replicates,
    bootstrap_failed_replicates = bootstrap_failed_replicates
  )
  run_inputs[[run_id]] <- tibble::tibble(
    run_id = run_id,
    placement = run$placement,
    analytical_role = run$analytical_role,
    model_frame_path = relative_to_root(frame_path),
    selected_model_path = relative_to_root(selected_model_path),
    participants = sample_metadata$participants,
    participant_days = sample_metadata$participant_days,
    observations_30_minute = sample_metadata$observations_30_minute,
    sites = sample_metadata$sites,
    rho = rho,
    execution_mode = execution_mode,
    execution_id = execution_id,
    subset_fit_elapsed_seconds = subset_fit_elapsed_seconds,
    bootstrap_elapsed_seconds = bootstrap_elapsed_seconds,
    run_elapsed_seconds = run_elapsed_seconds,
    maximum_r_heap_megabytes = maximum_r_heap_megabytes,
    bootstrap_replicates = bootstrap_replicates,
    bootstrap_failed_replicates = bootstrap_failed_replicates,
    selected_formula = observed_full_formula,
    subset_models = nrow(design),
    outcome_scale = "log10(melEDI + 0.1 lx)",
    R2_definition = paste(
      "1 minus row-weighted SSE divided by total sum of squares around",
      "the fitted-sample arithmetic response mean"
    ),
    hierarchy_rule = paste(
      "Common cyclic time is mandatory; exact Shapley/general dominance",
      "is calculated over all subsets and all six orderings of the three",
      "heterogeneity blocks."
    ),
    interval_scope = paste(
      "conditional 95% cluster-bootstrap interval from fixed predictions;",
      "subset models are not refitted within resamples"
    ),
    multiplicity_family = NA_character_,
    inferential_role = inferential_role_text,
    R_version = as.character(getRversion()),
    mgcv_version = as.character(utils::packageVersion("mgcv"))
  )

  subset_models[[run_id]] <- dplyr::bind_rows(run_model_rows) |>
    dplyr::mutate(
      squared_error = unname(values$squared_error[
        match(.data$mask, design$mask)
      ]),
      total_sum_squares = values$total_sum_squares,
      in_sample_R2 = unname(values$r_squared[
        match(.data$mask, design$mask)
      ])
    )
  marginal_contributions[[run_id]] <- decomposition$marginal |>
    dplyr::mutate(
      run_id = run_id,
      placement = run$placement,
      analytical_role = run$analytical_role,
      .before = 1L
    )
  conditional_summaries[[run_id]] <- decomposition$conditional |>
    dplyr::mutate(
      run_id = run_id,
      placement = run$placement,
      analytical_role = run$analytical_role,
      .before = 1L
    )
  allocation_summaries[[run_id]] <-
    h02_dominance_interval_summary(
      decomposition$allocation,
      bootstrap,
      run_id
    ) |>
    dplyr::mutate(
      placement = run$placement,
      analytical_role = run$analytical_role,
      definition = unname(
        component_definitions[.data$component]
      ),
      participants = sample_metadata$participants,
      participant_days = sample_metadata$participant_days,
      observations_30_minute = sample_metadata$observations_30_minute,
      sites = sample_metadata$sites,
      outcome_scale = "log10(melEDI + 0.1 lx)",
      multiplicity_family = NA_character_,
      execution_mode = execution_mode,
      execution_id = execution_id,
      inferential_role = inferential_role_text,
      .after = "run_id"
    )
  comparison_summaries[[run_id]] <-
    h02_dominance_comparison_interval_summary(
      decomposition$comparisons,
      bootstrap,
      run_id
    ) |>
    dplyr::mutate(
      placement = run$placement,
      analytical_role = run$analytical_role,
      participants = sample_metadata$participants,
      participant_days = sample_metadata$participant_days,
      observations_30_minute = sample_metadata$observations_30_minute,
      sites = sample_metadata$sites,
      outcome_scale = "log10(melEDI + 0.1 lx)",
      multiplicity_family = NA_character_,
      execution_mode = execution_mode,
      execution_id = execution_id,
      inferential_role = inferential_role_text,
      .after = "run_id"
    )

  prediction_artifact <- list(
    run_id = run_id,
    placement = run$placement,
    response = data$response,
    row_keys = data |>
      dplyr::transmute(
        site = as.character(.data$site),
        participant = as.character(.data$participant),
        participant_day = as.character(.data$participant_day),
        local_date = .data$local_date,
        clock_bin = .data$clock_bin,
        true_elapsed_sequence_id = .data$true_elapsed_sequence_id,
        AR_start = .data$AR_start
      ),
    design = design |>
      dplyr::select(-"formula"),
    predictions = predictions,
    point_values = values
  )
  write_h02_rds(
    prediction_artifact,
    file.path(
      output_directories$source_data,
      paste0(run_id, "__dominance_predictions.rds")
    ),
    paste0(run_id, "__dominance_predictions"),
    metadata = sample_metadata
  )
  write_h02_rds(
    bootstrap,
    file.path(
      output_directories$diagnostics,
      paste0(run_id, "__dominance_bootstrap.rds")
    ),
    paste0(run_id, "__dominance_bootstrap"),
    metadata = c(
      sample_metadata,
      list(
        bootstrap_seed = bootstrap$seed
      )
    )
  )
  rm(
    frame,
    data,
    full_model,
    predictions,
    values,
    decomposition,
    bootstrap,
    prediction_artifact
  )
  invisible(gc())
}
Running H02 dominance analysis 1/2: main__glasses__all_available
  fitting dominance subset: common_time_only
  fitting dominance subset: common_time+site_pattern
  fitting dominance subset: common_time+participant_pattern
  fitting dominance subset: common_time+site_pattern+participant_pattern
  fitting dominance subset: common_time+participant_day
  fitting dominance subset: common_time+site_pattern+participant_day
  fitting dominance subset: common_time+participant_pattern+participant_day
  fitting dominance subset: common_time+site_pattern+participant_pattern+participant_day
Running H02 dominance analysis 2/2: main__chest__all_available
  fitting dominance subset: common_time_only
  fitting dominance subset: common_time+site_pattern
  fitting dominance subset: common_time+participant_pattern
  fitting dominance subset: common_time+site_pattern+participant_pattern
  fitting dominance subset: common_time+participant_day
  fitting dominance subset: common_time+site_pattern+participant_day
  fitting dominance subset: common_time+participant_pattern+participant_day
  fitting dominance subset: common_time+site_pattern+participant_pattern+participant_day
execution_summary <- bind_rows(run_inputs) |>
  select(run_id, placement, bootstrap_replicates, bootstrap_failed_replicates,
         subset_fit_elapsed_seconds, bootstrap_elapsed_seconds, run_elapsed_seconds)
tables <- list(
  dominance_execution_summary = execution_summary,
  dominance_run_inputs = dplyr::bind_rows(run_inputs),
  dominance_subset_models = dplyr::bind_rows(subset_models),
  dominance_marginal_contributions = dplyr::bind_rows(marginal_contributions),
  dominance_conditional_summary = dplyr::bind_rows(conditional_summaries),
  dominance_summary = dplyr::bind_rows(allocation_summaries),
  dominance_comparison_summary = dplyr::bind_rows(comparison_summaries)
)
table_locations <- c(
  dominance_execution_summary = file.path(
    output_directories$tables,
    "dominance_execution_summary.csv"
  ),
  dominance_run_inputs = file.path(
    output_directories$tables,
    "dominance_run_inputs.csv"
  ),
  dominance_subset_models = file.path(
    output_directories$tables,
    "dominance_subset_models.csv"
  ),
  dominance_marginal_contributions = file.path(
    output_directories$tables,
    "dominance_marginal_contributions.csv"
  ),
  dominance_conditional_summary = file.path(
    output_directories$tables,
    "dominance_conditional_summary.csv"
  ),
  dominance_summary = file.path(
    output_directories$tables,
    "dominance_summary.csv"
  ),
  dominance_comparison_summary = file.path(
    output_directories$tables,
    "dominance_comparison_summary.csv"
  )
)
for (name in names(tables)) {
  write_h02_csv(
    tables[[name]],
    table_locations[[name]],
    paste0("table__", name)
  )
}
bind_rows(allocation_summaries) |> gt()
run_id placement analytical_role definition participants participant_days observations_30_minute sites outcome_scale multiplicity_family execution_mode execution_id inferential_role component allocated_R2 allocated_R2_lower_95 allocated_R2_upper_95 share_of_full_model_R2 share_of_full_model_R2_lower_95 share_of_full_model_R2_upper_95 share_of_increment_beyond_common share_of_increment_beyond_common_lower_95 share_of_increment_beyond_common_upper_95 common_time_R2 common_time_R2_lower_95 common_time_R2_upper_95 heterogeneity_increment_R2 heterogeneity_increment_R2_lower_95 heterogeneity_increment_R2_upper_95 full_model_R2 full_model_R2_lower_95 full_model_R2_upper_95 allocation_method general_dominance_rank finite_bootstrap_replicates_allocated_R2 finite_bootstrap_replicates_share_full finite_bootstrap_replicates_share_increment interval_method bootstrap_seed bootstrap_replicates
main__glasses__all_available glasses primary_near_eye In-sample R2 of the mandatory common cyclic time-of-day curve relative to the row-weighted response mean 141 816 37756 9 log10(melEDI + 0.1 lx) NA full dominance_2000rep descriptive allocation of in-sample model fit; not a confirmatory test or predictive validation common_time 0.61132661 0.550309840 0.65662953 0.78854439 0.736310839 0.82584595 NA NA NA 0.6113266 0.5503098 0.6566295 0.1639330 0.1365835 0.2004939 0.7752596 0.7393936 0.8039016 mandatory common-time baseline fitted before heterogeneity blocks NA 2000 2000 NA 95% percentile hierarchical cluster bootstrap of fixed predictions from all eight fitted subset models; sites, participants within sites, and participant-days within participants; conditional on the fitted subset models; 2000 replicates 20302631 2000
main__glasses__all_available glasses primary_near_eye Exact conditional Shapley allocation to the sum-to-zero site-pattern block beyond the common time curve 141 816 37756 9 log10(melEDI + 0.1 lx) NA full dominance_2000rep descriptive allocation of in-sample model fit; not a confirmatory test or predictive validation site_pattern 0.01562089 0.006860956 0.02640732 0.02014923 0.008724129 0.03533711 0.09528825 0.04384100 0.1477862 0.6113266 0.5503098 0.6566295 0.1639330 0.1365835 0.2004939 0.7752596 0.7393936 0.8039016 exact conditional Shapley/general dominance beyond common time 3 2000 2000 2000 95% percentile hierarchical cluster bootstrap of fixed predictions from all eight fitted subset models; sites, participants within sites, and participant-days within participants; conditional on the fitted subset models; 2000 replicates 20302631 2000
main__glasses__all_available glasses primary_near_eye Exact conditional Shapley allocation to the participant factor-smooth block beyond the common time curve 141 816 37756 9 log10(melEDI + 0.1 lx) NA full dominance_2000rep descriptive allocation of in-sample model fit; not a confirmatory test or predictive validation participant_pattern 0.10013609 0.079328241 0.12517935 0.12916459 0.101431842 0.16365767 0.61083548 0.53245997 0.6983248 0.6113266 0.5503098 0.6566295 0.1639330 0.1365835 0.2004939 0.7752596 0.7393936 0.8039016 exact conditional Shapley/general dominance beyond common time 1 2000 2000 2000 95% percentile hierarchical cluster bootstrap of fixed predictions from all eight fitted subset models; sites, participants within sites, and participant-days within participants; conditional on the fitted subset models; 2000 replicates 20302631 2000
main__glasses__all_available glasses primary_near_eye Exact conditional Shapley allocation to the participant-day random-intercept block beyond the common time curve 141 816 37756 9 log10(melEDI + 0.1 lx) NA full dominance_2000rep descriptive allocation of in-sample model fit; not a confirmatory test or predictive validation participant_day 0.04817602 0.035177071 0.06425943 0.06214179 0.044404006 0.08566087 0.29387627 0.22327475 0.3747797 0.6113266 0.5503098 0.6566295 0.1639330 0.1365835 0.2004939 0.7752596 0.7393936 0.8039016 exact conditional Shapley/general dominance beyond common time 2 2000 2000 2000 95% percentile hierarchical cluster bootstrap of fixed predictions from all eight fitted subset models; sites, participants within sites, and participant-days within participants; conditional on the fitted subset models; 2000 replicates 20302631 2000
main__chest__all_available chest complementary_chest In-sample R2 of the mandatory common cyclic time-of-day curve relative to the row-weighted response mean 154 902 41842 8 log10(melEDI + 0.1 lx) NA full dominance_2000rep descriptive allocation of in-sample model fit; not a confirmatory test or predictive validation common_time 0.58271050 0.523460573 0.62856765 0.79077248 0.736181020 0.82824444 NA NA NA 0.5827105 0.5234606 0.6285676 0.1541772 0.1297875 0.1894268 0.7368877 0.7034493 0.7663214 mandatory common-time baseline fitted before heterogeneity blocks NA 2000 2000 NA 95% percentile hierarchical cluster bootstrap of fixed predictions from all eight fitted subset models; sites, participants within sites, and participant-days within participants; conditional on the fitted subset models; 2000 replicates 20296857 2000
main__chest__all_available chest complementary_chest Exact conditional Shapley allocation to the sum-to-zero site-pattern block beyond the common time curve 154 902 41842 8 log10(melEDI + 0.1 lx) NA full dominance_2000rep descriptive allocation of in-sample model fit; not a confirmatory test or predictive validation site_pattern 0.01538232 0.008181033 0.02373792 0.02087471 0.011012231 0.03274129 0.09977041 0.05547126 0.1427407 0.5827105 0.5234606 0.6285676 0.1541772 0.1297875 0.1894268 0.7368877 0.7034493 0.7663214 exact conditional Shapley/general dominance beyond common time 3 2000 2000 2000 95% percentile hierarchical cluster bootstrap of fixed predictions from all eight fitted subset models; sites, participants within sites, and participant-days within participants; conditional on the fitted subset models; 2000 replicates 20296857 2000
main__chest__all_available chest complementary_chest Exact conditional Shapley allocation to the participant factor-smooth block beyond the common time curve 154 902 41842 8 log10(melEDI + 0.1 lx) NA full dominance_2000rep descriptive allocation of in-sample model fit; not a confirmatory test or predictive validation participant_pattern 0.08747262 0.070972842 0.11049798 0.11870550 0.094608646 0.15264011 0.56735128 0.50032502 0.6339806 0.5827105 0.5234606 0.6285676 0.1541772 0.1297875 0.1894268 0.7368877 0.7034493 0.7663214 exact conditional Shapley/general dominance beyond common time 1 2000 2000 2000 95% percentile hierarchical cluster bootstrap of fixed predictions from all eight fitted subset models; sites, participants within sites, and participant-days within participants; conditional on the fitted subset models; 2000 replicates 20296857 2000
main__chest__all_available chest complementary_chest Exact conditional Shapley allocation to the participant-day random-intercept block beyond the common time curve 154 902 41842 8 log10(melEDI + 0.1 lx) NA full dominance_2000rep descriptive allocation of in-sample model fit; not a confirmatory test or predictive validation participant_day 0.05132224 0.039610926 0.06740598 0.06964730 0.052738590 0.09383182 0.33287831 0.27242711 0.4037635 0.5827105 0.5234606 0.6285676 0.1541772 0.1297875 0.1894268 0.7368877 0.7034493 0.7663214 exact conditional Shapley/general dominance beyond common time 2 2000 2000 2000 95% percentile hierarchical cluster bootstrap of fixed predictions from all eight fitted subset models; sites, participants within sites, and participant-days within participants; conditional on the fitted subset models; 2000 replicates 20296857 2000

Construct the daily-pattern figures

Calculate conditional pointwise intervals from the model covariance matrix, distinguish them from simultaneous intervals, and add civil-night context for the exact fitted participant-days. Both sensor positions use the same figure construction.

library(patchwork)
library(legendry)
library(ggtext)
source("scripts/Brown_bracket.R")
source("scripts/hypotheses/H02/h02_figure4_pointwise.R")
run_id <- "main__glasses__all_available"
chest_run_id <- "main__chest__all_available"
figure_directory <- file.path(paths$figures, "H02")
table_directory <- file.path(paths$tables, "H02")
source_directory <- file.path(paths$source_data, "H02")
site_registry <- readr::read_csv(file.path(root, "config/site_display_registry.csv"), show_col_types = FALSE) |> arrange(display_order)
frame_path <- file.path(
  paths$model_data,
  "H02",
  paste0(run_id, ".rds")
)
model_path <- file.path(
  paths$models,
  "H02",
  paste0(run_id, "__selected_model.rds")
)
prediction_path <- file.path(
  paths$source_data,
  "H02",
  "site_curve_predictions.csv"
)
contribution_path <- file.path(
  paths$source_data,
  "H02",
  paste0(run_id, "__fitted_contributions.rds")
)
base_path <- file.path(
  paths$model_data,
  "base",
  "metrics_glasses_30_minute_context.rds"
)
chest_frame_path <- file.path(
  paths$model_data,
  "H02",
  paste0(chest_run_id, ".rds")
)
chest_model_path <- file.path(
  paths$models,
  "H02",
  paste0(chest_run_id, "__selected_model.rds")
)
chest_contribution_path <- file.path(
  paths$source_data,
  "H02",
  paste0(chest_run_id, "__fitted_contributions.rds")
)
chest_base_path <- file.path(
  paths$model_data,
  "base",
  "metrics_chest_30_minute_context.rds"
)

near_eye_contract <- h02_build_figure_contract(
  run_id = run_id,
  placement_label = "Near-eye",
  frame_path = frame_path,
  model_path = model_path,
  contribution_path = contribution_path,
  base_path = base_path,
  prediction_path = prediction_path,
  site_registry = site_registry
)
chest_contract <- h02_build_figure_contract(
  run_id = chest_run_id,
  placement_label = "Chest",
  frame_path = chest_frame_path,
  model_path = chest_model_path,
  contribution_path = chest_contribution_path,
  base_path = chest_base_path,
  prediction_path = prediction_path,
  site_registry = site_registry
)
figure4 <- h02_build_figure4_layout(near_eye_contract)
figure4_chest <- h02_build_figure4_layout(chest_contract)
pointwise_windows <- h02_pointwise_windows(list(
  near_eye_contract,
  chest_contract
))


for (placement in c("near_eye", "chest")) {
  contract <- if (placement == "near_eye") near_eye_contract else chest_contract
  plot <- if (placement == "near_eye") figure4 else figure4_chest
  suffix <- if (placement == "near_eye") "" else "_chest"
  for (extension in c("png", "svg", "pdf")) {
    write_h02_plot(plot, file.path(figure_directory, paste0("daily_patterns", suffix, ".", extension)), width = 11, height = 14)
  }
  write_h02_rds(contract, file.path(source_directory, paste0("daily_patterns_source", suffix, ".rds")))
  for (name in names(contract)) {
    if (is.data.frame(contract[[name]])) {
      write_h02_csv(contract[[name]], file.path(source_directory, paste0("daily_patterns_", placement, "_", name, ".csv")))
    }
  }
}
write_h02_csv(pointwise_windows, file.path(table_directory, "figure4_pointwise_conditional_windows.csv"))
pointwise_windows |> head()
# A tibble: 6 × 15
  run_id    placement site  display_order display_name direction start_clock_bin
  <chr>     <chr>     <chr>         <dbl> <chr>        <chr>               <int>
1 main__ch… Chest     RISE              1 Borås (SE)   higher                360
2 main__ch… Chest     RISE              1 Borås (SE)   lower                1260
3 main__ch… Chest     THUAS             2 Delft (NL)   lower                 300
4 main__ch… Chest     THUAS             2 Delft (NL)   higher                960
5 main__ch… Chest     BAUA              3 Dortmund (D… higher                990
6 main__ch… Chest     TUM               5 Munich (DE)  higher               1050
# ℹ 8 more variables: end_clock_bin_exclusive <int>, start_local_clock <chr>,
#   end_local_clock <chr>, bins_30_minute <int>, minimum_point_ratio <dbl>,
#   maximum_point_ratio <dbl>, minimum_pointwise_lower <dbl>,
#   maximum_pointwise_upper <dbl>

Draw residual diagnostic figures

Display distributional diagnostics together with the within-sequence residual autocorrelation. Export the exact response, fitted values, residuals, and grouping fields behind the plots.

write_plot <- function(plot, path, width = 10.5, height = 9.5) {
  temporary <- tempfile(
    pattern = paste0(tools::file_path_sans_ext(basename(path)), "."),
    tmpdir = dirname(path),
    fileext = paste0(".", tools::file_ext(path))
  )
  on.exit(unlink(temporary), add = TRUE)
  ggplot2::ggsave(
    filename = temporary,
    plot = plot,
    width = width,
    height = height,
    units = "in",
    dpi = 300,
    bg = "white"
  )
  atomic_replace_artifact(temporary, path)
  invisible(path)
}

residual_acf <- readr::read_csv(
  file.path(root, "results", "csv/diagnostics", "H02", "residual_acf.csv"),
  show_col_types = FALSE
)

run_specification <- tibble::tribble(
  ~run_id, ~placement_label, ~file_stem,
  "main__glasses__all_available", "Near-eye", "near_eye",
  "main__chest__all_available", "Chest", "chest"
)

for (index in seq_len(nrow(run_specification))) {
  run_id <- run_specification$run_id[[index]]
  placement_label <- run_specification$placement_label[[index]]
  file_stem <- run_specification$file_stem[[index]]
  model_path <- file.path(
    root,
    "results", "models",
    "H02",
    paste0(run_id, "__selected_model.rds")
  )
  model <- readRDS(model_path)
  if (!inherits(model, "bam")) {
    stop(sprintf("Expected a bam object for %s", run_id), call. = FALSE)
  }
  if (length(model$std.rsd) != nrow(model$model)) {
    stop(sprintf("Residual/model-row mismatch for %s", run_id), call. = FALSE)
  }

  appraisal <- gratia::appraise(
    model,
    method = "normal",
    type = "pearson",
    ncol = 2,
    point_col = "grey25",
    point_alpha = 0.10,
    line_col = "#A61C3C"
  ) &
    ggplot2::theme(
      text = ggplot2::element_text(size = 12),
      plot.title = ggplot2::element_text(size = 14)
    )

  acf_data <- residual_acf |>
    dplyr::filter(.data$run_id == .env$run_id) |>
    dplyr::mutate(
      stage = factor(
        .data$stage,
        levels = c("preliminary_no_AR1", "final_AR1_standardized"),
        labels = c(
          "Before AR(1)",
          "After AR(1), standardized"
        )
      )
    )
  if (nrow(acf_data) != 12L) {
    stop(sprintf("Expected 12 residual-ACF rows for %s", run_id), call. = FALSE)
  }
  acf_plot <- ggplot2::ggplot(
    acf_data,
    ggplot2::aes(
      x = .data$lag_30_minute_bins,
      y = .data$correlation,
      colour = .data$stage,
      group = .data$stage
    )
  ) +
    ggplot2::geom_hline(yintercept = 0, colour = "grey70") +
    ggplot2::geom_line(linewidth = 1.0) +
    ggplot2::geom_point(size = 2.4) +
    ggplot2::scale_x_continuous(breaks = seq_len(6L)) +
    ggplot2::scale_colour_manual(
      values = c("Before AR(1)" = "#A61C3C", "After AR(1), standardized" = "#005A8D")
    ) +
    ggplot2::coord_cartesian(ylim = c(-0.06, 0.68)) +
    ggplot2::labs(
      x = "Lag (30-minute bins within verified sequences)",
      y = "Residual correlation",
      colour = NULL,
      title = "Remaining temporal dependence"
    ) +
    ggplot2::theme_minimal(base_size = 12) +
    ggplot2::theme(
      legend.position = "bottom",
      panel.grid.minor = ggplot2::element_blank()
    )

  diagnostic_figure <- (appraisal / acf_plot) +
    patchwork::plot_layout(heights = c(3.1, 1)) +
    patchwork::plot_annotation(
      title = paste(placement_label, "model diagnostics"),
      tag_levels = "A",
      theme = ggplot2::theme(
        plot.title = ggplot2::element_text(size = 17, face = "bold"),
        plot.tag = ggplot2::element_text(size = 15, face = "bold")
      )
    )

  png_path <- file.path(
    figure_directory,
    paste0("model_diagnostics_", file_stem, ".png")
  )
  pdf_path <- file.path(
    figure_directory,
    paste0("model_diagnostics_", file_stem, ".pdf")
  )
  write_plot(diagnostic_figure, png_path)
  write_plot(diagnostic_figure, pdf_path)

  plot_source <- tibble::tibble(
    run_id = run_id,
    response = model$model$response,
    linear_predictor = unname(model$linear.predictors),
    fitted_value = unname(model$fitted.values),
    pearson_residual = unname(stats::residuals(model, type = "pearson")),
    ar_standardized_residual = unname(model$std.rsd),
    site = as.character(model$model$site),
    participant = as.character(model$model$participant),
    participant_day = as.character(model$model$participant_day),
    time_hour = model$model$time_hour,
    ar_start = as.logical(model$model[["(AR.start)"]])
  )
  source_path <- file.path(
    source_directory,
    paste0("model_diagnostics_", file_stem, "_source.rds")
  )
  write_h02_csv(plot_source, sub("[.]rds$", ".csv", source_path))
  write_rds_artifact(
    plot_source,
    source_path,
    producer,
    list(
      run_id = run_id,
      residual_appraisal = "gratia::appraise(method = normal, type = pearson)",
      temporal_panel = "boundary-aware ACF from residual_acf.csv"
    )
  )

}
diagnostic_figure

Compare sensor positions on matched observations

Require identical participant, day, and clock-bin keys before combining the near-eye and chest curve estimates. This comparison uses the common sample and does not treat the two sensor positions as interchangeable.

near_id <- "main__glasses__paired_common_sample"
chest_id <- "main__chest__paired_common_sample"

near_frame_path <- file.path(paths$model_data, "H02", paste0(near_id, ".rds"))
chest_frame_path <- file.path(
  paths$model_data,
  "H02",
  paste0(chest_id, ".rds")
)
prediction_path <- file.path(
  paths$source_data,
  "H02",
  "site_curve_predictions.csv"
)
sample_path <- file.path(paths$model_data, "H02", "sample_counts.csv")
registry_path <- file.path(root, "config", "site_display_registry.csv")
output_path <- file.path(
  paths$source_data,
  "H02",
  "paired_placement_site_curves.csv"
)

required_inputs <- c(
  near_frame_path,
  chest_frame_path,
  prediction_path,
  sample_path,
  registry_path
)
if (any(!file.exists(required_inputs))) {
  h02_abort(
    "Missing H02 paired-placement input(s): %s",
    paste(required_inputs[!file.exists(required_inputs)], collapse = ", ")
  )
}

near_frame <- readRDS(near_frame_path)
chest_frame <- readRDS(chest_frame_path)
observation_key <- c(
  "participant_key",
  "participant_day_key",
  "site",
  "clock_bin",
  "time_hour"
)
if (
  nrow(near_frame) != nrow(chest_frame) ||
    !identical(near_frame[observation_key], chest_frame[observation_key])
) {
  h02_abort("Near-eye and chest common-sample observation keys do not match")
}

sample_counts <- readr::read_csv(sample_path, show_col_types = FALSE)
paired_counts <- sample_counts |>
  dplyr::filter(
    .data$run_id %in% c(.env$near_id, .env$chest_id),
    .data$site == "ALL_SITES"
  ) |>
  dplyr::arrange(match(.data$run_id, c(.env$near_id, .env$chest_id)))
count_columns <- c(
  "participants",
  "participant_days",
  "observations_30_minute",
  "sites"
)
if (
  nrow(paired_counts) != 2L ||
    any(vapply(
      count_columns,
      function(column) {
        length(unique(paired_counts[[column]])) != 1L
      },
      logical(1)
    )) ||
    paired_counts$observations_30_minute[[1L]] != nrow(near_frame)
) {
  h02_abort("Recorded H02 paired-placement sample counts do not match")
}
if (!identical(
  unname(as.integer(paired_counts[1L, count_columns])),
  c(112L, 643L, 29786L, 8L)
)) {
  h02_abort("Unexpected H02 paired-placement sample identity")
}

site_registry <- readr::read_csv(registry_path, show_col_types = FALSE) |>
  dplyr::arrange(.data$display_order)
predictions <- readr::read_csv(prediction_path, show_col_types = FALSE) |>
  dplyr::filter(.data$run_id %in% c(.env$near_id, .env$chest_id))
curve_key <- c("site", "clock_bin", "time_hour")
near_keys <- predictions |>
  dplyr::filter(.data$run_id == .env$near_id) |>
  dplyr::select(dplyr::all_of(curve_key))
chest_keys <- predictions |>
  dplyr::filter(.data$run_id == .env$chest_id) |>
  dplyr::select(dplyr::all_of(curve_key))
if (
  nrow(near_keys) != 384L ||
    !identical(near_keys, chest_keys) ||
    anyDuplicated(near_keys)
) {
  h02_abort("Stored paired-placement site-curve grids do not match")
}

critical <- stats::qnorm(0.975)
paired_sample <- paired_counts[1L, count_columns]
display_data <- predictions |>
  dplyr::mutate(
    placement = dplyr::recode(
      .data$run_id,
      !!near_id := "Near eye",
      !!chest_id := "Chest"
    ),
    pointwise_lower_eta = .data$eta - critical * .data$standard_error,
    pointwise_upper_eta = .data$eta + critical * .data$standard_error,
    pointwise_lower_melEDI_lx = h02_inverse_transform(
      .data$pointwise_lower_eta
    ),
    pointwise_upper_melEDI_lx = h02_inverse_transform(
      .data$pointwise_upper_eta
    )
  ) |>
  dplyr::left_join(
    site_registry,
    by = "site",
    relationship = "many-to-one"
  ) |>
  dplyr::mutate(
    paired_participants = paired_sample$participants,
    paired_participant_days = paired_sample$participant_days,
    paired_observations_30_minute = paired_sample$observations_30_minute,
    paired_sites = paired_sample$sites,
    placement_order = match(.data$placement, c("Near eye", "Chest"))
  ) |>
  dplyr::arrange(
    .data$display_order,
    .data$clock_bin,
    .data$placement_order
  ) |>
  dplyr::select(
    "run_id",
    "placement",
    "site",
    "display_name",
    "display_order",
    "color_hex",
    "clock_bin",
    "time_hour",
    "eta",
    "standard_error",
    "pointwise_lower_eta",
    "pointwise_upper_eta",
    "estimate_melEDI_lx",
    "pointwise_lower_melEDI_lx",
    "pointwise_upper_melEDI_lx",
    "paired_participants",
    "paired_participant_days",
    "paired_observations_30_minute",
    "paired_sites"
  )

if (
  nrow(display_data) != 768L ||
    anyNA(display_data[c("display_name", "display_order", "color_hex")]) ||
    any(display_data$pointwise_lower_eta > display_data$eta) ||
    any(display_data$pointwise_upper_eta < display_data$eta)
) {
  h02_abort("Invalid H02 paired-placement display data")
}

invisible(write_csv_artifact(display_data, output_path, producer))
display_data |> head()
# A tibble: 6 × 19
  run_id          placement site  display_name display_order color_hex clock_bin
  <chr>           <chr>     <chr> <chr>                <dbl> <chr>         <dbl>
1 main__glasses_… Near eye  RISE  Borås (SE)               1 #88CCEE           0
2 main__chest__p… Chest     RISE  Borås (SE)               1 #88CCEE           0
3 main__glasses_… Near eye  RISE  Borås (SE)               1 #88CCEE          30
4 main__chest__p… Chest     RISE  Borås (SE)               1 #88CCEE          30
5 main__glasses_… Near eye  RISE  Borås (SE)               1 #88CCEE          60
6 main__chest__p… Chest     RISE  Borås (SE)               1 #88CCEE          60
# ℹ 12 more variables: time_hour <dbl>, eta <dbl>, standard_error <dbl>,
#   pointwise_lower_eta <dbl>, pointwise_upper_eta <dbl>,
#   estimate_melEDI_lx <dbl>, pointwise_lower_melEDI_lx <dbl>,
#   pointwise_upper_melEDI_lx <dbl>, paired_participants <dbl>,
#   paired_participant_days <dbl>, paired_observations_30_minute <dbl>,
#   paired_sites <dbl>

Bind the displayed results to the estimates

The following extracts select the primary, complementary, and sensitivity estimates for the tables and accompanying interpretation.

source("scripts/hypotheses/H02/h02_reader_helpers.R")
suppressPackageStartupMessages({
  library(dplyr)
  library(ggplot2)
  library(gt)
  library(readr)
  library(tibble)
  library(tidyr)
})

root <- normalizePath(
  Sys.getenv("QUARTO_PROJECT_DIR", unset = getwd()),
  winslash = "/",
  mustWork = TRUE
)

source(file.path(root, "scripts", "pipeline", "p_value_display.R"))

site_registry <- read_h02_csv("config", "site_display_registry.csv") |>
  arrange(.data$display_order)

sample_counts <- read_h02_csv(
  "results", "intermediate/model_data", "H02", "sample_counts.csv"
)

support_audit <- read_h02_csv(
  "results", "intermediate/model_data", "H02", "support_audit.csv"
)

model_fits <- read_h02_csv(
  "results", "tables", "H02", "model_fit_summary.csv"
)

model_comparisons <- read_h02_csv(
  "results", "tables", "H02", "model_structure_comparisons.csv"
)

variation <- read_h02_csv(
  "results", "tables", "H02", "variation_summary.csv"
)

dominance <- read_h02_csv(
  "results", "tables", "H02", "dominance_summary.csv"
)

dominance_comparison <- read_h02_csv(
  "results", "tables", "H02", "dominance_comparison_summary.csv"
)

pointwise_windows <- read_h02_csv(
  "results", "tables", "H02",
  "figure4_pointwise_conditional_windows.csv"
)

residual_acf <- read_h02_csv(
  "results", "csv/diagnostics", "H02", "residual_acf.csv"
)

residual_summary <- read_h02_csv(
  "results", "csv/diagnostics", "H02", "residual_summary.csv"
)

cluster_acf <- read_h02_csv(
  "results", "csv/diagnostics", "H02",
  "cluster_residual_acf_summary.csv"
)

k_checks <- read_h02_csv(
  "results", "csv/diagnostics", "H02",
  "basis_dimension_checks.csv"
)

sz_checks <- read_h02_csv(
  "results", "csv/diagnostics", "H02",
  "sz_constraint_identifiability.csv"
)

concurvity <- read_h02_csv(
  "results", "csv/diagnostics", "H02", "formal_concurvity.csv"
)

preparation_sensitivity <- read_h02_csv(
  "results", "tables", "H02",
  "alternative_preprocessing_stability.csv"
)

formula_sensitivity <- read_h02_csv(
  "results", "tables", "H02",
  "formula_sensitivity_comparison.csv"
)

paired_placement_curves <- read_h02_csv(
  "results", "csv/source_data", "H02",
  "paired_placement_site_curves.csv"
)

near_id <- "main__glasses__all_available"

chest_id <- "main__chest__all_available"

paired_near_id <- "main__glasses__paired_common_sample"

paired_chest_id <- "main__chest__paired_common_sample"

alternative_preparation_id <-
  "alternative_preprocessing__glasses__all_available"

near_counts <- sample_overall(near_id)

chest_counts <- sample_overall(chest_id)

near_variation <- variation |>
  filter(.data$run_id == near_id)

chest_variation <- variation |>
  filter(.data$run_id == chest_id)

near_dominance <- dominance |>
  filter(.data$run_id == near_id)

chest_dominance <- dominance |>
  filter(.data$run_id == chest_id)

near_dominance_comparison <- dominance_comparison |>
  filter(.data$run_id == near_id)

chest_dominance_comparison <- dominance_comparison |>
  filter(.data$run_id == chest_id)

site_test <- model_comparisons |>
  filter(.data$comparison_id == "site_pattern_vs_no_site")

deviations_model <- tibble::tribble(
  ~Aspect, ~Registered, ~Analysis, ~Reason_or_consequence,
  "Primary placement",
  "Chest primary; near-eye repeat",
  "Near-eye primary; chest complementary",
  "Near-eye better represents light close to the eye; placements are not pooled.",
  "Outcome and epoch",
  "Hourly geometric-mean melEDI",
  "30-minute arithmetic-mean melEDI",
  "Provides clock-resolved support but changes the outcome and dependence structure.",
  "Response scale",
  "No zero-handling transform specified",
  "log10(melEDI + 0.1 lx)",
  "Retains exact zero observations while defining a finite modelling scale.",
  "Global time effect",
  "No separate overall smooth",
  "Cyclic global time smooth, k = 12",
  "Makes site curves deviations from one shared daily profile.",
  "Site smooth",
  "Site-specific cyclic cc smooths",
  "Sum-to-zero sz deviations, k = 12; default time marginal is not cyclic",
  "Sum-to-zero site contrasts replace reference-site comparisons.",
  "Participant hierarchy",
  "Participant-time factor smooth",
  "Participant factor smooth (k = 10) plus participant-day random intercept",
  "Separates person-specific shape from day-to-day level shifts.",
  "Variance target",
  "Within-participant variance exceeds between-site variance",
  "Integrated fitted-curve dispersion plus conditional Shapley allocation",
  "The reported quantities are explicitly defined and are not response variance explained."
)

deviations_data <- tibble::tribble(
  ~Aspect, ~Registered_or_unspecified, ~Analysis, ~Reason_or_consequence,
  "30-minute support",
  "Hourly outcome; no 30-minute rule",
  "At least 15 valid one-minute values per 30-minute bin",
  "Unsupported bins remain missing rather than being treated as darkness.",
  "Whole-day handling",
  "Coverage-based exclusion; no exact-zero-day rule",
  "No additional daily-coverage deletion for H02; entirely exact-zero days are excluded",
  "Preserves partly observed days while removing the fixed signal-plausibility failure.",
  "Clock and DST",
  "Not operationally specified",
  "Local wall clock for profiles; true UTC for ordering; fall-back folds retained",
  "AR sequences reset at every participant-day and discontinuity.",
  "Measurement context",
  "Wear and sleep removal described generally",
  "Wake values are worn measurements; sleep values describe the bedside environment",
  "The 24-hour record is hybrid and is not continuous ocular exposure.",
  "Operating range",
  "Values above 120,000 lx excluded",
  "melEDI must be below 100,000 lx after one-minute aggregation",
  "Uses the manufacturer operating boundary."
)

primary_ratios <- bind_rows(
  ratio_row(near_id, "participant_to_site_ratio") |>
    mutate(Scenario = "Primary near-eye"),
  ratio_row(near_id, "participant_plus_day_to_site_ratio") |>
    mutate(Scenario = "Primary near-eye")
)

alternative_counts <- sample_overall(alternative_preparation_id)

paired_near_counts <- sample_overall(paired_near_id)

paired_chest_counts <- sample_overall(paired_chest_id)

near_participant_ratio <- extract_ratio(
  near_id, "participant_to_site_ratio"
)
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"estimate"` instead of `.data$estimate`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"lower_95"` instead of `.data$lower_95`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"upper_95"` instead of `.data$upper_95`
near_combined_ratio <- extract_ratio(
  near_id, "participant_plus_day_to_site_ratio"
)

alternative_participant_ratio <- extract_ratio(
  alternative_preparation_id, "participant_to_site_ratio"
)

alternative_combined_ratio <- extract_ratio(
  alternative_preparation_id, "participant_plus_day_to_site_ratio"
)

paired_near_participant_ratio <- extract_ratio(
  paired_near_id, "participant_to_site_ratio"
)

paired_near_combined_ratio <- extract_ratio(
  paired_near_id, "participant_plus_day_to_site_ratio"
)

chest_participant_ratio <- extract_ratio(
  chest_id, "participant_to_site_ratio"
)

chest_combined_ratio <- extract_ratio(
  chest_id, "participant_plus_day_to_site_ratio"
)

paired_chest_participant_ratio <- extract_ratio(
  paired_chest_id, "participant_to_site_ratio"
)

paired_chest_combined_ratio <- extract_ratio(
  paired_chest_id, "participant_plus_day_to_site_ratio"
)

cyclic_participant_ratio <- formula_sensitivity |>
  filter(
    .data$data_scenario_id == "main",
    .data$summary_id == "participant_to_site_ratio"
  )

cyclic_combined_ratio <- formula_sensitivity |>
  filter(
    .data$data_scenario_id == "main",
    .data$summary_id == "participant_plus_day_to_site_ratio"
  )

sensitivity_display <- bind_rows(
  make_sensitivity_row(
    "Primary near-eye",
    near_counts$participants,
    near_counts$participant_days,
    near_counts$observations_30_minute,
    near_participant_ratio$estimate,
    near_participant_ratio$lower_95,
    near_participant_ratio$upper_95,
    near_combined_ratio$estimate,
    near_combined_ratio$lower_95,
    near_combined_ratio$upper_95,
    "Reference result"
  ),
  make_sensitivity_row(
    "Gap-timing-unaware dataset",
    alternative_counts$participants,
    alternative_counts$participant_days,
    alternative_counts$observations_30_minute,
    alternative_participant_ratio$estimate,
    alternative_participant_ratio$lower_95,
    alternative_participant_ratio$upper_95,
    alternative_combined_ratio$estimate,
    alternative_combined_ratio$lower_95,
    alternative_combined_ratio$upper_95,
    "Stable"
  ),
  make_sensitivity_row(
    "Cyclic site and participant deviations",
    near_counts$participants,
    near_counts$participant_days,
    near_counts$observations_30_minute,
    cyclic_participant_ratio$cyclic_sensitivity_estimate,
    cyclic_participant_ratio$cyclic_sensitivity_lower_95,
    cyclic_participant_ratio$cyclic_sensitivity_upper_95,
    cyclic_combined_ratio$cyclic_sensitivity_estimate,
    cyclic_combined_ratio$cyclic_sensitivity_lower_95,
    cyclic_combined_ratio$cyclic_sensitivity_upper_95,
    "Stable"
  ),
  make_sensitivity_row(
    "Placement-matched near eye",
    paired_near_counts$participants,
    paired_near_counts$participant_days,
    paired_near_counts$observations_30_minute,
    paired_near_participant_ratio$estimate,
    paired_near_participant_ratio$lower_95,
    paired_near_participant_ratio$upper_95,
    paired_near_combined_ratio$estimate,
    paired_near_combined_ratio$lower_95,
    paired_near_combined_ratio$upper_95,
    "Combined ordering retained; participant-only interval includes 1"
  ),
  make_sensitivity_row(
    "Complementary chest",
    chest_counts$participants,
    chest_counts$participant_days,
    chest_counts$observations_30_minute,
    chest_participant_ratio$estimate,
    chest_participant_ratio$lower_95,
    chest_participant_ratio$upper_95,
    chest_combined_ratio$estimate,
    chest_combined_ratio$lower_95,
    chest_combined_ratio$upper_95,
    "Combined ordering retained; participant-only interval includes 1"
  ),
  make_sensitivity_row(
    "Placement-matched chest",
    paired_chest_counts$participants,
    paired_chest_counts$participant_days,
    paired_chest_counts$observations_30_minute,
    paired_chest_participant_ratio$estimate,
    paired_chest_participant_ratio$lower_95,
    paired_chest_participant_ratio$upper_95,
    paired_chest_combined_ratio$estimate,
    paired_chest_combined_ratio$lower_95,
    paired_chest_combined_ratio$upper_95,
    "Combined ordering retained; participant-only interval includes 1"
  )
)

Question

The preregistered hypothesis was:

“Within-participant variance in hourly melanopic EDI, with participants nested in sites, exceeds variance between sites.”

The preregistered model was:

Metric ~ Site +
  s(Time, by = Site, bs = "cc", k = 12) +
  s(Participant, Time, bs = "fs")

The scientific question is whether daily patterns of melanopic equivalent daylight illuminance (melEDI) differ among sites, how much people within a site differ from one another, and whether those within-site differences are larger than the differences among sites.

NoteAnswer in brief

Near-eye daily patterns differed among sites (\(\chi^2=\) 474.93, 96 degrees of freedom, false-discovery-rate (FDR)-adjusted \(p\) = <0.001). Participant curves plus participant-day shifts were 1.99 (95% CI 1.29 to 4.77) times as dispersed as site curves. In the conditional Shapley analysis, those two participant-level blocks received 9.49 (95% CI 5.77 to 21.81) times as much in-sample model-fit credit as the site-pattern block. Chest measurements gave the same combined ordering.

What was analysed

The near-eye sensor position is primary because it measures light closer to the eye, but it is not a direct retinal measurement. The chest sensor position is analysed separately as complementary evidence and is not a measure of ocular exposure; the two placements are never pooled. During reported sleep, both positions characterize the bedside light environment rather than light at their nominal worn position.

A participant-day is one participant’s observations on one local calendar date. Counts of participant-days therefore describe repeated recorded days, not additional independent participants.

The model used local wall-clock time to describe the daily pattern. True UTC time determined observation ordering. A 30-minute bin entered the model when at least 15 of its 30 one-minute melEDI values were valid. Missing, unsupported, non-wear, and out-of-range values remained missing, not zero. Repeated fall-back clock bins retained their fold provenance; residual sequences were reset at each participant-day, after any elapsed-time discontinuity, and around a wall-clock outcome without a one-to-one true-time coordinate.

Terms used below

  • A nonlinear GAM analysis uses a generalized additive model (GAM), which allows the association with local clock time to bend across the day rather than forcing it to follow a straight line.
  • The model uses log10(melEDI + 0.1 lx). A back-transformed curve applies the inverse transformation and returns to melEDI in lux.
  • Participant and participant-day smooths represent fitted variation that remains among people or recorded days after the time and site patterns have been considered.
  • Autocorrelation means adjacent 30-minute residuals can remain more alike than residuals farther apart. The AR(1) structure represents this dependence as strongest for adjacent observations and weaker at longer lags.
  • A pointwise 95% confidence interval (95% CI) gives uncertainty at one displayed clock time. It is not a simultaneous band for the full day and does not multiplicity-control every red segment in a daily curve.
  • Shapley allocation averages a model component’s contribution over all orders in which components could be added, allocating shared fitted-model information rather than counting it repeatedly.

The complete registration-change record appears in the detailed analysis record.

Model

The daily patterns were estimated with a nonlinear GAM analysis. This model combines a curved time-of-day pattern with structured departures for sites, participants, and participant-days while retaining the repeated-measures structure.

The exact Wilkinson formula was:

response ~
  s(time_hour, bs = "cc", k = 12) +
  s(time_hour, site, bs = "sz", k = 12) +
  s(time_hour, participant, bs = "fs", k = 10) +
  s(participant_day, bs = "re")

Here, response is \(\log_{10}(\mathrm{melEDI}+0.1\ \mathrm{lx})\). The first term is the global time effect, which is the site-average daily curve: it gives every site equal weight and joins smoothly at midnight. The sz term estimates how each site’s curve departs from that site-average curve; the departures sum to zero, so no reference site is required. The participant smooth represents remaining differences in daily shape among people after the reported time and site patterns are considered. Participant identifiers are site-prefixed, so people remain nested within sites. The participant-day random effect represents remaining day-to-day level shifts within participants after those patterns are considered; it shifts an entire fitted day up or down without changing its shape.

Only the global time effect is explicitly cyclic. The default time marginals inside the sz site term and fs participant term are not forced to meet at midnight. A fully cyclic formulation is therefore included as a model-form sensitivity.

Because adjacent 30-minute residuals can remain more similar than residuals farther apart, the model was fitted with mgcv::bam() under fREML and a boundary-aware AR(1) working correlation. Site curves and their pointwise 95% CIs were obtained from the fitted linear-predictor matrix and full coefficient covariance. Each interval describes uncertainty at one clock time; it is not a simultaneous band across the day. Smoothing parameters are treated as fixed in these intervals. gratia was used for residual appraisal and for the concurvity model check; concurvity is overlap among nonlinear model terms that makes their separate contributions harder to distinguish.

Quantities used to compare sites and people

The first comparison describes the spread of the fitted curves. On a common 48-bin clock grid,

\[ V_{\mathrm{site}} = \frac{1}{48}\sum_b \operatorname{Var}_{s}\{\hat f_s(t_b)\}, \]

and participant-curve variation is the within-site variance among participant curves over the same clock grid, averaged across sites with each site receiving equal weight. Participant-day variation uses the same site-average weighting for the variance of day-specific intercept contributions. Their ratios compare fitted curve dispersion on the transformed modelling scale. They are not proportions of response variance and are not “variance explained.”

The second comparison asks which model block contributes more to in-sample fit. The global time effect is retained in every model. Site pattern, participant pattern, and participant-day shift are then included in every possible combination. For each block, the conditional Shapley allocation is its average increase in row-weighted in-sample \(R^2\) across all possible orders of entry. This allocates shared fitted-model information without counting it repeatedly. It describes conditional in-sample model-fit relevance, not causal importance or cross-validated predictive importance.

Both sets of 95% confidence intervals use 2,000 hierarchical cluster resamples: sites, participants within sampled sites, and participant-days within sampled participants. The fitted curve contributions or fitted subset models are held fixed. The intervals therefore describe cluster-sampling uncertainty conditional on the fitted smoothing structure.

Primary near-eye result

The primary model fitted 141 participants, 816 participant-days, 37,756 30-minute observations, and 9 sites.

Daily profiles

Panel A shows the global time effect, the site-average curve that gives each site equal weight. Panel B shows the absolute fitted curve for each site; each facet states its fitted participant-day count. Panel C overlays the fitted participant curves. Panel D shows each site’s fitted curve divided by the global time effect. A factor of 2 means twice the fitted \(\mathrm{melEDI}+0.1\ \mathrm{lx}\) quantity; a factor of 0.5 means half. Grey vertical bands show the mean civil-night intervals.

Ribbons in panels A, B, and D are pointwise 95% CIs. Red segments in panel D identify individual 30-minute bins whose interval excludes one. These segments are local descriptions, not jointly multiplicity-controlled tests across the day or across sites.

include_project_graphics(file.path(
  root,
  "results", "images",
  "H02",
  "daily_patterns.png"
))
Four-panel near-eye daily-pattern figure. Panel A shows the site-average global time effect for 816 participant-days with a pointwise 95% confidence ribbon. Panel B shows nine site curves, pointwise ribbons, country-coded site labels, and a participant-day count in each facet. Panel C shows participant curves. Panel D shows site-to-global-time-effect factors with pointwise ribbons; red segments mark individual clock times whose intervals exclude one.
Figure 1: Daily near-eye melEDI patterns: site-average curve (A), site curves (B), participant curves (C), and site-to-global-time-effect factors (D). Ribbons are pointwise 95% CIs.

Fitted-curve dispersion

variation_table_data(near_id) |>
  gt::gt(rowname_col = "Quantity", groupname_col = "Section") |>
  gt::cols_align(align = "right", columns = c(Estimate, `95% CI`)) |>
  gt::tab_source_note(
    source_note = paste(
      "Variation units are squared log10(melEDI + 0.1 lx) prediction",
      "units. Participant / site divides participant-curve variation by",
      "site-curve variation; (Participant + day) / site adds participant-day",
      "intercept variation to that numerator. Ratios are unitless. CIs use",
      paste0(format(bootstrap_count(2000L), big.mark = ","), " hierarchical resamples.")
    )
  ) |>
  h02_gt()
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"Section"` instead of `.data$Section`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"Quantity"` instead of `.data$Quantity`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"Estimate"` instead of `.data$Estimate`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"95% CI"` instead of `.data$95% CI`
Table 1: Near-eye fitted-curve variation and participant-to-site ratios.
Estimate 95% CI
Integrated fitted-curve variation
Site curves 0.100 0.100 (95% CI 0.041 to 0.139)
Participant curves 0.180 0.180 (95% CI 0.126 to 0.214)
Participant-day shifts 0.019 0.019 (95% CI 0.010 to 0.024)
Participant curves + day shifts 0.200 0.200 (95% CI 0.140 to 0.230)
Relative curve dispersion
Participant / site 1.80 1.80 (95% CI 1.16 to 4.33)
(Participant + day) / site 1.99 1.99 (95% CI 1.29 to 4.77)
Variation units are squared log10(melEDI + 0.1 lx) prediction units. Participant / site divides participant-curve variation by site-curve variation; (Participant + day) / site adds participant-day intercept variation to that numerator. Ratios are unitless. CIs use 2,000 hierarchical resamples.

Participant curves were 1.80 (95% CI 1.16 to 4.33) times as dispersed as site curves. Adding participant-day shifts increased that ratio to 1.99 (95% CI 1.29 to 4.77). Thus, fitted variation within sites was larger than fitted variation among sites under the predeclared clock-grid definition.

Relative contribution to fitted-model performance

dominance_table_data(near_id) |>
  gt::gt(rowname_col = "Component") |>
  gt::tab_source_note(
    source_note = paste(
      "Full-model in-sample R² =",
      fmt_ci(
        near_dominance$full_model_R2[[1L]],
        near_dominance$full_model_R2_lower_95[[1L]],
        near_dominance$full_model_R2_upper_95[[1L]],
        3
      ),
      "."
    )
  ) |>
  h02_gt()
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"Component"` instead of `.data$Component`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"Allocated R² (95% CI)"` instead of `.data$Allocated R² (95% CI)`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"Share of full-model R² (95% CI)"` instead of `.data$Share of
  full-model R² (95% CI)`
Table 2: Near-eye conditional Shapley allocation of in-sample model fit.
Allocated R² (95% CI) Share of full-model R² (95% CI)
Global time effect 0.611 (95% CI 0.550 to 0.657) 78.9% (95% CI 73.6% to 82.6%)
Site pattern 0.016 (95% CI 0.007 to 0.026) 2.0% (95% CI 0.9% to 3.5%)
Participant pattern 0.100 (95% CI 0.079 to 0.125) 12.9% (95% CI 10.1% to 16.4%)
Participant-day shift 0.048 (95% CI 0.035 to 0.064) 6.2% (95% CI 4.4% to 8.6%)
Full-model in-sample R² = 0.775 (95% CI 0.739 to 0.804) .
dominance_comparison_data(near_id) |>
  gt::gt(rowname_col = "Comparison") |>
  gt::cols_align(align = "right", columns = Result) |>
  h02_gt()
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"Comparison"` instead of `.data$Comparison`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"Result"` instead of `.data$Result`
Table 3: Near-eye participant-versus-site comparisons from the Shapley allocation.
Result
Participant pattern / site pattern 6.41 (95% CI 3.85 to 15.14) times
(Participant pattern + day shift) / site pattern 9.49 (95% CI 5.77 to 21.81) times
Participant pattern + day shift: share beyond global time effect 90.5% (95% CI 85.2% to 95.6%)

Participant-specific patterns received 6.41 (95% CI 3.85 to 15.14) times as much in-sample model-fit credit as site patterns. Participant patterns and participant-day shifts together received 9.49 (95% CI 5.77 to 21.81) times as much credit as site patterns and accounted for 90.5% (95% CI 85.2% to 95.6%) of the fitted \(R^2\) increment beyond the global time effect.

This is the most direct answer to “how much more relevant were participants within sites than sites themselves”: in this fitted sample and model, participant-level blocks contributed substantially more to in-sample fit than the site-pattern block. It does not imply that changing a person’s behaviour would cause that amount of change.

Pointwise site windows

window_table_data(near_id) |>
  gt::gt(rowname_col = "Site") |>
  gt::tab_source_note(
    source_note = paste(
      "Factors compare each site with the global time effect.",
      "The windows are not simultaneous curve-level discoveries."
    )
  ) |>
  h02_gt()
Table 4: Near-eye clock windows whose pointwise conditional 95% interval excludes the global time effect.
Pointwise windows (fitted factor range)
Borås (SE) Higher 06:00:00–11:00:00 (2.04–4.64×); Higher 15:00:00–19:00:00 (1.80–2.73×)
Delft (NL) No 30-minute bin excluded 1
Dortmund (DE) Higher 05:30:00–07:30:00 (2.05–2.46×); Higher 16:30:00–21:00:00 (2.00–3.35×)
Tübingen (DE) Lower 05:30:00–06:30:00 (0.63–0.65×)
Munich (DE) Higher 06:00:00–07:00:00 (2.04–2.08×); Higher 17:00:00–21:30:00 (2.30–3.72×)
Madrid (ES) Lower 05:30:00–10:30:00 (0.15–0.46×); Lower 17:30:00–20:30:00 (0.40–0.57×)
Izmir (TR) Lower 06:00:00–08:30:00 (0.51–0.59×); Higher 21:00:00–24:00:00 (1.98–4.28×)
San José (CR) No 30-minute bin excluded 1
Kumasi (GH) Lower 05:30:00–07:00:00 (0.48–0.53×); Lower 11:30:00–23:00:00 (0.11–0.38×)
Factors compare each site with the global time effect. The windows are not simultaneous curve-level discoveries.

Complementary chest result

The chest model used the identical formula, transform, fitting algorithm, curve definitions, and interval procedures. It fitted 154 participants, 902 participant-days, 41,842 30-minute observations, and 8 sites. Tübingen (DE) is absent because chest measurements were unavailable there.

Fitted-curve dispersion

variation_table_data(chest_id) |>
  gt::gt(rowname_col = "Quantity", groupname_col = "Section") |>
  gt::cols_align(align = "right", columns = c(Estimate, `95% CI`)) |>
  gt::tab_source_note(
    source_note = paste(
      "Variation units are squared log10(melEDI + 0.1 lx) prediction",
      "units. Participant / site divides participant-curve variation by",
      "site-curve variation; (Participant + day) / site adds participant-day",
      "intercept variation to that numerator. Ratios are unitless. CIs use",
      paste0(format(bootstrap_count(2000L), big.mark = ","), " hierarchical resamples.")
    )
  ) |>
  h02_gt()
Table 5: Chest fitted-curve variation and participant-to-site ratios.
Estimate 95% CI
Integrated fitted-curve variation
Site curves 0.101 0.101 (95% CI 0.049 to 0.116)
Participant curves 0.148 0.148 (95% CI 0.100 to 0.199)
Participant-day shifts 0.035 0.035 (95% CI 0.020 to 0.039)
Participant curves + day shifts 0.183 0.183 (95% CI 0.127 to 0.228)
Relative curve dispersion
Participant / site 1.47 1.47 (95% CI 0.97 to 3.18)
(Participant + day) / site 1.81 1.81 (95% CI 1.22 to 3.74)
Variation units are squared log10(melEDI + 0.1 lx) prediction units. Participant / site divides participant-curve variation by site-curve variation; (Participant + day) / site adds participant-day intercept variation to that numerator. Ratios are unitless. CIs use 2,000 hierarchical resamples.

Chest participant curves were 1.47 (95% CI 0.97 to 3.18) times as dispersed as chest site curves. That interval includes one. After participant-day shifts were included, the ratio was 1.81 (95% CI 1.22 to 3.74), retaining the combined participant-level ordering.

Relative contribution to fitted-model performance

dominance_table_data(chest_id) |>
  gt::gt(rowname_col = "Component") |>
  gt::tab_source_note(
    source_note = paste(
      "Full-model in-sample R² =",
      fmt_ci(
        chest_dominance$full_model_R2[[1L]],
        chest_dominance$full_model_R2_lower_95[[1L]],
        chest_dominance$full_model_R2_upper_95[[1L]],
        3
      ),
      "."
    )
  ) |>
  h02_gt()
Table 6: Chest conditional Shapley allocation of in-sample model fit.
Allocated R² (95% CI) Share of full-model R² (95% CI)
Global time effect 0.583 (95% CI 0.523 to 0.629) 79.1% (95% CI 73.6% to 82.8%)
Site pattern 0.015 (95% CI 0.008 to 0.024) 2.1% (95% CI 1.1% to 3.3%)
Participant pattern 0.087 (95% CI 0.071 to 0.110) 11.9% (95% CI 9.5% to 15.3%)
Participant-day shift 0.051 (95% CI 0.040 to 0.067) 7.0% (95% CI 5.3% to 9.4%)
Full-model in-sample R² = 0.737 (95% CI 0.703 to 0.766) .
dominance_comparison_data(chest_id) |>
  gt::gt(rowname_col = "Comparison") |>
  gt::cols_align(align = "right", columns = Result) |>
  h02_gt()
Table 7: Chest participant-versus-site comparisons from the Shapley allocation.
Result
Participant pattern / site pattern 5.69 (95% CI 3.72 to 10.68) times
(Participant pattern + day shift) / site pattern 9.02 (95% CI 6.01 to 17.03) times
Participant pattern + day shift: share beyond global time effect 90.0% (95% CI 85.7% to 94.5%)

At the chest, participant patterns received 5.69 (95% CI 3.72 to 10.68) times as much in-sample model-fit credit as site patterns. Participant patterns plus participant-day shifts received 9.02 (95% CI 6.01 to 17.03) times as much credit. The complementary placement therefore reproduces the relative-relevance ordering.

Daily profiles

As in the near-eye figure, panel A shows the site-average global time effect, panel B shows site curves with fitted participant-day counts, panel C shows participant curves, and panel D shows site-to-global-time-effect factors. Ribbons in panels A, B, and D are pointwise 95% CIs.

include_project_graphics(file.path(
  root,
  "results", "images",
  "H02",
  "daily_patterns_chest.png"
))
Four-panel chest daily-pattern figure. Panel A shows the site-average global time effect for 902 participant-days with a pointwise 95% confidence ribbon. Panel B shows eight site curves, pointwise ribbons, country-coded site labels, and a participant-day count in each facet. Panel C shows participant curves. Panel D shows site-to-global-time-effect factors with pointwise ribbons; red segments mark individual clock times whose intervals exclude one.
Figure 2: Daily chest melEDI patterns: site-average curve (A), site curves (B), participant curves (C), and site-to-global-time-effect factors (D). Ribbons are pointwise 95% CIs.
window_table_data(chest_id) |>
  gt::gt(rowname_col = "Site") |>
  gt::tab_source_note(
    source_note = paste(
      "Factors compare each site with the global time effect.",
      "The windows are not simultaneous curve-level discoveries."
    )
  ) |>
  h02_gt()
Table 8: Chest clock windows whose pointwise conditional 95% interval excludes the global time effect.
Pointwise windows (fitted factor range)
Borås (SE) Higher 06:00:00–18:30:00 (1.71–3.63×); Lower 21:00:00–23:30:00 (0.38–0.52×)
Delft (NL) Lower 05:00:00–07:30:00 (0.51–0.63×); Higher 16:00:00–20:30:00 (1.70–2.20×)
Dortmund (DE) Higher 16:30:00–20:00:00 (1.87–2.37×)
Munich (DE) Higher 17:30:00–21:00:00 (2.03–2.80×); Higher 23:30:00–24:00:00 (3.97–3.97×)
Madrid (ES) Lower 05:00:00–11:00:00 (0.14–0.55×); Lower 17:30:00–20:30:00 (0.44–0.60×)
Izmir (TR) Higher 00:00:00–00:30:00 (2.88–2.88×); Lower 05:30:00–09:30:00 (0.46–0.63×); Higher 20:00:00–24:00:00 (1.61–5.01×)
San José (CR) Higher 04:30:00–11:30:00 (1.50–6.65×); Lower 16:00:00–20:00:00 (0.43–0.67×)
Kumasi (GH) Lower 12:00:00–23:00:00 (0.14–0.44×)
Factors compare each site with the global time effect. The windows are not simultaneous curve-level discoveries.

Direct comparison of near-eye and chest patterns

The comparison used the same 112 participants, 643 participant-days, and 29,786 30-minute observations at both sensor positions across eight sites; their observation keys match one-to-one. This is the matched sample. The two positions were fitted separately with the same response definition, transformation, temporal grid, and model structure; they were not pooled.

A scalar near-eye-versus-chest identity plot would discard the feature H02 is designed to estimate: how the fitted pattern changes across the day. The figure therefore overlays the two fitted site curves at every matched half-hour. It uses only stored placement-matched predictions and does not fit a new model. The y-axis is evenly spaced on the modelling scale, with tick labels back-transformed to melEDI for interpretation.

paired_site_levels <- site_registry |>
  filter(.data$site %in% paired_placement_curves$site) |>
  arrange(.data$display_order) |>
  pull(.data$display_name)

paired_placement_plot_data <- paired_placement_curves |>
  mutate(
    placement = factor(.data$placement, levels = c("Near eye", "Chest")),
    display_name = factor(
      .data$display_name,
      levels = paired_site_levels
    )
  )

ggplot(
  paired_placement_plot_data,
  aes(
    x = .data$time_hour,
    y = .data$eta,
    colour = .data$placement,
    fill = .data$placement,
    linetype = .data$placement,
    group = .data$placement
  )
) +
  geom_hline(yintercept = -1, colour = "grey55", linetype = "dotted") +
  geom_ribbon(
    aes(
      ymin = .data$pointwise_lower_eta,
      ymax = .data$pointwise_upper_eta
    ),
    alpha = 0.14,
    colour = NA,
    show.legend = FALSE
  ) +
  geom_line(linewidth = 0.85) +
  facet_wrap(vars(.data$display_name), ncol = 4) +
  scale_colour_manual(
    values = c("Near eye" = "#0072B2", "Chest" = "#D55E00")
  ) +
  scale_fill_manual(
    values = c("Near eye" = "#0072B2", "Chest" = "#D55E00")
  ) +
  scale_linetype_manual(
    values = c("Near eye" = "solid", "Chest" = "longdash")
  ) +
  scale_x_continuous(
    breaks = c(0, 6, 12, 18, 24),
    limits = c(0, 24),
    expand = expansion(mult = c(0, 0))
  ) +
  scale_y_continuous(
    breaks = -1:3,
    labels = c("0", "0.9", "9.9", "99.9", "1,000")
  ) +
  coord_cartesian(ylim = c(-1.5, 3.25)) +
  labs(
    x = "Local wall-clock time",
    y = "Fitted melEDI (lx; transformed spacing)",
    colour = "Placement",
    linetype = "Placement"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    legend.position = "bottom",
    legend.title = element_text(face = "bold"),
    panel.grid.minor = element_blank(),
    strip.text = element_text(face = "bold")
  )
Eight country-coded site facets compare separately fitted near-eye and chest daily melEDI curves for the same 112 participants, 643 participant-days, and 29,786 half-hour observations at both sensor positions. Near-eye curves are solid blue and chest curves are dashed orange; translucent ribbons show component pointwise 95% confidence intervals.
Figure 3: Near-eye and chest site curves for the same 112 participants, 643 participant-days, and 29,786 30-minute observations at both sensor positions. Ribbons are component pointwise 95% CIs.

The ribbons are component pointwise 95% CIs for each separately fitted placement curve. They are not an interval for the near-eye-minus-chest difference because the covariance between the two fitted models was not estimated. Similar-looking curves indicate descriptive concordance on this matched sample, not statistical equivalence.

Model checks

Near-eye model checks

Show detailed near-eye model checks
include_project_graphics(file.path(
  root,
  "results", "images",
  "H02",
  "model_diagnostics_near_eye.png"
))
Five-panel model-check figure for the near-eye nonlinear GAM. Panels A to D show a normal QQ plot, residuals versus linear predictor, a residual histogram, and observed versus fitted values using Pearson residuals. Panel E shows residual autocorrelation before and after the boundary-aware AR(1) correction.
Figure 4: Near-eye model checks (diagnostics).
diagnostic_table_data(near_id) |>
  gt::gt(rowname_col = "Check") |>
  gt::cols_width(
    Result ~ gt::pct(34),
    Reading ~ gt::pct(43)
  ) |>
  h02_gt()
Table 9: Near-eye fit and model-check summary.
Result Reading
Convergence and coefficient rank Converged; rank 2,333/2,333 Numerical fit completed at full coefficient rank.
Full-model in-sample R² 0.775 (95% CI 0.739 to 0.804) Describes fit to these observations; it is not cross-validated performance.
AR(1) correction ρ = 0.623; lag-1 0.623 → 0.083 Most average 30-minute residual dependence was removed.
Participant-day residual dependence median lag-1 0.097; 95th percentile 0.458 Some days retain appreciable positive autocorrelation.
Residual spread RMSE 0.702; cor(|residual|, fitted) 0.202; max |residual| 5.69 Residual spread changes with fitted exposure and tails remain.
Basis-dimension checks common k-index 0.999 (check p = 0.435); participant 0.999 (check p = 0.455) No evidence that the available temporal basis was too small.
Sum-to-zero site constraint verified; max |sum| = 2.57e-15 Site curves are identifiable deviations around the global time effect.
Nonlinear-term overlap (concurvity) common/site = 0.999/0.992 The global and site bases overlap strongly; interpret complete curves and contrasts, not isolated coefficients.

The model converged at full rank, the basis-size checks did not indicate that the available time bases were too small, and the AR(1) correction reduced overall lag-1 residual correlation from 0.623 to 0.083. These are strengths for estimating average daily patterns.

The residual checks are not ideal. The QQ plot has an S-shaped departure from normality, and the line of observations at response \(-1\) reflects the large number of exact zeros after the \(\log_{10}(\mathrm{melEDI}+0.1)\) transform. Residual magnitude also increases modestly with fitted exposure. Some participant-days retain positive lag-1 correlation. In addition, concurvity, the overlap among nonlinear terms, is high between the global and site time bases even though the sum-to-zero constraint is satisfied at full rank. The model is therefore adequate for a conditional description of mean curves and their complete contrasts, but it is not a perfect generative description of the full zero-heavy response distribution. Isolated smooth coefficients should not be interpreted as independent effects.

Chest model checks

Show detailed chest model checks
include_project_graphics(file.path(
  root,
  "results", "images",
  "H02",
  "model_diagnostics_chest.png"
))
Five-panel model-check figure for the chest nonlinear GAM. Panels A to D show a normal QQ plot, residuals versus linear predictor, a residual histogram, and observed versus fitted values using Pearson residuals. Panel E shows residual autocorrelation before and after the boundary-aware AR(1) correction.
Figure 5: Chest model checks.
diagnostic_table_data(chest_id) |>
  gt::gt(rowname_col = "Check") |>
  gt::cols_width(
    Result ~ gt::pct(34),
    Reading ~ gt::pct(43)
  ) |>
  h02_gt()
Table 10: Chest fit and model-check summary.
Result Reading
Convergence and coefficient rank Converged; rank 2,537/2,537 Numerical fit completed at full coefficient rank.
Full-model in-sample R² 0.737 (95% CI 0.703 to 0.766) Describes fit to these observations; it is not cross-validated performance.
AR(1) correction ρ = 0.607; lag-1 0.607 → 0.070 Most average 30-minute residual dependence was removed.
Participant-day residual dependence median lag-1 0.088; 95th percentile 0.445 Some days retain appreciable positive autocorrelation.
Residual spread RMSE 0.775; cor(|residual|, fitted) 0.224; max |residual| 5.44 Residual spread changes with fitted exposure and tails remain.
Basis-dimension checks common k-index 0.982 (check p = 0.130); participant 0.982 (check p = 0.115) No evidence that the available temporal basis was too small.
Sum-to-zero site constraint verified; max |sum| = 1.17e-15 Site curves are identifiable deviations around the global time effect.
Nonlinear-term overlap (concurvity) common/site = 1.000/0.989 The global and site bases overlap strongly; interpret complete curves and contrasts, not isolated coefficients.

The chest model also converged at full rank. The AR(1) correction reduced overall lag-1 residual correlation from 0.607 to 0.070. Its basis-size checks were acceptable. The same lower-bound pattern, non-normal tails, fitted-dependent residual spread, and high overlap between the global and site nonlinear terms (concurvity) remain. The chest model is therefore corroborating evidence with the same interpretive limitations as the near-eye model.

Sensitivity to other analytical choices

The table changes one substantive choice at a time where possible. The gap-timing-unaware dataset sensitivity changes the prepared 30-minute dataset while retaining the model specification. It is the predefined data-preparation sensitivity. It still passed the general 50%-per-hour and 80%-per-day coverage rules, but the timing of the remaining missing observations was not used for an additional metric-specific adjustment. For contrast, the primary dataset could be interpreted as a time-sensitive primary metric dataset because its preparation uses the timing of remaining missing observations where that timing is relevant to the metric; it is called simply the primary dataset elsewhere in this report. The fully cyclic basis sensitivity changes the site and participant time bases so those deviations also meet at midnight. The placement-matched sensitivities change the fitted sample by restricting near-eye and chest fits to the same participant-day-clock observations. The complementary chest analysis changes sensor position while retaining the selected formula and implementation. A separate non-cyclic global-time basis check changed only the global time basis from cyclic cubic to thin plate; residual model checks did not materially improve and midnight continuity worsened.

sensitivity_display |>
  gt::gt(rowname_col = "Scenario") |>
  gt::cols_width(
    `Fitted sample` ~ gt::pct(24),
    `Participant / site` ~ gt::pct(19),
    `(Participant + day) / site` ~ gt::pct(21),
    Interpretation ~ gt::pct(25)
  ) |>
  gt::tab_source_note(
    source_note = paste(
      "All entries are fitted-curve dispersion ratios, not shares of",
      "response variance. Intervals are conditional hierarchical",
      "cluster-bootstrap 95% CIs."
    )
  ) |>
  h02_gt()
Table 11: Sensitivity of participant-to-site fitted-curve dispersion ratios.
Fitted sample Participant / site (Participant + day) / site Interpretation
Primary near-eye 141 participants; 816 days; 37,756 observations 1.80 (95% CI 1.16 to 4.33) 1.99 (95% CI 1.29 to 4.77) Reference result
Gap-timing-unaware dataset 141 participants; 809 days; 37,603 observations 1.71 (95% CI 1.07 to 4.03) 1.92 (95% CI 1.20 to 4.49) Stable
Cyclic site and participant deviations 141 participants; 816 days; 37,756 observations 1.73 (95% CI 1.12 to 3.88) 1.89 (95% CI 1.21 to 4.13) Stable
Placement-matched near eye 112 participants; 643 days; 29,786 observations 1.33 (95% CI 0.89 to 2.83) 1.54 (95% CI 1.02 to 3.30) Combined ordering retained; participant-only interval includes 1
Complementary chest 154 participants; 902 days; 41,842 observations 1.47 (95% CI 0.97 to 3.18) 1.81 (95% CI 1.22 to 3.74) Combined ordering retained; participant-only interval includes 1
Placement-matched chest 112 participants; 643 days; 29,786 observations 1.41 (95% CI 0.85 to 3.25) 1.75 (95% CI 1.09 to 3.91) Combined ordering retained; participant-only interval includes 1
All entries are fitted-curve dispersion ratios, not shares of response variance. Intervals are conditional hierarchical cluster-bootstrap 95% CIs.

The participant-plus-day/site ratio remained above one under every reported choice. The point estimate changed from 1.99 in the primary near-eye analysis to 1.92 in the gap-timing-unaware dataset and 1.89 under the fully cyclic model. On the exact placement-matched sample it was 1.54 near-eye and 1.75 chest. The participant-only ratio was less stable: its interval included one in the chest and placement-matched analyses. The strongest reproducible conclusion is therefore that participant-specific patterns together with day-to-day shifts vary more than site patterns; the participant-pattern-only contrast is less certain outside the full near-eye sample.

Interpretation

The daily profile is dominated by the global time effect. After that pattern was considered, the fitted variation among people and their recorded days was larger than the fitted variation among sites. People within the same site showed more distinct fitted daily patterns than the average differences among site curves, especially when day-to-day level shifts were included.

Two complementary summaries support that interpretation:

  • fitted participant-plus-day curves were about twice as dispersed as site curves in the primary near-eye analysis; and
  • participant pattern plus participant-day shift received about 9.5 times as much conditional in-sample \(R^2\) credit as site pattern.

Those statements have different denominators and should not be numerically combined. The first compares model-implied curve spread. The second allocates in-sample model fit. Neither is a causal effect, a population-wide fraction of variance, or a guarantee of out-of-sample prediction.

Limitations

The site-specific time windows are useful for describing when a fitted site profile departs from the global time effect, but each ribbon is a pointwise 95% CI rather than a simultaneous band. A reader should not interpret every red segment as a separate familywise-controlled discovery. The single confirmatory family contains only the near-eye omnibus site-pattern test, so FDR adjustment leaves its p-value unchanged.

Overall, the model provides a defensible description of mean daily patterns and supports the preregistered ordering when participant-day variation is included. Confidence is tempered by the zero-heavy residual distribution, remaining day-specific serial correlation, high common/site concurvity, only nine near-eye sites, and the conditional rather than model-refitting nature of the bootstrap intervals.

Detailed analysis record

Exact fitted samples

The primary near-eye and complementary chest sample tables below give the overall fitted counts and their site-specific distribution.

Near eye

sample_by_site(near_id) |>
  gt::gt(rowname_col = "Site") |>
  gt::fmt_integer(
    columns = c(
      Participants,
      `Participant-days`,
      `30-minute observations`,
      `Exact-zero observations`,
      `AR sequences`
    ),
    use_seps = TRUE
  ) |>
  gt::tab_style(
    style = gt::cell_text(weight = "bold"),
    locations = gt::cells_stub(rows = Site == "All sites")
  ) |>
  h02_gt()
Table 12: Exact fitted near-eye sample, overall and by site.
Participants Participant-days 30-minute observations Exact-zero observations AR sequences
All sites 141 816 37,756 12,107 1,358
Borås (SE) 13 78 3,619 1,101 123
Delft (NL) 13 78 3,596 1,214 130
Dortmund (DE) 18 107 4,959 1,516 167
Tübingen (DE) 26 150 6,900 1,944 257
Munich (DE) 10 60 2,758 659 115
Madrid (ES) 23 129 6,052 2,503 173
Izmir (TR) 17 101 4,702 1,292 163
San José (CR) 6 32 1,469 378 60
Kumasi (GH) 15 81 3,701 1,500 170

Chest

sample_by_site(chest_id) |>
  gt::gt(rowname_col = "Site") |>
  gt::fmt_integer(
    columns = c(
      Participants,
      `Participant-days`,
      `30-minute observations`,
      `Exact-zero observations`,
      `AR sequences`
    ),
    use_seps = TRUE
  ) |>
  gt::tab_style(
    style = gt::cell_text(weight = "bold"),
    locations = gt::cells_stub(rows = Site == "All sites")
  ) |>
  h02_gt()
Table 13: Exact fitted chest sample, overall and by site.
Participants Participant-days 30-minute observations Exact-zero observations AR sequences
All sites 154 902 41,842 13,799 1,507
Borås (SE) 16 96 4,472 1,440 148
Delft (NL) 15 93 4,307 1,461 146
Dortmund (DE) 20 114 5,308 1,784 171
Munich (DE) 10 60 2,757 799 116
Madrid (ES) 22 123 5,783 2,420 165
Izmir (TR) 17 102 4,749 1,311 165
San José (CR) 39 230 10,628 3,158 418
Kumasi (GH) 15 84 3,838 1,426 178

Deviations from preregistration

The tables below list the scientific and implementation differences that are material to H02. They are stated here because they change the exact outcome, model, or interpretation.

deviations_model |>
  gt::gt(rowname_col = "Aspect") |>
  gt::cols_label(
    Registered = "Preregistered",
    Analysis = "Analysed",
    Reason_or_consequence = "Why it matters"
  ) |>
  gt::cols_width(
    Registered ~ gt::pct(24),
    Analysis ~ gt::pct(31),
    Reason_or_consequence ~ gt::pct(30)
  ) |>
  h02_gt()
Table 14: H02 estimand and model changes relative to the registered analysis.
Preregistered Analysed Why it matters
Primary placement Chest primary; near-eye repeat Near-eye primary; chest complementary Near-eye better represents light close to the eye; placements are not pooled.
Outcome and epoch Hourly geometric-mean melEDI 30-minute arithmetic-mean melEDI Provides clock-resolved support but changes the outcome and dependence structure.
Response scale No zero-handling transform specified log10(melEDI + 0.1 lx) Retains exact zero observations while defining a finite modelling scale.
Global time effect No separate overall smooth Cyclic global time smooth, k = 12 Makes site curves deviations from one shared daily profile.
Site smooth Site-specific cyclic cc smooths Sum-to-zero sz deviations, k = 12; default time marginal is not cyclic Sum-to-zero site contrasts replace reference-site comparisons.
Participant hierarchy Participant-time factor smooth Participant factor smooth (k = 10) plus participant-day random intercept Separates person-specific shape from day-to-day level shifts.
Variance target Within-participant variance exceeds between-site variance Integrated fitted-curve dispersion plus conditional Shapley allocation The reported quantities are explicitly defined and are not response variance explained.
deviations_data |>
  gt::gt(rowname_col = "Aspect") |>
  gt::cols_label(
    Registered_or_unspecified = "Preregistered or unspecified",
    Analysis = "Analysed",
    Reason_or_consequence = "Why it matters"
  ) |>
  gt::cols_width(
    Registered_or_unspecified ~ gt::pct(24),
    Analysis ~ gt::pct(31),
    Reason_or_consequence ~ gt::pct(30)
  ) |>
  h02_gt()
Table 15: H02 data and implementation deviations or clarifications.
Preregistered or unspecified Analysed Why it matters
30-minute support Hourly outcome; no 30-minute rule At least 15 valid one-minute values per 30-minute bin Unsupported bins remain missing rather than being treated as darkness.
Whole-day handling Coverage-based exclusion; no exact-zero-day rule No additional daily-coverage deletion for H02; entirely exact-zero days are excluded Preserves partly observed days while removing the fixed signal-plausibility failure.
Clock and DST Not operationally specified Local wall clock for profiles; true UTC for ordering; fall-back folds retained AR sequences reset at every participant-day and discontinuity.
Measurement context Wear and sleep removal described generally Wake values are worn measurements; sleep values describe the bedside environment The 24-hour record is hybrid and is not continuous ocular exposure.
Operating range Values above 120,000 lx excluded melEDI must be below 100,000 lx after one-minute aggregation Uses the manufacturer operating boundary.

Preregistration deviations describes the scientific changes across analyses.

Source data

The numerical source files include the site-curve predictions, fitted-curve variation results, Shapley allocation results, and matched-position curve data. These files preserve full precision; the report applies reader-facing formatting only.