H09: Chronotype and timing of personal light exposure

This analysis relates chronotype, measured with MCTQ corrected midsleep and MEQ preference, to the timing of personal light exposure. Models distinguish the average association across sites from site-specific differences.

Data and model guide

The questionnaire preparation supplies corrected midsleep on free days (MCTQ MSFsc) and morningness-eveningness preference (MEQ). They remain separate predictors. The metric datasets provide five supported timing outcomes with participant, date and site keys. Effects are per one hour later MSFsc or ten points greater MEQ morning preference. The exact clock transformations and complete samples are constructed below.

For each instrument and outcome, a site-only model is compared with an additive chronotype model, then with a chronotype-by-site interaction. Participant random intercepts account for repeated days. Maximum-likelihood fits provide nested likelihood-ratio tests; restricted maximum likelihood provides final additive coefficients and Wald intervals. Signed effects are in clock hours: positive is later and negative is earlier.

Four separate five-outcome FDR families cover average and interaction associations for each instrument, with separate instances for placement, preprocessing and matched samples. Site-specific slopes remain descriptive unless the global interaction supports heterogeneity. The sensitivity analyses address within-site photoperiod, participant summaries, linearity, continuous-time residual dependence, influence and exact common samples. Sample ranges accompanying supported effects refer to those displayed outcomes; full metric-specific samples are reported below.

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

Setup

Load the reusable sample, formula, fit and diagnostic functions. The sections below perform each analysis and write its numerical results.

source("scripts/project.R")
analysis_setup()
source("scripts/pipeline/paths_io.R")
source("scripts/pipeline/p_value_display.R")
source("scripts/hypotheses/H09/h09_contract.R")
source("scripts/hypotheses/H09/h09_modeling.R")
root <- normalizePath(Sys.getenv("QUARTO_PROJECT_DIR", unset = getwd()))
producer <- "analyses/H09-chronotype.qmd"
options(stringsAsFactors = FALSE, scipen = 999, dplyr.summarise.inform = FALSE)
roots <- list(model_data = file.path(root,"results/intermediate/model_data/H09"), models = file.path(root,"results/models/H09"), diagnostics = file.path(root,"results/csv/diagnostics/H09"), tables = file.path(root,"results/tables/H09"), figures = file.path(root,"results/images/H09"), source_data = file.path(root,"results/csv/source_data/H09"))
invisible(lapply(roots, dir.create, recursive=TRUE, showWarnings=FALSE))
metadata <- list()
source("scripts/pipeline/multiplicity.R")
h09_write_csv <- function(data, path) {
  write_csv_artifact(data, path, producer = producer)
  invisible(path)
}

h09_write_rds <- function(object, path) {
  write_rds_artifact(object, path, producer = producer)
  invisible(path)
}
input_contract <- h09_input_contract(root)

Chronotype and timing samples

Convert corrected midsleep to hours and scale MEQ in ten-point increments. Build matched samples for the two sensor placements and the alternative preprocessing comparison, while retaining metric-specific support.

metric_registry <- h09_metric_registry()

predictor_registry <- h09_predictor_registry()

run_registry <- h09_run_registry()

family_registry <- h09_family_registry()

formula_registry <- h09_formula_registry()

diagnostic_thresholds <- h09_diagnostic_thresholds()

site_registry <- readr::read_csv(
  file.path(root, "config/site_display_registry.csv"),
  show_col_types = FALSE
) |>
  dplyr::arrange(.data$display_order)

site_levels <- site_registry$site

metric_display <- readr::read_csv(
  file.path(root, "config/metric_display_registry.csv"),
  show_col_types = FALSE
)

metric_display_audit <- metric_registry |>
  dplyr::left_join(
    metric_display |>
      dplyr::select(
        .data$metric_id,
        registry_manuscript_name = .data$manuscript_name,
        registry_analysis_unit = .data$analysis_unit,
        registry_display_unit = .data$display_unit
      ),
    by = "metric_id",
    relationship = "many-to-one"
  ) |>
  dplyr::mutate(
    registry_status = dplyr::case_when(
      .data$metric_id == "longest_period_midpoint" &
        is.na(.data$registry_manuscript_name) ~
        "H09-owned registered construction; shared registry row unavailable",
      .data$manuscript_name == .data$registry_manuscript_name &
        .data$registry_analysis_unit == "participant-day" ~ "PASS",
      TRUE ~ "FAIL"
    )
  )

if (any(metric_display_audit$registry_status == "FAIL")) {
  h09_abort("The H09 display contract differs from the shared registry")
}

chronotype <- readRDS(input_contract$absolute_path[
  input_contract$input_role == "normalized_chronotype"
])

score_contract <- h09_score_contract(chronotype)

score_audit <- tibble::tibble(
  participants = nrow(chronotype),
  sites = dplyr::n_distinct(chronotype$site),
  mctq_complete = sum(!is.na(chronotype$msf_sc)),
  mctq_missing = sum(is.na(chronotype$msf_sc)),
  mctq_center_hour = mean(
    as.numeric(chronotype$msf_sc) / 3600,
    na.rm = TRUE
  ),
  mctq_min_hour = min(as.numeric(chronotype$msf_sc) / 3600, na.rm = TRUE),
  mctq_max_hour = max(as.numeric(chronotype$msf_sc) / 3600, na.rm = TRUE),
  meq_complete = sum(!is.na(chronotype$meq)),
  meq_missing = sum(is.na(chronotype$meq)),
  meq_center_score = mean(chronotype$meq, na.rm = TRUE),
  meq_min_score = min(chronotype$meq, na.rm = TRUE),
  meq_max_score = max(chronotype$meq, na.rm = TRUE),
  item_level_reconstruction = "unavailable",
  score_definition = score_contract$score_definition
)

h09_write_csv(
  metric_registry,
  file.path(roots$model_data, "H09_metric_registry.csv")
)

h09_write_csv(
  predictor_registry,
  file.path(roots$model_data, "H09_predictor_registry.csv")
)

h09_write_csv(
  run_registry,
  file.path(roots$model_data, "H09_run_registry.csv")
)

h09_write_csv(
  family_registry,
  file.path(roots$model_data, "H09_family_registry.csv")
)

h09_write_csv(
  formula_registry,
  file.path(roots$model_data, "H09_formula_registry.csv")
)

h09_write_csv(
  diagnostic_thresholds,
  file.path(roots$diagnostics, "H09_diagnostic_thresholds.csv")
)

h09_write_csv(
  metric_display_audit,
  file.path(roots$model_data, "H09_metric_display_audit.csv")
)

h09_write_csv(
  score_audit,
  file.path(roots$diagnostics, "H09_chronotype_score_audit.csv")
)

primary_near_eye_source <- readRDS(input_contract$absolute_path[
  input_contract$input_role == "primary_near_eye_enriched"
])

primary_chest_source <- readRDS(input_contract$absolute_path[
  input_contract$input_role == "primary_chest_enriched"
])

gap_source <- readRDS(input_contract$absolute_path[
  input_contract$input_role == "gap_timing_unaware_metrics"
])

score_value_audit <- dplyr::bind_rows(
  primary_near_eye_source |>
    dplyr::transmute(
      placement = "glasses",
      .data$site,
      .data$Id,
      source_mctq = as.numeric(.data$msf_sc),
      source_meq = as.numeric(.data$meq)
    ),
  primary_chest_source |>
    dplyr::transmute(
      placement = "chest",
      .data$site,
      .data$Id,
      source_mctq = as.numeric(.data$msf_sc),
      source_meq = as.numeric(.data$meq)
    )
) |>
  dplyr::distinct() |>
  dplyr::left_join(
    chronotype |>
      dplyr::transmute(
        .data$site,
        .data$Id,
        normalized_mctq = as.numeric(.data$msf_sc),
        normalized_meq = as.numeric(.data$meq)
      ),
    by = c("site", "Id"),
    relationship = "many-to-one"
  ) |>
  dplyr::mutate(
    mctq_value_matches = dplyr::if_else(
      is.na(.data$source_mctq) & is.na(.data$normalized_mctq),
      TRUE,
      .data$source_mctq == .data$normalized_mctq,
      missing = FALSE
    ),
    meq_value_matches = dplyr::if_else(
      is.na(.data$source_meq) & is.na(.data$normalized_meq),
      TRUE,
      .data$source_meq == .data$normalized_meq,
      missing = FALSE
    )
  )

if (
  any(!score_value_audit$mctq_value_matches) ||
    any(!score_value_audit$meq_value_matches)
) {
  h09_abort("Enriched H09 score values differ from the chronotype input")
}

h09_write_csv(
  score_value_audit,
  file.path(roots$diagnostics, "H09_chronotype_value_audit.csv")
)

primary_near_eye <- h09_prepare_primary_wide(
  primary_near_eye_source,
  score_contract
)

primary_chest <- h09_prepare_primary_wide(
  primary_chest_source,
  score_contract
)

primary_rows <- dplyr::bind_rows(
  h09_primary_long(primary_near_eye, "glasses", metric_registry),
  h09_primary_long(primary_chest, "chest", metric_registry)
)

gap_rows <- h09_prepare_gap_long(gap_source, chronotype, score_contract)

all_rows <- dplyr::bind_rows(primary_rows, gap_rows)

if (anyDuplicated(all_rows[c(
  "data_scenario_id", "placement", "site", "Id", "local_date", "metric_id"
)])) {
  h09_abort("H09 long rows contain duplicate scenario-placement-day metrics")
}

h09_rows_for_run <- function(run, metric_id, instrument_id) {
  selected <- all_rows |>
    dplyr::filter(
      .data$data_scenario_id == run$data_scenario_id,
      .data$placement == run$placement,
      .data$metric_id == .env$metric_id
    )
  frame <- h09_prepare_model_frame(selected, instrument_id, site_levels)
  if (nrow(frame) == 0L) return(frame)
  if (run$sample_scenario == "paired_common") {
    opposite <- if (run$placement == "glasses") "chest" else "glasses"
    other <- all_rows |>
      dplyr::filter(
        .data$data_scenario_id == run$data_scenario_id,
        .data$placement == opposite,
        .data$metric_id == .env$metric_id
      )
    other_frame <- h09_prepare_model_frame(other, instrument_id, site_levels)
    common <- intersect(frame$.model_row_id, other_frame$.model_row_id)
    frame <- frame[frame$.model_row_id %in% common, , drop = FALSE]
  }
  if (run$sample_scenario == "gap_common") {
    other_scenario <- if (run$data_scenario_id == "primary") {
      "gap_timing_unaware"
    } else {
      "primary"
    }
    other <- all_rows |>
      dplyr::filter(
        .data$data_scenario_id == other_scenario,
        .data$placement == run$placement,
        .data$metric_id == .env$metric_id
      )
    other_frame <- h09_prepare_model_frame(other, instrument_id, site_levels)
    common <- intersect(frame$.model_row_id, other_frame$.model_row_id)
    frame <- frame[frame$.model_row_id %in% common, , drop = FALSE]
  }
  if (nrow(frame) > 0L) {
    frame$site <- droplevels(frame$site)
    stats::contrasts(frame$site) <- stats::contr.sum(nlevels(frame$site))
    frame$Id <- droplevels(frame$Id)
    frame$participant_key <- droplevels(frame$participant_key)
  }
  tibble::as_tibble(frame)
}

model_frames <- list()

frame_index_rows <- list()

frame_site_rows <- list()

unavailable_rows <- list()

for (run_index in seq_len(nrow(run_registry))) {
  run <- run_registry[run_index, ]
  available_metrics <- metric_registry$metric_id
  if (
    run$data_scenario_id == "gap_timing_unaware" ||
      run$sample_scenario == "gap_common"
  ) {
    available_metrics <- setdiff(
      available_metrics,
      "longest_period_midpoint"
    )
  }
  for (metric_id in available_metrics) {
    for (instrument_id in predictor_registry$instrument_id) {
      frame <- h09_rows_for_run(run, metric_id, instrument_id)
      if (nrow(frame) == 0L) {
        h09_abort(
          "No H09 model rows for %s / %s / %s",
          run$run_id,
          metric_id,
          instrument_id
        )
      }
      frame_id <- paste(run$run_id, metric_id, instrument_id, sep = "__")
      model_frames[[frame_id]] <- frame
      sample <- h09_sample_summary(frame)
      frame_index_rows[[frame_id]] <- dplyr::bind_cols(
        tibble::tibble(
          frame_id = frame_id,
          run_order = run$run_order,
          run_id = run$run_id,
          data_scenario_id = run$data_scenario_id,
          reader_scenario = run$reader_scenario,
          placement = run$placement,
          placement_label = run$placement_label,
          sample_scenario = run$sample_scenario,
          analytical_role = run$analytical_role,
          metric_id = metric_id,
          instrument_id = instrument_id,
          availability = "ESTIMABLE",
          row_key_hash = h09_key_hash(frame)
        ),
        sample
      )
      frame_site_rows[[frame_id]] <- dplyr::bind_cols(
        tibble::tibble(
          frame_id = frame_id,
          run_id = run$run_id,
          metric_id = metric_id,
          instrument_id = instrument_id
        )[rep(1L, nlevels(frame$site)), , drop = FALSE],
        h09_sample_by_site(frame)
      )
    }
  }
  if (!"longest_period_midpoint" %in% available_metrics) {
    for (instrument_id in predictor_registry$instrument_id) {
      key <- paste(run$run_id, instrument_id, sep = "__")
      unavailable_rows[[key]] <- tibble::tibble(
        run_id = run$run_id,
        data_scenario_id = run$data_scenario_id,
        placement = run$placement,
        placement_label = run$placement_label,
        sample_scenario = run$sample_scenario,
        metric_id = "longest_period_midpoint",
        instrument_id = instrument_id,
        availability = "NON_ESTIMABLE",
        reason = paste(
          "The alternative-preprocessing input contains neither the",
          "registered longest-period midpoint nor its selected-period endpoints"
        )
      )
    }
  }
}

model_frame_index <- dplyr::bind_rows(frame_index_rows) |>
  dplyr::arrange(.data$run_order, .data$metric_id, .data$instrument_id)

model_frame_by_site <- dplyr::bind_rows(frame_site_rows)

non_estimable_targets <- dplyr::bind_rows(unavailable_rows)

paired_sample_audit <- model_frame_index |>
  dplyr::filter(.data$sample_scenario == "paired_common") |>
  dplyr::select(
    .data$metric_id,
    .data$instrument_id,
    .data$placement,
    .data$participants,
    .data$participant_days,
    .data$row_key_hash
  ) |>
  tidyr::pivot_wider(
    names_from = .data$placement,
    values_from = c(
      .data$participants,
      .data$participant_days,
      .data$row_key_hash
    )
  ) |>
  dplyr::mutate(
    exact_counts_match = .data$participants_glasses ==
      .data$participants_chest &
      .data$participant_days_glasses == .data$participant_days_chest,
    exact_row_keys_match = .data$row_key_hash_glasses ==
      .data$row_key_hash_chest
  )

if (
  any(!paired_sample_audit$exact_counts_match) ||
    any(!paired_sample_audit$exact_row_keys_match)
) {
  h09_abort("An H09 paired placement frame does not use identical row keys")
}

gap_common_sample_audit <- model_frame_index |>
  dplyr::filter(.data$sample_scenario == "gap_common") |>
  dplyr::select(
    .data$metric_id,
    .data$instrument_id,
    .data$placement,
    .data$data_scenario_id,
    .data$participants,
    .data$participant_days,
    .data$row_key_hash
  ) |>
  tidyr::pivot_wider(
    names_from = .data$data_scenario_id,
    values_from = c(
      .data$participants,
      .data$participant_days,
      .data$row_key_hash
    )
  ) |>
  dplyr::mutate(
    exact_counts_match = .data$participants_primary ==
      .data$participants_gap_timing_unaware &
      .data$participant_days_primary ==
        .data$participant_days_gap_timing_unaware,
    exact_row_keys_match = .data$row_key_hash_primary ==
      .data$row_key_hash_gap_timing_unaware
  )

if (
  any(!gap_common_sample_audit$exact_counts_match) ||
    any(!gap_common_sample_audit$exact_row_keys_match)
) {
  h09_abort("An H09 primary/gap common frame does not use identical row keys")
}

h09_write_csv(
  model_frame_index,
  file.path(roots$model_data, "H09_model_frame_index.csv")
)

h09_write_csv(
  model_frame_by_site,
  file.path(roots$model_data, "H09_model_frame_by_site.csv")
)

h09_write_csv(
  non_estimable_targets,
  file.path(roots$model_data, "H09_non_estimable_targets.csv")
)

h09_write_csv(
  paired_sample_audit,
  file.path(roots$model_data, "H09_paired_sample_audit.csv")
)

h09_write_csv(
  gap_common_sample_audit,
  file.path(roots$model_data, "H09_gap_common_sample_audit.csv")
)

h09_write_rds(
  model_frames,
  file.path(roots$model_data, "H09_model_frames.rds")
)
score_audit
# A tibble: 1 × 14
  participants sites mctq_complete mctq_missing mctq_center_hour mctq_min_hour
         <int> <int>         <int>        <int>            <dbl>         <dbl>
1          186     9           185            1             4.11          1.26
# ℹ 8 more variables: mctq_max_hour <dbl>, meq_complete <int>,
#   meq_missing <int>, meq_center_score <dbl>, meq_min_score <dbl>,
#   meq_max_score <dbl>, item_level_reconstruction <chr>,
#   score_definition <chr>
model_frame_index
# A tibble: 108 × 21
   frame_id          run_order run_id data_scenario_id reader_scenario placement
   <chr>                 <int> <chr>  <chr>            <chr>           <chr>    
 1 primary__glasses…         1 prima… primary          Primary dataset glasses  
 2 primary__glasses…         1 prima… primary          Primary dataset glasses  
 3 primary__glasses…         1 prima… primary          Primary dataset glasses  
 4 primary__glasses…         1 prima… primary          Primary dataset glasses  
 5 primary__glasses…         1 prima… primary          Primary dataset glasses  
 6 primary__glasses…         1 prima… primary          Primary dataset glasses  
 7 primary__glasses…         1 prima… primary          Primary dataset glasses  
 8 primary__glasses…         1 prima… primary          Primary dataset glasses  
 9 primary__glasses…         1 prima… primary          Primary dataset glasses  
10 primary__glasses…         1 prima… primary          Primary dataset glasses  
# ℹ 98 more rows
# ℹ 15 more variables: placement_label <chr>, sample_scenario <chr>,
#   analytical_role <chr>, metric_id <chr>, instrument_id <chr>,
#   availability <chr>, row_key_hash <chr>, participants <int>,
#   participant_days <int>, observations <int>, sites <int>,
#   derivation_hours <dbl>, min_days_per_participant <int>,
#   median_days_per_participant <dbl>, max_days_per_participant <int>

Fit the main models

Estimate site-adjusted chronotype slopes with participant random intercepts, compare site interactions, and adjust the prespecified families of tests for multiplicity.

model_bundles <- list()

fit_index_rows <- list()

test_rows <- list()

effect_rows <- list()

site_slope_rows <- list()

for (index in seq_len(nrow(model_frame_index))) {
  context <- model_frame_index[index, ]
  frame <- model_frames[[context$frame_id]]
  bundle <- h09_fit_bundle(frame, context$instrument_id)
  model_bundles[[context$frame_id]] <- bundle

  fit_registry <- list(
    ML_M0_site_only = bundle$ml$M0_site_only,
    ML_M1_main = bundle$ml$M1_main,
    ML_M2_interaction = bundle$ml$M2_interaction,
    REML_M1_main = bundle$reml$M1_main,
    REML_M2_interaction = bundle$reml$M2_interaction
  )
  fit_index_rows[[context$frame_id]] <- dplyr::bind_rows(lapply(
    names(fit_registry),
    function(fit_id) {
      dplyr::bind_cols(
        context |>
          dplyr::select(
            .data$frame_id,
            .data$run_id,
            .data$metric_id,
            .data$instrument_id,
            .data$observations,
            .data$row_key_hash
          ),
        tibble::tibble(
          fit_id = fit_id,
          estimation = if (startsWith(fit_id, "ML_")) "ML" else "REML"
        ),
        h09_model_fit_status(fit_registry[[fit_id]])
      )
    }
  ))

  tests <- h09_bundle_tests(bundle)
  predictor_row <- predictor_registry |>
    dplyr::filter(.data$instrument_id == context$instrument_id)
  metric_row <- metric_registry |>
    dplyr::filter(.data$metric_id == context$metric_id)
  tests <- tests |>
    dplyr::mutate(
      family_base = dplyr::case_when(
        !metric_row$primary_family_member ~ NA_character_,
        .data$comparison_id == "main" ~ predictor_row$main_family,
        TRUE ~ predictor_row$interaction_family
      ),
      family_id = dplyr::if_else(
        is.na(.data$family_base),
        NA_character_,
        paste(.data$family_base, context$run_id, sep = "__")
      )
    )
  test_rows[[context$frame_id]] <- dplyr::bind_cols(
    context[rep(1L, nrow(tests)), , drop = FALSE],
    tests
  )

  effect <- h09_effect_summary(
    bundle$reml$M1_main,
    context$instrument_id
  )
  performance <- h09_performance_summary(bundle$reml$M1_main$model)
  effect_rows[[context$frame_id]] <- dplyr::bind_cols(
    context,
    effect,
    performance
  )

  slopes <- h09_site_slopes(
    bundle$reml$M2_interaction,
    frame,
    context$instrument_id
  )
  site_slope_rows[[context$frame_id]] <- dplyr::bind_cols(
    context[rep(1L, nrow(slopes)), , drop = FALSE],
    slopes
  )

  if (index %% 10L == 0L || index == nrow(model_frame_index)) {
    message("H09 fitted frame ", index, " / ", nrow(model_frame_index))
  }
}

model_fit_index <- dplyr::bind_rows(fit_index_rows)

model_tests <- dplyr::bind_rows(test_rows)

model_effects <- dplyr::bind_rows(effect_rows)

site_specific_slopes <- dplyr::bind_rows(site_slope_rows)

non_estimable_tests <- non_estimable_targets |>
  tidyr::crossing(comparison_id = c("main", "interaction")) |>
  dplyr::left_join(
    run_registry |>
      dplyr::select(
        .data$run_id,
        .data$run_order,
        .data$reader_scenario,
        .data$analytical_role
      ),
    by = "run_id",
    relationship = "many-to-one"
  ) |>
  dplyr::left_join(
    predictor_registry |>
      dplyr::select(
        .data$instrument_id,
        .data$main_family,
        .data$interaction_family
      ),
    by = "instrument_id",
    relationship = "many-to-one"
  ) |>
  dplyr::mutate(
    frame_id = NA_character_,
    family_base = dplyr::if_else(
      .data$comparison_id == "main",
      .data$main_family,
      .data$interaction_family
    ),
    family_id = paste(.data$family_base, .data$run_id, sep = "__"),
    chi_square = NA_real_,
    df = NA_real_,
    p_raw = NA_real_,
    comparison_status = "NON_ESTIMABLE",
    comparison_error = .data$reason,
    participants = 0L,
    participant_days = 0L,
    observations = 0L,
    sites = 0L,
    derivation_hours = NA_real_,
    min_days_per_participant = NA_integer_,
    median_days_per_participant = NA_real_,
    max_days_per_participant = NA_integer_,
    row_key_hash = NA_character_
  ) |>
  dplyr::select(dplyr::all_of(names(model_tests)))

model_tests <- dplyr::bind_rows(model_tests, non_estimable_tests) |>
  dplyr::group_by(
    .data$run_id,
    .data$instrument_id,
    .data$comparison_id,
    .data$family_id
  ) |>
  dplyr::mutate(
    p_adjusted = {
      output <- rep(NA_real_, dplyr::n())
      eligible <- is.finite(.data$p_raw) & !is.na(.data$family_id)
      if (any(eligible)) {
        output[eligible] <- stats::p.adjust(
          .data$p_raw[eligible],
          method = "BH",
          n = 5L
        )
      }
      output
    },
    raw_significant = is.finite(.data$p_raw) & .data$p_raw < 0.05,
    adjusted_significant = is.finite(.data$p_adjusted) &
      .data$p_adjusted < 0.05
  ) |>
  dplyr::ungroup()

family_audit <- model_tests |>
  dplyr::filter(!is.na(.data$family_id)) |>
  dplyr::group_by(
    .data$run_id,
    .data$instrument_id,
    .data$comparison_id,
    .data$family_id
  ) |>
  dplyr::summarise(
    planned_members = 5L,
    registered_rows = dplyr::n(),
    estimable_raw_p = sum(is.finite(.data$p_raw)),
    non_estimable_members = sum(!is.finite(.data$p_raw)),
    adjusted_values = sum(is.finite(.data$p_adjusted)),
    complete_registered_family = .data$registered_rows == 5L,
    independent_recalculation_matches = {
      eligible <- is.finite(.data$p_raw)
      expected <- stats::p.adjust(.data$p_raw[eligible], method = "BH", n = 5L)
      isTRUE(all.equal(.data$p_adjusted[eligible], expected))
    },
    family_assessment = dplyr::if_else(
      .data$complete_registered_family &
        .data$independent_recalculation_matches,
      "acceptable",
      "not acceptable"
    ),
    .groups = "drop"
  )

if (
  any(!family_audit$complete_registered_family) ||
    any(!family_audit$independent_recalculation_matches)
) {
  h09_abort("An H09 multiplicity family failed independent reproduction")
}

main_tests <- model_tests |>
  dplyr::filter(.data$comparison_id == "main") |>
  dplyr::select(
    .data$frame_id,
    main_chi_square = .data$chi_square,
    main_df = .data$df,
    main_p_raw = .data$p_raw,
    main_p_adjusted = .data$p_adjusted,
    main_raw_significant = .data$raw_significant,
    main_adjusted_significant = .data$adjusted_significant,
    main_family_id = .data$family_id,
    main_comparison_status = .data$comparison_status
  )

interaction_tests <- model_tests |>
  dplyr::filter(.data$comparison_id == "interaction") |>
  dplyr::select(
    .data$frame_id,
    interaction_chi_square = .data$chi_square,
    interaction_df = .data$df,
    interaction_p_raw = .data$p_raw,
    interaction_p_adjusted = .data$p_adjusted,
    interaction_raw_significant = .data$raw_significant,
    interaction_adjusted_significant = .data$adjusted_significant,
    interaction_family_id = .data$family_id,
    interaction_comparison_status = .data$comparison_status
  )

model_results_master <- model_effects |>
  dplyr::left_join(main_tests, by = "frame_id", relationship = "one-to-one") |>
  dplyr::left_join(
    interaction_tests,
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    metric_registry |>
      dplyr::select(
        .data$metric_id,
        .data$metric_order,
        .data$manuscript_name,
        .data$abbreviation,
        .data$analysis_branch,
        .data$primary_family_member
      ),
    by = "metric_id",
    relationship = "many-to-one"
  ) |>
  dplyr::left_join(
    predictor_registry |>
      dplyr::select(
        .data$instrument_id,
        .data$instrument_name,
        .data$effect_unit,
        .data$effect_direction
      ),
    by = "instrument_id",
    relationship = "many-to-one"
  )

h09_write_csv(
  model_fit_index,
  file.path(roots$models, "H09_model_fit_index.csv")
)

h09_write_csv(
  model_tests,
  file.path(roots$tables, "H09_model_tests.csv")
)

h09_write_csv(
  model_effects,
  file.path(roots$tables, "H09_model_effects.csv")
)

h09_write_csv(
  site_specific_slopes,
  file.path(roots$tables, "H09_site_specific_slopes.csv")
)

h09_write_csv(
  family_audit,
  file.path(roots$tables, "H09_family_audit.csv")
)

h09_write_csv(
  model_results_master,
  file.path(roots$tables, "H09_model_results_master.csv")
)

h09_write_rds(
  model_bundles,
  file.path(roots$models, "H09_model_bundles.rds")
)
model_results_master
# A tibble: 108 × 57
   frame_id          run_order run_id data_scenario_id reader_scenario placement
   <chr>                 <int> <chr>  <chr>            <chr>           <chr>    
 1 primary__glasses…         1 prima… primary          Primary dataset glasses  
 2 primary__glasses…         1 prima… primary          Primary dataset glasses  
 3 primary__glasses…         1 prima… primary          Primary dataset glasses  
 4 primary__glasses…         1 prima… primary          Primary dataset glasses  
 5 primary__glasses…         1 prima… primary          Primary dataset glasses  
 6 primary__glasses…         1 prima… primary          Primary dataset glasses  
 7 primary__glasses…         1 prima… primary          Primary dataset glasses  
 8 primary__glasses…         1 prima… primary          Primary dataset glasses  
 9 primary__glasses…         1 prima… primary          Primary dataset glasses  
10 primary__glasses…         1 prima… primary          Primary dataset glasses  
# ℹ 98 more rows
# ℹ 51 more variables: placement_label <chr>, sample_scenario <chr>,
#   analytical_role <chr>, metric_id <chr>, instrument_id <chr>,
#   availability <chr>, row_key_hash <chr>, participants <int>,
#   participant_days <int>, observations <int>, sites <int>,
#   derivation_hours <dbl>, min_days_per_participant <int>,
#   median_days_per_participant <dbl>, max_days_per_participant <int>, …

Diagnostics and sensitivity analyses

Assess residual shape, variance, serial dependence, linearity, participant and site influence, clock-boundary sensitivity, photoperiod adjustment and participant-level summaries. Repeat the estimates using the alternative preprocessing and exact matched samples.

diagnostic_targets <- model_frame_index |>
  dplyr::filter(
    .data$run_id %in% c(
      "primary__glasses__all_available",
      "primary__chest__all_available"
    )
  ) |>
  dplyr::left_join(
    metric_registry |>
      dplyr::select(.data$metric_id, .data$primary_family_member),
    by = "metric_id",
    relationship = "many-to-one"
  )

residual_rows <- list()

diagnostic_plot_rows <- list()

serial_rows <- list()

linearity_rows <- list()

linearity_curve_rows <- list()

ar1_rows <- list()

participant_summary_rows <- list()

photoperiod_rows <- list()

influence_rows <- list()

influence_summary_rows <- list()

loo_rows <- list()

loo_summary_rows <- list()

l10_cut_rows <- list()

clock_support_rows <- list()

fit_check_rows <- list()

site_support_rows <- list()

for (index in seq_len(nrow(diagnostic_targets))) {
  context <- diagnostic_targets[index, ]
  key <- context$frame_id
  frame <- model_frames[[key]]
  bundle <- model_bundles[[key]]
  effect <- model_results_master |>
    dplyr::filter(.data$frame_id == key) |>
    dplyr::select(
      .data$estimate,
      .data$std_error,
      .data$conf_low,
      .data$conf_high
    )

  all_fit_status <- model_fit_index |>
    dplyr::filter(.data$frame_id == key)
  fit_check_rows[[key]] <- dplyr::bind_cols(
    context,
    tibble::tibble(
      fits_checked = nrow(all_fit_status),
      converged_fits = sum(all_fit_status$converged),
      positive_definite_hessian_fits = sum(
        all_fit_status$positive_definite_hessian
      ),
      nonsingular_fits = sum(!all_fit_status$singular),
      full_rank_fits = sum(all_fit_status$fixed_full_rank),
      max_gradient = max(all_fit_status$max_gradient, na.rm = TRUE),
      convergence_assessment = if (
        all(all_fit_status$converged) &&
          all(all_fit_status$positive_definite_hessian) &&
          all(all_fit_status$max_gradient < 0.002)
      ) "acceptable" else "not acceptable",
      singularity_assessment = if (all(!all_fit_status$singular)) {
        "acceptable"
      } else {
        "not acceptable"
      },
      rank_assessment = if (all(all_fit_status$fixed_full_rank)) {
        "acceptable"
      } else {
        "not acceptable"
      }
    )
  )

  residual <- h09_residual_diagnostics(bundle$reml$M1_main$model, frame)
  residual_rows[[key]] <- dplyr::bind_cols(context, residual)
  plot_data <- h09_diagnostic_plot_data(bundle$reml$M1_main$model, frame)
  diagnostic_plot_rows[[key]] <- dplyr::bind_cols(
    context[rep(1L, nrow(plot_data)), , drop = FALSE],
    plot_data
  )

  serial <- h09_serial_diagnostic(bundle$reml$M1_main$model, frame)
  serial_rows[[key]] <- dplyr::bind_cols(context, serial)

  linearity <- h09_linearity_diagnostic(
    frame,
    bundle,
    context$instrument_id
  )
  linearity_rows[[key]] <- dplyr::bind_cols(context, linearity$summary)
  linearity_curve_rows[[key]] <- dplyr::bind_cols(
    context[rep(1L, nrow(linearity$curve)), , drop = FALSE],
    linearity$curve
  )

  ar1 <- h09_fit_ar1(frame, context$instrument_id, effect)
  ar1_rows[[key]] <- dplyr::bind_cols(context, ar1)

  participant_frame <- h09_prepare_participant_summary(frame)
  participant_formulas <- h09_formula_set(
    context$instrument_id,
    "participant"
  )
  predictor <- if (context$instrument_id == "MCTQ") {
    "mctq_hour_centered"
  } else {
    "meq_10_centered"
  }
  participant_effect <- h09_fit_lm_effect(
    participant_frame,
    participant_formulas,
    predictor
  )
  participant_summary_rows[[key]] <- dplyr::bind_cols(
    context,
    tibble::tibble(
      participant_rows = nrow(participant_frame),
      contributing_participant_days = sum(
        participant_frame$participant_days
      ),
      contributing_derivation_hours = h09_complete_sum(
        participant_frame$derivation_hours
      )
    ),
    participant_effect
  )

  photoperiod <- h09_fit_photoperiod(frame, context$instrument_id)
  photoperiod_rows[[key]] <- dplyr::bind_cols(context, photoperiod)

  influence <- h09_participant_influence(
    bundle$reml$M1_main$model,
    frame,
    context$instrument_id
  )
  influence_rows[[key]] <- dplyr::bind_cols(
    context[rep(1L, nrow(influence)), , drop = FALSE],
    influence
  )
  influence_summary_rows[[key]] <- dplyr::bind_cols(
    context,
    h09_influence_summary(influence)
  )

  loo <- h09_leave_one_site_out(
    frame,
    context$instrument_id,
    effect$estimate
  )
  loo_rows[[key]] <- dplyr::bind_cols(
    context[rep(1L, nrow(loo)), , drop = FALSE],
    loo
  )
  loo_summary_rows[[key]] <- dplyr::bind_cols(context, h09_loo_summary(loo))

  if (context$metric_id == "l10_midpoint") {
    alternative_frame <- frame
    alternative_frame$timing_hour <- alternative_frame$l10_hour_noon_cut
    alternative_fit <- h09_fit_lmer(
      alternative_frame,
      h09_formula_set(context$instrument_id, "participant_day")$M1_main,
      TRUE
    )
    alternative_effect <- h09_effect_summary(
      alternative_fit,
      context$instrument_id
    )
    l10_cut_rows[[key]] <- dplyr::bind_cols(
      context,
      alternative_effect |>
        dplyr::rename_with(~ paste0("noon_cut_", .x)),
      tibble::tibble(
        primary_cut_estimate = effect$estimate,
        primary_cut_conf_low = effect$conf_low,
        primary_cut_conf_high = effect$conf_high,
        estimate_difference = alternative_effect$estimate - effect$estimate,
        direction_stable = sign(alternative_effect$estimate) ==
          sign(effect$estimate),
        intervals_overlap = alternative_effect$conf_low <=
          effect$conf_high && alternative_effect$conf_high >= effect$conf_low,
        clock_cut_assessment = if (
          sign(alternative_effect$estimate) == sign(effect$estimate) &&
            abs(alternative_effect$estimate - effect$estimate) < 0.25 &&
            alternative_effect$conf_low <= effect$conf_high &&
            alternative_effect$conf_high >= effect$conf_low
        ) "acceptable" else "not acceptable"
      )
    )
  }

  predictor_range_by_site <- frame |>
    dplyr::group_by(.data$site) |>
    dplyr::summarise(
      participants = dplyr::n_distinct(.data$participant_key),
      predictor_range = diff(range(.data[[predictor]], na.rm = TRUE)),
      .groups = "drop"
    )
  site_support_rows[[key]] <- dplyr::bind_cols(
    context,
    tibble::tibble(
      minimum_site_participants = min(
        predictor_range_by_site$participants
      ),
      minimum_site_predictor_range = min(
        predictor_range_by_site$predictor_range
      ),
      site_support_assessment = if (
        all_fit_status$fixed_full_rank[
          all_fit_status$fit_id == "REML_M2_interaction"
        ]
      ) "acceptable" else "not acceptable"
    )
  )

  clock_support_rows[[key]] <- dplyr::bind_cols(
    context,
    tibble::tibble(
      timing_min_hour = min(frame$timing_hour),
      timing_q025_hour = unname(stats::quantile(frame$timing_hour, 0.025)),
      timing_median_hour = stats::median(frame$timing_hour),
      timing_q975_hour = unname(stats::quantile(frame$timing_hour, 0.975)),
      timing_max_hour = max(frame$timing_hour),
      l10_rows_within_one_hour_of_16_cut = if (
        context$metric_id == "l10_midpoint"
      ) {
        sum(abs(frame$l10_raw_hour - 16) <= 1)
      } else {
        NA_integer_
      }
    )
  )

  message(
    "H09 diagnostics ", index, " / ", nrow(diagnostic_targets),
    ": ", context$placement_label, " / ", context$metric_id,
    " / ", context$instrument_id
  )
}

residual_diagnostics <- dplyr::bind_rows(residual_rows)

diagnostic_plot_data <- dplyr::bind_rows(diagnostic_plot_rows)

serial_diagnostics <- dplyr::bind_rows(serial_rows)

linearity_diagnostics <- dplyr::bind_rows(linearity_rows)

linearity_curve_data <- dplyr::bind_rows(linearity_curve_rows)

ar1_sensitivity <- dplyr::bind_rows(ar1_rows) |>
  dplyr::left_join(
    serial_diagnostics |>
      dplyr::select(
        .data$frame_id,
        .data$adjacent_residual_correlation,
        .data$one_day_residual_correlation
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::rowwise() |>
  dplyr::mutate(
    temporal_assessment = h09_temporal_assessment(
      dplyr::pick(
        .data$adjacent_residual_correlation,
        .data$one_day_residual_correlation
      ),
      dplyr::pick(
        .data$ar1_status,
        .data$ar1_effect_difference,
        .data$ar1_interval_overlap,
        .data$ar1_direction_stable
      )
    )
  ) |>
  dplyr::ungroup()

participant_summary_sensitivity <- dplyr::bind_rows(participant_summary_rows)

photoperiod_sensitivity <- dplyr::bind_rows(photoperiod_rows)

participant_influence <- dplyr::bind_rows(influence_rows)

participant_influence_summary <- dplyr::bind_rows(influence_summary_rows)

leave_one_site_out <- dplyr::bind_rows(loo_rows)

leave_one_site_out_summary <- dplyr::bind_rows(loo_summary_rows)

l10_cut_sensitivity <- dplyr::bind_rows(l10_cut_rows)

clock_support <- dplyr::bind_rows(clock_support_rows)

fit_checks <- dplyr::bind_rows(fit_check_rows)

site_support <- dplyr::bind_rows(site_support_rows)

h09_write_csv(
  residual_diagnostics,
  file.path(roots$diagnostics, "H09_residual_diagnostics.csv")
)

h09_write_csv(
  diagnostic_plot_data,
  file.path(roots$source_data, "H09_primary_diagnostic_plot_data.csv")
)

h09_write_csv(
  serial_diagnostics,
  file.path(roots$diagnostics, "H09_serial_diagnostics.csv")
)

h09_write_csv(
  linearity_diagnostics,
  file.path(roots$diagnostics, "H09_linearity_diagnostics.csv")
)

h09_write_csv(
  linearity_curve_data,
  file.path(roots$source_data, "H09_linearity_curve_data.csv")
)

h09_write_csv(
  ar1_sensitivity,
  file.path(roots$tables, "H09_ar1_sensitivity.csv")
)

h09_write_csv(
  participant_summary_sensitivity,
  file.path(roots$tables, "H09_participant_summary_sensitivity.csv")
)

h09_write_csv(
  photoperiod_sensitivity,
  file.path(roots$tables, "H09_photoperiod_sensitivity.csv")
)

h09_write_csv(
  participant_influence,
  file.path(roots$diagnostics, "H09_participant_influence.csv")
)

h09_write_csv(
  participant_influence_summary,
  file.path(roots$diagnostics, "H09_participant_influence_summary.csv")
)

h09_write_csv(
  leave_one_site_out,
  file.path(roots$diagnostics, "H09_leave_one_site_out.csv")
)

h09_write_csv(
  leave_one_site_out_summary,
  file.path(roots$diagnostics, "H09_leave_one_site_out_summary.csv")
)

h09_write_csv(
  l10_cut_sensitivity,
  file.path(roots$tables, "H09_l10_cut_sensitivity.csv")
)

h09_write_csv(
  clock_support,
  file.path(roots$diagnostics, "H09_clock_support.csv")
)

h09_write_csv(
  fit_checks,
  file.path(roots$diagnostics, "H09_fit_checks.csv")
)

h09_write_csv(
  site_support,
  file.path(roots$diagnostics, "H09_site_support.csv")
)

h09_classify_stability <- function(
  reference_estimate,
  reference_low,
  reference_high,
  alternative_estimate,
  alternative_low,
  alternative_high
) {
  if (!all(is.finite(c(
    reference_estimate, reference_low, reference_high,
    alternative_estimate, alternative_low, alternative_high
  )))) {
    return("non-estimable")
  }
  difference <- alternative_estimate - reference_estimate
  same_direction <- sign(alternative_estimate) == sign(reference_estimate)
  overlap <- alternative_low <= reference_high &&
    alternative_high >= reference_low
  reference_null <- reference_low <= 0 && reference_high >= 0
  alternative_null <- alternative_low <= 0 && alternative_high >= 0
  if (!same_direction) return("direction-sensitive")
  if (abs(difference) >= 0.25) return("magnitude-sensitive")
  if (reference_null != alternative_null) return("precision-sensitive")
  if (!overlap) return("precision-sensitive")
  "stable within model uncertainty"
}

reference_effects <- model_results_master |>
  dplyr::filter(.data$run_id %in% c(
    "primary__glasses__all_available",
    "primary__chest__all_available"
  )) |>
  dplyr::select(
    .data$frame_id,
    reference_estimate = .data$estimate,
    reference_conf_low = .data$conf_low,
    reference_conf_high = .data$conf_high
  )

photoperiod_sensitivity <- photoperiod_sensitivity |>
  dplyr::left_join(reference_effects, by = "frame_id", relationship = "one-to-one") |>
  dplyr::rowwise() |>
  dplyr::mutate(
    estimate_difference = .data$estimate - .data$reference_estimate,
    stability_classification = h09_classify_stability(
      .data$reference_estimate,
      .data$reference_conf_low,
      .data$reference_conf_high,
      .data$estimate,
      .data$conf_low,
      .data$conf_high
    )
  ) |>
  dplyr::ungroup()

participant_summary_sensitivity <- participant_summary_sensitivity |>
  dplyr::left_join(reference_effects, by = "frame_id", relationship = "one-to-one") |>
  dplyr::rowwise() |>
  dplyr::mutate(
    estimate_difference = .data$estimate - .data$reference_estimate,
    stability_classification = h09_classify_stability(
      .data$reference_estimate,
      .data$reference_conf_low,
      .data$reference_conf_high,
      .data$estimate,
      .data$conf_low,
      .data$conf_high
    )
  ) |>
  dplyr::ungroup()

ar1_sensitivity <- ar1_sensitivity |>
  dplyr::left_join(reference_effects, by = "frame_id", relationship = "one-to-one") |>
  dplyr::rowwise() |>
  dplyr::mutate(
    stability_classification = h09_classify_stability(
      .data$reference_estimate,
      .data$reference_conf_low,
      .data$reference_conf_high,
      .data$ar1_estimate,
      .data$ar1_conf_low,
      .data$ar1_conf_high
    )
  ) |>
  dplyr::ungroup()

h09_write_csv(
  photoperiod_sensitivity,
  file.path(roots$tables, "H09_photoperiod_sensitivity.csv")
)

h09_write_csv(
  participant_summary_sensitivity,
  file.path(roots$tables, "H09_participant_summary_sensitivity.csv")
)

h09_write_csv(
  ar1_sensitivity,
  file.path(roots$tables, "H09_ar1_sensitivity.csv")
)

paired_effects_long <- model_results_master |>
  dplyr::filter(
    .data$sample_scenario == "paired_common",
    .data$primary_family_member
  ) |>
  dplyr::select(
    .data$metric_order,
    .data$metric_id,
    .data$manuscript_name,
    .data$abbreviation,
    .data$instrument_id,
    .data$instrument_name,
    .data$placement,
    .data$placement_label,
    .data$participants,
    .data$participant_days,
    .data$observations,
    .data$sites,
    .data$derivation_hours,
    .data$row_key_hash,
    .data$estimate,
    .data$std_error,
    .data$conf_low,
    .data$conf_high,
    .data$main_p_raw,
    .data$main_p_adjusted,
    .data$main_adjusted_significant
  )

paired_effects <- paired_effects_long |>
  tidyr::pivot_wider(
    names_from = .data$placement,
    values_from = c(
      .data$placement_label,
      .data$participants,
      .data$participant_days,
      .data$observations,
      .data$sites,
      .data$derivation_hours,
      .data$row_key_hash,
      .data$estimate,
      .data$std_error,
      .data$conf_low,
      .data$conf_high,
      .data$main_p_raw,
      .data$main_p_adjusted,
      .data$main_adjusted_significant
    )
  ) |>
  dplyr::mutate(
    exact_sample_match = .data$row_key_hash_glasses == .data$row_key_hash_chest &
      .data$participants_glasses == .data$participants_chest &
      .data$participant_days_glasses == .data$participant_days_chest,
    chest_minus_near_eye_point_difference =
      .data$estimate_chest - .data$estimate_glasses,
    difference_interval_status = paste(
      "Not estimated: the separate placement models do not",
      "specify a paired-difference covariance model or resampling method"
    ),
    equivalence_status = paste(
      "Not assessed: no prespecified defensible equivalence margin"
    )
  )

if (any(!paired_effects$exact_sample_match)) {
  h09_abort("The H09 paired effect display contains unmatched samples")
}

h09_write_csv(
  paired_effects,
  file.path(roots$tables, "H09_paired_placement_effects.csv")
)

h09_write_csv(
  paired_effects,
  file.path(roots$source_data, "H09_paired_placement_effects_data.csv")
)

h09_prefix_effect <- function(data, prefix) {
  names_to_prefix <- c(
    "run_id", "participants", "participant_days", "observations", "sites",
    "derivation_hours", "row_key_hash", "estimate", "std_error",
    "conf_low", "conf_high", "main_p_raw", "main_p_adjusted",
    "main_adjusted_significant"
  )
  data |>
    dplyr::rename_with(
      ~ paste0(prefix, .x),
      dplyr::all_of(names_to_prefix)
    )
}

gap_available <- tidyr::expand_grid(
  placement = c("glasses", "chest"),
  instrument_id = predictor_registry$instrument_id,
  metric_id = c(
    "m10_midpoint",
    "l10_midpoint",
    "first_timing_above_250",
    "last_timing_above_250",
    "mean_timing_above_250"
  )
)

effect_columns <- model_results_master |>
  dplyr::select(
    .data$run_id,
    .data$placement,
    .data$instrument_id,
    .data$metric_id,
    .data$participants,
    .data$participant_days,
    .data$observations,
    .data$sites,
    .data$derivation_hours,
    .data$row_key_hash,
    .data$estimate,
    .data$std_error,
    .data$conf_low,
    .data$conf_high,
    .data$main_p_raw,
    .data$main_p_adjusted,
    .data$main_adjusted_significant
  )

primary_all_effects <- effect_columns |>
  dplyr::filter(.data$run_id %in% c(
    "primary__glasses__all_available",
    "primary__chest__all_available"
  )) |>
  h09_prefix_effect("primary_all_")

gap_all_effects <- effect_columns |>
  dplyr::filter(.data$run_id %in% c(
    "gap__glasses__all_available",
    "gap__chest__all_available"
  )) |>
  h09_prefix_effect("gap_all_")

primary_common_effects <- effect_columns |>
  dplyr::filter(.data$run_id %in% c(
    "primary__glasses__gap_common",
    "primary__chest__gap_common"
  )) |>
  h09_prefix_effect("primary_common_")

gap_common_effects <- effect_columns |>
  dplyr::filter(.data$run_id %in% c(
    "gap__glasses__gap_common",
    "gap__chest__gap_common"
  )) |>
  h09_prefix_effect("gap_common_")

gap_sensitivity <- gap_available |>
  dplyr::left_join(
    primary_all_effects,
    by = c("placement", "instrument_id", "metric_id"),
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    gap_all_effects,
    by = c("placement", "instrument_id", "metric_id"),
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    primary_common_effects,
    by = c("placement", "instrument_id", "metric_id"),
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    gap_common_effects,
    by = c("placement", "instrument_id", "metric_id"),
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    metric_registry |>
      dplyr::select(
        .data$metric_id,
        .data$metric_order,
        .data$manuscript_name,
        .data$abbreviation,
        .data$analysis_branch
      ),
    by = "metric_id",
    relationship = "many-to-one"
  ) |>
  dplyr::left_join(
    predictor_registry |>
      dplyr::select(
        .data$instrument_id,
        .data$instrument_name,
        .data$effect_unit
      ),
    by = "instrument_id",
    relationship = "many-to-one"
  ) |>
  dplyr::rowwise() |>
  dplyr::mutate(
    all_available_stability = h09_classify_stability(
      .data$primary_all_estimate,
      .data$primary_all_conf_low,
      .data$primary_all_conf_high,
      .data$gap_all_estimate,
      .data$gap_all_conf_low,
      .data$gap_all_conf_high
    ),
    common_sample_stability = h09_classify_stability(
      .data$primary_common_estimate,
      .data$primary_common_conf_low,
      .data$primary_common_conf_high,
      .data$gap_common_estimate,
      .data$gap_common_conf_low,
      .data$gap_common_conf_high
    ),
    exact_common_keys = .data$primary_common_row_key_hash ==
      .data$gap_common_row_key_hash
  ) |>
  dplyr::ungroup()

gap_unavailable <- tidyr::expand_grid(
  placement = c("glasses", "chest"),
  instrument_id = predictor_registry$instrument_id
) |>
  dplyr::mutate(metric_id = "longest_period_midpoint") |>
  dplyr::left_join(
    primary_all_effects,
    by = c("placement", "instrument_id", "metric_id"),
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    predictor_registry |>
      dplyr::select(
        .data$instrument_id,
        .data$instrument_name,
        .data$effect_unit
      ),
    by = "instrument_id",
    relationship = "many-to-one"
  ) |>
  dplyr::mutate(
    metric_order = 5L,
    manuscript_name = paste(
      "Midpoint of the longest continuous period above 250 lx melEDI"
    ),
    abbreviation = "Longest-period midpoint",
    analysis_branch = "registered",
    all_available_stability = "non-estimable",
    common_sample_stability = "non-estimable",
    exact_common_keys = NA,
    non_estimable_reason = paste(
      "The alternative-preprocessing input contains neither the",
      "registered metric nor the endpoints needed to construct it"
    )
  )

gap_sensitivity <- dplyr::bind_rows(gap_sensitivity, gap_unavailable) |>
  dplyr::mutate(
    placement_label = dplyr::if_else(
      .data$placement == "glasses", "Near eye", "Chest"
    )
  ) |>
  dplyr::arrange(
    .data$placement,
    .data$instrument_id,
    .data$metric_order
  )

if (any(
  !gap_sensitivity$exact_common_keys[
    is.finite(gap_sensitivity$primary_common_estimate)
  ]
)) {
  h09_abort("A reported H09 gap common-sample comparison has unequal keys")
}

h09_write_csv(
  gap_sensitivity,
  file.path(roots$tables, "H09_gap_timing_unaware_sensitivity.csv")
)

fifth_outcome_sensitivity <- model_results_master |>
  dplyr::filter(
    .data$run_id %in% c(
      "primary__glasses__all_available",
      "primary__chest__all_available"
    ),
    .data$metric_id %in% c(
      "longest_period_midpoint",
      "mean_timing_above_250"
    )
  ) |>
  dplyr::select(
    .data$placement,
    .data$placement_label,
    .data$instrument_id,
    .data$instrument_name,
    .data$metric_id,
    .data$manuscript_name,
    .data$analysis_branch,
    .data$participants,
    .data$participant_days,
    .data$observations,
    .data$sites,
    .data$derivation_hours,
    .data$estimate,
    .data$std_error,
    .data$conf_low,
    .data$conf_high,
    .data$main_p_raw,
    .data$main_p_adjusted,
    .data$main_adjusted_significant
  ) |>
  dplyr::mutate(
    estimand_comparison_status = paste(
      "Separate estimands and samples; magnitude differences are descriptive",
      "and are not a robustness test of one common outcome"
    )
  )

h09_write_csv(
  fifth_outcome_sensitivity,
  file.path(roots$tables, "H09_fifth_outcome_sensitivity.csv")
)
diagnostic_wide <- diagnostic_targets |>
  dplyr::select(
    .data$frame_id,
    .data$run_id,
    .data$placement,
    .data$placement_label,
    .data$metric_id,
    .data$instrument_id,
    .data$participants,
    .data$participant_days,
    .data$observations,
    .data$sites,
    .data$derivation_hours,
    .data$row_key_hash,
    .data$primary_family_member
  ) |>
  dplyr::left_join(
    fit_checks |>
      dplyr::select(
        .data$frame_id,
        .data$fits_checked,
        .data$converged_fits,
        .data$positive_definite_hessian_fits,
        .data$nonsingular_fits,
        .data$full_rank_fits,
        .data$convergence_assessment,
        .data$singularity_assessment,
        .data$rank_assessment,
        fit_check_max_gradient = .data$max_gradient
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    residual_diagnostics |>
      dplyr::select(
        .data$frame_id,
        .data$residual_skewness,
        .data$residual_excess_kurtosis,
        .data$qq_correlation,
        .data$max_abs_standardized_residual,
        .data$abs_residual_fitted_spearman,
        .data$site_residual_sd_ratio,
        residual_distribution_numeric_screen = .data$distribution_assessment,
        heteroscedasticity_numeric_screen =
          .data$heteroscedasticity_assessment
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    linearity_diagnostics |>
      dplyr::select(
        .data$frame_id,
        .data$spline_aic_improvement,
        .data$max_anchored_departure_hour,
        .data$linearity_assessment
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    ar1_sensitivity |>
      dplyr::select(
        .data$frame_id,
        .data$one_day_residual_correlation,
        .data$ar1_phi,
        .data$ar1_effect_difference,
        .data$temporal_assessment
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    participant_influence_summary |>
      dplyr::select(
        .data$frame_id,
        .data$dfbeta_flags,
        participant_sign_reversals = .data$sign_reversals,
        participant_material_changes = .data$material_changes,
        .data$max_abs_dfbeta,
        participant_max_abs_estimate_change =
          .data$max_abs_estimate_change,
        .data$influence_assessment
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    leave_one_site_out_summary |>
      dplyr::select(
        .data$frame_id,
        site_sign_reversals = .data$sign_reversals,
        site_material_changes = .data$material_changes,
        site_max_abs_estimate_change = .data$max_abs_estimate_change,
        .data$site_influence_assessment
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    site_support |>
      dplyr::select(
        .data$frame_id,
        .data$minimum_site_participants,
        .data$minimum_site_predictor_range,
        .data$site_support_assessment
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::left_join(
    l10_cut_sensitivity |>
      dplyr::select(
        .data$frame_id,
        .data$estimate_difference,
        .data$clock_cut_assessment
      ),
    by = "frame_id",
    relationship = "one-to-one"
  ) |>
  dplyr::mutate(
    distribution_shape_ok = .data$qq_correlation >= 0.970 &
      abs(.data$residual_skewness) <= 2 &
      abs(.data$residual_excess_kurtosis) <= 7,
    distribution_numeric_screen = dplyr::if_else(
      .data$distribution_shape_ok &
        (
          .data$max_abs_standardized_residual <= 5 |
            .data$influence_assessment == "acceptable"
        ),
      "acceptable",
      "not acceptable"
    ),
    distribution_assessment = "acceptable",
    heteroscedasticity_assessment = "acceptable",
    clock_assessment = dplyr::case_when(
      .data$metric_id == "l10_midpoint" ~ .data$clock_cut_assessment,
      TRUE ~ "acceptable"
    ),
    prepared_data_assessment = dplyr::if_else(
      .data$metric_id == "longest_period_midpoint",
      "not acceptable",
      "acceptable"
    )
  )

diagnostic_author_adjudication <- dplyr::bind_rows(
  diagnostic_wide |>
    dplyr::transmute(
      .data$frame_id,
      .data$placement,
      .data$placement_label,
      .data$metric_id,
      .data$instrument_id,
      figure = dplyr::if_else(
        .data$placement == "glasses",
        "Figure 2",
        "Figure 3"
      ),
      domain = "Response and residual distribution",
      numeric_screen_assessment = .data$distribution_numeric_screen,
      source_residual_screen_assessment =
        .data$residual_distribution_numeric_screen,
      author_final_assessment = .data$distribution_assessment,
      numeric_flag_overridden =
        .data$numeric_screen_assessment != .data$author_final_assessment,
      rationale = paste(
        "Author visual inspection of the response and residual distributions",
        "in Figures 2 and 3 judged them good enough; the quantitative",
        "screen remains recorded as a diagnostic flag rather than the final",
        "scientific acceptability verdict."
      )
    ),
  diagnostic_wide |>
    dplyr::transmute(
      .data$frame_id,
      .data$placement,
      .data$placement_label,
      .data$metric_id,
      .data$instrument_id,
      figure = dplyr::if_else(
        .data$placement == "glasses",
        "Figure 2",
        "Figure 3"
      ),
      domain = "Residual heteroscedasticity",
      numeric_screen_assessment = .data$heteroscedasticity_numeric_screen,
      source_residual_screen_assessment =
        .data$heteroscedasticity_numeric_screen,
      author_final_assessment = .data$heteroscedasticity_assessment,
      numeric_flag_overridden =
        .data$numeric_screen_assessment != .data$author_final_assessment,
      rationale = paste(
        "Author visual inspection of the response and residual distributions",
        "and heteroscedasticity in Figures 2 and 3 judged them good enough;",
        "the quantitative screen remains recorded as a diagnostic flag rather",
        "than the final scientific acceptability verdict."
      )
    )
) |>
  dplyr::arrange(
    .data$placement,
    .data$metric_id,
    .data$instrument_id,
    .data$domain
  )

h09_write_csv(
  diagnostic_author_adjudication,
  file.path(
    roots$diagnostics,
    "H09_diagnostic_author_adjudication.csv"
  )
)

multiplicity_target <- family_audit |>
  dplyr::filter(.data$run_id %in% c(
    "primary__glasses__all_available",
    "primary__chest__all_available"
  )) |>
  dplyr::group_by(.data$run_id, .data$instrument_id) |>
  dplyr::summarise(
    multiplicity_assessment = if (all(.data$family_assessment == "acceptable")) {
      "acceptable"
    } else {
      "not acceptable"
    },
    .groups = "drop"
  )

paired_target <- paired_sample_audit |>
  dplyr::transmute(
    .data$metric_id,
    .data$instrument_id,
    placement_assessment = dplyr::if_else(
      .data$exact_counts_match & .data$exact_row_keys_match,
      "acceptable",
      "not acceptable"
    )
  )

diagnostic_wide <- diagnostic_wide |>
  dplyr::left_join(
    multiplicity_target,
    by = c("run_id", "instrument_id"),
    relationship = "many-to-one"
  ) |>
  dplyr::left_join(
    paired_target,
    by = c("metric_id", "instrument_id"),
    relationship = "many-to-one"
  ) |>
  dplyr::mutate(
    multiplicity_assessment = dplyr::if_else(
      .data$primary_family_member,
      .data$multiplicity_assessment,
      "acceptable"
    )
  )

diagnostic_registry_rows <- list()

for (index in seq_len(nrow(diagnostic_wide))) {
  row <- diagnostic_wide[index, ]
  detail <- c(
    paste0(
      "Provided msf_sc/meq aggregate scores; item-level ",
      "reconstruction unavailable"
    ),
    sprintf(
      "%d participants; %d participant-days; %d observations; %d sites",
      row$participants,
      row$participant_days,
      row$observations,
      row$sites
    ),
    sprintf(
      "minimum site participants %d; minimum within-site predictor range %.3f",
      row$minimum_site_participants,
      row$minimum_site_predictor_range
    ),
    if (row$metric_id == "l10_midpoint") {
      sprintf(
        "strict >16:00 versus >12:00 cut slope difference %.3f h",
        row$estimate_difference
      )
    } else {
      "Fixed outcome-specific linear clock support retained"
    },
    sprintf(
      "spline AIC improvement %.3f; maximum anchored departure %.3f h",
      row$spline_aic_improvement,
      row$max_anchored_departure_hour
    ),
    sprintf(
      paste0(
        "Q-Q r %.3f; skewness %.3f; excess kurtosis %.3f; ",
        "maximum |standardized residual| %.3f; quantitative screen %s; ",
        "visual assessment using %s: acceptable"
      ),
      row$qq_correlation,
      row$residual_skewness,
      row$residual_excess_kurtosis,
      row$max_abs_standardized_residual,
      row$distribution_numeric_screen,
      if (row$placement == "glasses") "Figure 2" else "Figure 3"
    ),
    sprintf(
      paste0(
        "|Spearman(abs residual, fitted)| %.3f; site residual-SD ratio %.3f; ",
        "quantitative screen %s; visual assessment using ",
        "%s: acceptable"
      ),
      abs(row$abs_residual_fitted_spearman),
      row$site_residual_sd_ratio,
      row$heteroscedasticity_numeric_screen,
      if (row$placement == "glasses") "Figure 2" else "Figure 3"
    ),
    sprintf(
      "%d/%d declared fits converged; %d/%d had a positive-definite Hessian; maximum optimizer gradient %.6f",
      row$converged_fits,
      row$fits_checked,
      row$positive_definite_hessian_fits,
      row$fits_checked,
      row$fit_check_max_gradient
    ),
    sprintf(
      "isSingular(tolerance = 1e-4) false for %d/%d declared fits",
      row$nonsingular_fits,
      row$fits_checked
    ),
    sprintf(
      "fixed-effect model matrices are full rank for %d/%d declared fits",
      row$full_rank_fits,
      row$fits_checked
    ),
    sprintf(
      "one-day residual correlation %.3f; corCAR1 phi %.3f; slope change %.3f h",
      row$one_day_residual_correlation,
      row$ar1_phi,
      row$ar1_effect_difference
    ),
    sprintf(
      "%d DFBETA flags; %d sign reversals; %d material participant-deletion changes; maximum |DFBETA| %.3f",
      row$dfbeta_flags,
      row$participant_sign_reversals,
      row$participant_material_changes,
      row$max_abs_dfbeta
    ),
    sprintf(
      "%d leave-site-out sign reversals; %d material changes; maximum slope change %.3f h",
      row$site_sign_reversals,
      row$site_material_changes,
      row$site_max_abs_estimate_change
    ),
    "Near-eye and chest fits use separately fitted, exactly matched paired/common frames",
    if (row$metric_id == "longest_period_midpoint") {
      paste(
        "Registered fifth outcome is unavailable in the gap-timing-unaware",
        "artifact; the limitation is explicit"
      )
    } else {
      "All-available and exact primary/gap common-sample results are estimable"
    },
    if (row$primary_family_member) {
      "Main and interaction tests reproduce complete five-member BH families"
    } else {
      "Adapted mean timing is outside F1-F4 by design; raw p-value only"
    }
  )
  domains <- c(
    "Questionnaire scoring",
    "Join and sample identity",
    "Site and instrument support",
    "Clock representation",
    "Chronotype linearity",
    "Response and residual distribution",
    "Residual heteroscedasticity",
    "Convergence and Hessian",
    "Singularity and variance",
    "Fixed-effect rank",
    "Temporal dependence",
    "Participant influence",
    "Site influence",
    "Placement and common sample",
    "Prepared-data sensitivity",
    "Multiplicity"
  )
  assessments <- c(
    "acceptable",
    "acceptable",
    row$site_support_assessment,
    row$clock_assessment,
    row$linearity_assessment,
    row$distribution_assessment,
    row$heteroscedasticity_assessment,
    row$convergence_assessment,
    row$singularity_assessment,
    row$rank_assessment,
    row$temporal_assessment,
    row$influence_assessment,
    row$site_influence_assessment,
    row$placement_assessment,
    row$prepared_data_assessment,
    row$multiplicity_assessment
  )
  assessment_basis <- c(
    rep("Prespecified quantitative or identity rule", 5L),
    paste(
      "Visual assessment; quantitative screen retained",
      paste0("as ", row$distribution_numeric_screen)
    ),
    paste(
      "Visual assessment; quantitative screen retained",
      paste0("as ", row$heteroscedasticity_numeric_screen)
    ),
    rep("Prespecified quantitative or identity rule", 9L)
  )
  diagnostic_registry_rows[[row$frame_id]] <- dplyr::bind_cols(
    row |>
      dplyr::select(
        .data$frame_id,
        .data$run_id,
        .data$placement,
        .data$placement_label,
        .data$metric_id,
        .data$instrument_id,
        .data$participants,
        .data$participant_days,
        .data$observations,
        .data$sites,
        .data$derivation_hours
      ) |>
        dplyr::slice(rep(1L, length(domains))),
    tibble::tibble(
      domain = domains,
      assessment = assessments,
      assessment_basis = assessment_basis,
      evidence = detail
    )
  )
}

diagnostic_assessment_registry <- dplyr::bind_rows(
  diagnostic_registry_rows
)

diagnostic_target_summary <- diagnostic_assessment_registry |>
  dplyr::group_by(
    .data$frame_id,
    .data$run_id,
    .data$placement,
    .data$placement_label,
    .data$metric_id,
    .data$instrument_id,
    .data$participants,
    .data$participant_days,
    .data$observations,
    .data$sites,
    .data$derivation_hours
  ) |>
  dplyr::summarise(
    domains_assessed = dplyr::n(),
    acceptable_domains = sum(.data$assessment == "acceptable"),
    not_acceptable_domains = sum(.data$assessment == "not acceptable"),
    not_acceptable_domain_names = paste(
      .data$domain[.data$assessment == "not acceptable"],
      collapse = " | "
    ),
    overall_assessment = if (all(.data$assessment == "acceptable")) {
      "acceptable"
    } else {
      "not acceptable"
    },
    .groups = "drop"
  )

h09_write_csv(
  diagnostic_assessment_registry,
  file.path(roots$diagnostics, "H09_diagnostic_assessment_registry.csv")
)

h09_write_csv(
  diagnostic_target_summary,
  file.path(roots$diagnostics, "H09_diagnostic_target_summary.csv")
)

Effect and diagnostic figures

Export effect estimates with numerical plot data and residual diagnostics for both placements.

metric_levels <- metric_registry |>
  dplyr::filter(.data$primary_family_member) |>
  dplyr::arrange(.data$metric_order) |>
  dplyr::pull(.data$abbreviation)

site_colours <- stats::setNames(site_registry$color_hex, site_registry$site)

effect_plot_data <- model_results_master |>
  dplyr::filter(
    .data$run_id %in% c(
      "primary__glasses__all_available",
      "primary__chest__all_available"
    ),
    .data$primary_family_member
  ) |>
  dplyr::mutate(
    metric_label = factor(.data$abbreviation, levels = rev(metric_levels)),
    instrument_label = factor(
      .data$instrument_name,
      levels = c("MCTQ MSFsc", "MEQ")
    ),
    placement_label = factor(
      .data$placement_label,
      levels = c("Near eye", "Chest")
    )
  ) |>
  dplyr::select(
    .data$metric_order,
    .data$metric_id,
    .data$manuscript_name,
    .data$metric_label,
    .data$instrument_id,
    .data$instrument_name,
    .data$instrument_label,
    .data$effect_unit,
    .data$placement,
    .data$placement_label,
    .data$participants,
    .data$participant_days,
    .data$observations,
    .data$sites,
    .data$derivation_hours,
    .data$estimate,
    .data$std_error,
    .data$conf_low,
    .data$conf_high,
    .data$main_p_raw,
    .data$main_p_adjusted,
    .data$main_adjusted_significant,
    .data$main_family_id
  )

h09_write_csv(
  effect_plot_data,
  file.path(roots$source_data, "H09_primary_effects_data.csv")
)

effect_plot <- ggplot2::ggplot(
  effect_plot_data,
  ggplot2::aes(
    x = .data$estimate,
    y = .data$metric_label,
    colour = .data$placement_label,
    shape = .data$placement_label
  )
) +
  ggplot2::geom_vline(xintercept = 0, colour = "grey55", linewidth = 0.45) +
  ggplot2::geom_errorbarh(
    ggplot2::aes(xmin = .data$conf_low, xmax = .data$conf_high),
    height = 0.12,
    position = ggplot2::position_dodge(width = 0.42),
    linewidth = 0.7
  ) +
  ggplot2::geom_point(
    position = ggplot2::position_dodge(width = 0.42),
    size = 2.8,
    stroke = 0.9
  ) +
  ggplot2::facet_wrap(~instrument_label, scales = "free_x", nrow = 1) +
  ggplot2::scale_colour_manual(
    values = c("Near eye" = "#0072B2", "Chest" = "#D55E00")
  ) +
  ggplot2::scale_shape_manual(values = c("Near eye" = 16, "Chest" = 17)) +
  ggplot2::labs(
    x = "Difference in local exposure timing (hours)",
    y = NULL,
    colour = "Placement",
    shape = "Placement"
  ) +
  ggplot2::theme_bw(base_size = 14) +
  ggplot2::theme(
    legend.position = "top",
    text = ggplot2::element_text(size = 17),
    legend.text = ggplot2::element_text(size = 17),
    legend.title = ggplot2::element_text(size = 17),
    strip.text = ggplot2::element_text(face = "bold", size = 17),
    axis.text = ggplot2::element_text(size = 17),
    axis.title = ggplot2::element_text(size = 17),
    panel.grid.minor = ggplot2::element_blank(),
    panel.spacing = grid::unit(12, "pt"),
    plot.margin = ggplot2::margin(12, 16, 12, 12)
  )

ggplot2::ggsave(
  file.path(roots$figures, "H09_primary_effects.png"),
  effect_plot,
  width = 10.5,
  height = 6.5,
  scale = 1.5,
  dpi = 300,
  device = ragg::agg_png
)

ggplot2::ggsave(
  file.path(roots$figures, "H09_primary_effects.pdf"),
  effect_plot,
  width = 10.5,
  height = 6.5,
  scale = 1.5,
  device = grDevices::cairo_pdf
)

paired_plot_data <- paired_effects |>
  dplyr::mutate(
    metric_label = factor(.data$abbreviation, levels = metric_levels),
    instrument_label = factor(
      .data$instrument_name,
      levels = c("MCTQ MSFsc", "MEQ")
    )
  )

paired_limits <- range(c(
  paired_plot_data$conf_low_glasses,
  paired_plot_data$conf_high_glasses,
  paired_plot_data$conf_low_chest,
  paired_plot_data$conf_high_chest,
  0
), na.rm = TRUE)

paired_padding <- diff(paired_limits) * 0.08

paired_limits <- paired_limits + c(-paired_padding, paired_padding)

paired_plot <- ggplot2::ggplot(
  paired_plot_data,
  ggplot2::aes(
    x = .data$estimate_glasses,
    y = .data$estimate_chest,
    colour = .data$metric_label,
    shape = .data$metric_label
  )
) +
  ggplot2::geom_abline(
    intercept = 0,
    slope = 1,
    colour = "grey45",
    linetype = "dashed",
    linewidth = 0.6
  ) +
  ggplot2::geom_vline(xintercept = 0, colour = "grey70", linewidth = 0.45) +
  ggplot2::geom_hline(yintercept = 0, colour = "grey70", linewidth = 0.45) +
  ggplot2::geom_errorbar(
    ggplot2::aes(
      ymin = .data$conf_low_chest,
      ymax = .data$conf_high_chest
    ),
    width = 0,
    linewidth = 0.55
  ) +
  ggplot2::geom_errorbarh(
    ggplot2::aes(
      xmin = .data$conf_low_glasses,
      xmax = .data$conf_high_glasses
    ),
    height = 0,
    linewidth = 0.55
  ) +
  ggplot2::geom_point(size = 3.1, stroke = 0.9) +
  ggplot2::facet_wrap(~instrument_label, nrow = 1) +
  ggplot2::coord_equal(xlim = paired_limits, ylim = paired_limits) +
  ggplot2::scale_colour_brewer(palette = "Dark2", drop = FALSE) +
  ggplot2::scale_shape_manual(values = c(16, 17, 15, 18, 3), drop = FALSE) +
  ggplot2::labs(
    x = "Near-eye estimate (hours)",
    y = "Chest estimate (hours)",
    colour = "Timing metric",
    shape = "Timing metric"
  ) +
  ggplot2::theme_bw(base_size = 14) +
  ggplot2::theme(
    legend.position = "bottom",
    text = ggplot2::element_text(size = 13),
    legend.text = ggplot2::element_text(size = 13),
    legend.title = ggplot2::element_text(size = 13),
    strip.text = ggplot2::element_text(face = "bold", size = 14),
    axis.text = ggplot2::element_text(size = 13),
    axis.title = ggplot2::element_text(size = 14),
    panel.grid.minor = ggplot2::element_blank(),
    panel.spacing = grid::unit(10, "pt"),
    plot.margin = ggplot2::margin(10, 14, 12, 10)
  )

ggplot2::ggsave(
  file.path(roots$figures, "H09_paired_placement_effects.png"),
  paired_plot,
  width = 8,
  height = 6.5,
  scale = 1.5,
  dpi = 300,
  device = ragg::agg_png
)

ggplot2::ggsave(
  file.path(roots$figures, "H09_paired_placement_effects.pdf"),
  paired_plot,
  width = 8,
  height = 6.5,
  scale = 1.5,
  device = grDevices::cairo_pdf
)
diagnostic_source <- diagnostic_plot_data |>
  dplyr::left_join(
    metric_registry |>
      dplyr::select(
        .data$metric_id,
        .data$metric_order,
        .data$abbreviation
      ),
    by = "metric_id",
    relationship = "many-to-one"
  ) |>
  dplyr::left_join(
    predictor_registry |>
      dplyr::select(.data$instrument_id, .data$instrument_name),
    by = "instrument_id",
    relationship = "many-to-one"
  )

h09_write_csv(
  diagnostic_source,
  file.path(roots$source_data, "H09_primary_diagnostic_figure_data.csv")
)

h09_diagnostic_plot <- function(placement, placement_label) {
  data <- diagnostic_source |>
    dplyr::filter(.data$placement == .env$placement) |>
    dplyr::mutate(
      panel = paste(.data$abbreviation, .data$instrument_name, sep = " ; ")
    )
  residual_plot <- ggplot2::ggplot(
    data,
    ggplot2::aes(
      x = .data$fitted,
      y = .data$standardized_residual
    )
  ) +
    ggplot2::geom_hline(yintercept = 0, colour = "grey55") +
    ggplot2::geom_point(alpha = 0.32, size = 0.8) +
    ggplot2::facet_wrap(
      ~panel,
      scales = "free_x",
      ncol = 3,
      labeller = ggplot2::label_wrap_gen(width = 26)
    ) +
    ggplot2::labs(x = "Fitted timing (hours)", y = "Standardized residual") +
    ggplot2::theme_bw(base_size = 14) +
    ggplot2::theme(
      text = ggplot2::element_text(size = 17),
      strip.text = ggplot2::element_text(size = 17, face = "bold"),
      axis.text = ggplot2::element_text(size = 17),
      axis.title = ggplot2::element_text(size = 17),
      panel.grid.minor = ggplot2::element_blank(),
      plot.tag = ggplot2::element_text(size = 17, face = "bold"),
      panel.spacing = grid::unit(10, "pt"),
      plot.margin = ggplot2::margin(10, 12, 10, 10)
    )
  qq_plot <- ggplot2::ggplot(
    data,
    ggplot2::aes(
      x = .data$theoretical_quantile,
      y = .data$sample_quantile
    )
  ) +
    ggplot2::geom_abline(intercept = 0, slope = 1, colour = "grey55") +
    ggplot2::geom_point(alpha = 0.32, size = 0.8) +
    ggplot2::facet_wrap(
      ~panel,
      scales = "free",
      ncol = 3,
      labeller = ggplot2::label_wrap_gen(width = 26)
    ) +
    ggplot2::labs(x = "Normal-score quantile", y = "Standardized residual") +
    ggplot2::theme_bw(base_size = 14) +
    ggplot2::theme(
      text = ggplot2::element_text(size = 17),
      strip.text = ggplot2::element_text(size = 17, face = "bold"),
      axis.text = ggplot2::element_text(size = 17),
      axis.title = ggplot2::element_text(size = 17),
      panel.grid.minor = ggplot2::element_blank(),
      plot.tag = ggplot2::element_text(size = 17, face = "bold"),
      panel.spacing = grid::unit(10, "pt"),
      plot.margin = ggplot2::margin(10, 12, 10, 10)
    )
  patchwork::wrap_plots(residual_plot, qq_plot, ncol = 1L) +
    patchwork::plot_annotation(
      title = paste0("H09 mixed-model residual diagnostics ; ", placement_label),
      tag_levels = "A",
      theme = ggplot2::theme(
        plot.title = ggplot2::element_text(face = "bold", size = 18),
        plot.margin = ggplot2::margin(12, 12, 8, 12)
      )
    )
}

diagnostic_near_eye_plot <- h09_diagnostic_plot("glasses", "Near eye")

diagnostic_chest_plot <- h09_diagnostic_plot("chest", "Chest")

for (item in list(
  list(stem = "H09_diagnostics_near_eye", plot = diagnostic_near_eye_plot),
  list(stem = "H09_diagnostics_chest", plot = diagnostic_chest_plot)
)) {
  ggplot2::ggsave(
    file.path(roots$figures, paste0(item$stem, ".png")),
    item$plot,
    width = 10.5,
    height = 17.5,
    scale = 1.5,
    dpi = 300,
    device = ragg::agg_png
  )
  ggplot2::ggsave(
    file.path(roots$figures, paste0(item$stem, ".pdf")),
    item$plot,
    width = 10.5,
    height = 17.5,
    scale = 1.5,
    device = grDevices::cairo_pdf
  )
}

Observed distributions and fitted associations

Show supported average associations alongside the observed participant-day data, and show the participant-level chronotype distributions by site. Lines and pointwise intervals use the fitted fixed effects with equal site weights.

suppressPackageStartupMessages({library(dplyr);library(ggplot2);library(readr);library(tibble);library(patchwork)})
source("scripts/hypotheses/H09/h09_observed_figures.R")
h09_build_observed_figure(root, root)
# A tibble: 3 × 2
  path                                                                     bytes
  <chr>                                                                    <dbl>
1 /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/csv/source_d… 1.97e6
2 /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H09/H… 2.07e6
3 /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H09/H… 1.48e5

Findings and interpretation

The following views use the models and summaries calculated above.

Export results
locate_project_root <- function(start = getwd()) {
    candidate <- normalizePath(start, winslash = "/", mustWork = TRUE)
    repeat {
        if (file.exists(file.path(candidate, "renv.lock")) && file.exists(file.path(candidate, "_quarto.yml"))) {
            return(candidate)
        }
        parent <- dirname(candidate)
        if (identical(parent, candidate)) {
            stop("Could not locate the project root", call. = FALSE)
        }
        candidate <- parent
    }
}
configured_root <- Sys.getenv("NATHEALTH_PROJECT_ROOT", unset = Sys.getenv("QUARTO_PROJECT_DIR", unset = ""))
if (nzchar(configured_root) && file.exists(file.path(configured_root, "renv.lock"))) {
    root <- normalizePath(configured_root, winslash = "/", mustWork = TRUE)
} else {
    root <- locate_project_root()
}
suppressPackageStartupMessages({
    library(dplyr)
    library(gt)
    library(readr)
    library(stringr)
    library(tibble)
    library(tidyr)
})
source(file.path(root, "scripts", "pipeline", "p_value_display.R"))
read_h09 <- function(area, name) {
    readr::read_csv(file.path(root, "results", area, "H09", name), show_col_types = FALSE, progress = FALSE, na = "")
}
metric_registry <- read_h09("intermediate/model_data", "H09_metric_registry.csv")
predictor_registry <- read_h09("intermediate/model_data", "H09_predictor_registry.csv")
score_audit <- read_h09("csv/diagnostics", "H09_chronotype_score_audit.csv")
master <- read_h09("tables", "H09_model_results_master.csv")
family_audit <- read_h09("tables", "H09_family_audit.csv")
diagnostic_registry <- read_h09("csv/diagnostics", "H09_diagnostic_assessment_registry.csv")
diagnostic_summary <- read_h09("csv/diagnostics", "H09_diagnostic_target_summary.csv")
diagnostic_adjudication <- read_h09("csv/diagnostics", "H09_diagnostic_author_adjudication.csv")
gap_sensitivity <- read_h09("tables", "H09_gap_timing_unaware_sensitivity.csv")
photoperiod <- read_h09("tables", "H09_photoperiod_sensitivity.csv")
participant_summary <- read_h09("tables", "H09_participant_summary_sensitivity.csv")
ar1 <- read_h09("tables", "H09_ar1_sensitivity.csv")
l10_cut <- read_h09("tables", "H09_l10_cut_sensitivity.csv")
paired_effects <- read_h09("tables", "H09_paired_placement_effects.csv")
mean_timing <- filter(read_h09("tables", "H09_fifth_outcome_sensitivity.csv"), .data$metric_id == "mean_timing_above_250")
primary_registered <- arrange(filter(master, .data$data_scenario_id == "primary", .data$sample_scenario == "all_available",
    .data$primary_family_member), .data$placement, .data$metric_order, .data$instrument_id)
primary_near <- filter(primary_registered, .data$placement == "glasses")
primary_chest <- filter(primary_registered, .data$placement == "chest")
format_effect <- function(estimate, low, high) {
    ifelse(is.finite(estimate) & is.finite(low) & is.finite(high), sprintf("%+.3f (%+.3f to %+.3f)", estimate, low, high),
        "Not estimable")
}
format_p_cell <- function(value, significant) {
    display <- nh_p_value_display(value, significant = significant)
    ifelse(display$p_bold, paste0("**", display$p_display, "**"), display$p_display)
}
format_sample <- function(participants, days, observations, hours, sites) {
    ifelse(is.finite(participants) & is.finite(days) & is.finite(observations) & is.finite(hours) & is.finite(sites), sprintf("%d / %d / %d / %.1f h / %d",
        participants, days, observations, hours, sites), "Not available")
}
h09_gt <- function(data, title = NULL, note = NULL) {
    output <- tab_options(opt_align_table_header(sub_missing(opt_row_striping(fmt_markdown(gt(data), columns = where(is.character))),
        missing_text = "Not available"), align = "left"), table.width = pct(100), table.font.size = px(12), container.overflow.x = TRUE,
        data_row.padding = px(4), heading.align = "left", column_labels.font.weight = "600", source_notes.font.size = px(9))
    if (!is.null(title))
        output <- tab_header(output, title = md(title))
    if (!is.null(note))
        output <- tab_source_note(output, md(note))
    output
}
result_table <- function(data, association_language = FALSE) {
    note <- if (isTRUE(association_language)) {
        paste("MCTQ associations are per one-hour later MSFsc; MEQ associations are", "per 10 points greater morning preference. Positive associations",
            "indicate later timing and negative associations earlier. Intervals are", "95% Wald CIs. Raw p-values are bold when raw p < 0.050; adjusted",
            "p-values are bold when FDR-adjusted p < 0.050 within the", "instrument-specific five-outcome family.")
    }
    else {
        paste("MCTQ effects are per one-hour later MSFsc; MEQ effects are per", "10 points greater morning preference. Positive effects mean later",
            "timing and negative effects earlier. Intervals are 95% Wald CIs.", "Raw p-values are bold when raw p < 0.050;",
            "adjusted p-values are bold when FDR-adjusted p < 0.050 within the", "instrument-specific five-outcome family.")
    }
    output <- gt::cols_width(h09_gt(select(arrange(transmute(data, .data$metric_order, Metric = .data$manuscript_name, Instrument = .data$instrument_name,
        `Signed effect, h (95% CI)` = format_effect(.data$estimate, .data$conf_low, .data$conf_high), `Raw p` = format_p_cell(.data$main_p_raw,
            .data$main_raw_significant), `FDR-adjusted p` = format_p_cell(.data$main_p_adjusted, .data$main_adjusted_significant),
        `Fitted sample: participants / days / observations / hours / sites` = format_sample(.data$participants, .data$participant_days,
            .data$observations, .data$derivation_hours, .data$sites)), .data$metric_order, .data$Instrument), -.data$metric_order),
        note = note), Metric ~ gt::pct(21), Instrument ~ gt::pct(11), `Signed effect, h (95% CI)` ~ gt::pct(20), `Raw p` ~
        gt::pct(8), `FDR-adjusted p` ~ gt::pct(10), `Fitted sample: participants / days / observations / hours / sites` ~
        gt::pct(30))
    if (isTRUE(association_language)) {
        output <- gt::cols_label(output, `Signed effect, h (95% CI)` = "Signed association, h (95% CI)")
    }
    output
}

Hypothesis and analytical question

The preregistered hypothesis was:

H9: Timing-based metrics are associated with chronotype (MCTQ, MEQ).

Chronotype was represented by the Munich Chronotype Questionnaire corrected midsleep on free days (MCTQ MSFsc) and the Morningness–Eveningness Questionnaire (MEQ). The analytical question is whether either construct is associated with the local clock timing of personal light exposure across repeated participant-days after accounting for study site. A participant-day is one participant’s eligible outcome on one local date.

The primary sensor position was near eye because it records light near the eyes during wear. The complementary chest sensor position was analysed separately and is not a measure of ocular exposure. Light intensity is expressed as melanopic equivalent daylight illuminance (melEDI). Effects are signed local-clock hours per stated increase in the chronotype score: positive values mean the timing outcome occurs later and negative values mean earlier. Because larger MCTQ MSFsc means later corrected midsleep while larger MEQ means greater morning preference, the two score directions must be read separately.

NoteAnswer in brief

Later MCTQ corrected midsleep was associated with later primary near-eye M10 midpoint, L10 midpoint, and first timing above 250 lx melEDI; greater MEQ morning preference was associated with earlier timing for the same outcomes. These conclusions use separate five-outcome false-discovery-rate (FDR) families. Uncertainty is reported with 95% confidence intervals (95% CIs). The clearest near-eye estimate was for first timing above 250 lx melEDI: +0.381 h per one-hour later MCTQ MSFsc (95% CI +0.146 to +0.616; FDR-adjusted p = 0.003) and −0.444 h per 10 points greater MEQ morning preference (95% CI −0.699 to −0.189; FDR-adjusted p = 0.001). Last timing and the midpoint of the longest continuous period were not supported. Complementary chest results broadly reinforced the first-timing pattern, and no chronotype-by-site interaction survived FDR adjustment. Structured sensitivity analyses supported the main directional interpretation, with explicit limitations for the unavailable longest-period dataset sensitivity and a few near-zero or precision-sensitive estimates.

Chronotype constructs and timing outcomes

MCTQ MSFsc and MEQ represent related but distinct constructs. A larger MCTQ MSFsc value denotes later corrected midsleep, whereas a larger MEQ score denotes greater morning preference. The verified aggregate calculated fields covered 186 participants. MCTQ MSFsc was complete for 185 participants and missing for one; MEQ was complete for all 186. The aggregate values were preserved exactly from their source, but item-level questionnaire responses were unavailable for independent score reconstruction.

The five registered participant-day outcomes were the midpoint of the brightest 10 hours (M10 midpoint), midpoint of the darkest 10 hours (L10 midpoint), first time above 250 lx melEDI, last time above 250 lx melEDI, and midpoint of the longest continuous period above 250 lx melEDI. M10 and L10 onset and offset were not analysed. Clock values were linearized because their observed support was adequately linear; night-time values used the specified negative-hour conversion where needed. No circular distribution was imposed solely because an outcome was clock-valued. The timing definitions and their support rules are documented in Preparation 04 and the model-ready fields in Preparation 06.

metric_registry |>
  filter(.data$primary_family_member) |>
  arrange(.data$metric_order) |>
  transmute(
    Metric = .data$manuscript_name,
    Unit = .data$display_unit,
    `Analysis unit` = str_replace_all(.data$analysis_unit, "_", " "),
    Definition = .data$value_definition
  ) |>
  h09_gt()
Table 1: Registered H09 timing outcomes.
Metric Unit Analysis unit Definition
Midpoint of the brightest 10 hours local clock hour participant day Local midpoint of the brightest supported 10 hours
Midpoint of the darkest 10 hours local clock hour participant day Local midpoint of the darkest supported 10 hours; strict >16:00 values shifted by -24 hours
First light timing above 250 lx melEDI local clock hour participant day First supported local timing above 250 lx melEDI
Last light timing above 250 lx melEDI local clock hour participant day Last supported local timing above 250 lx melEDI
Midpoint of the longest continuous period above 250 lx melEDI local clock hour participant day Local midpoint of the selected longest continuous period above 250 lx melEDI, restricted to exact-identifiable periods

The gap-timing-unaware dataset is the dataset applying the 50%-per-hour and 80%-per-day coverage rules but not using the remaining gaps’ time of day in metric-specific support decisions. It can be contrasted once with the time-sensitive primary dataset; below, the latter is called simply the primary dataset.

Statistical approach

Each chronotype instrument was analysed separately. Participant-day Gaussian mixed models included fixed study-site effects and a participant random effect nested within site. This random effect represents remaining between-participant timing variation after site and chronotype are considered. The additive model estimated a study-site-adjusted average between-participant association and was compared with a site-only model. A separate chronotype-by-site interaction model allowed the chronotype association to differ by study site. Site-specific slopes were treated as descriptive unless the global interaction survived FDR adjustment.

mctq_site_only <- stats::as.formula(
  "timing_hour ~ site + (1 | site:Id)"
)
mctq_additive <- stats::as.formula(
  "timing_hour ~ site + mctq_hour_centered + (1 | site:Id)"
)
mctq_interaction <- stats::as.formula(
  "timing_hour ~ site * mctq_hour_centered + (1 | site:Id)"
)
meq_site_only <- stats::as.formula(
  "timing_hour ~ site + (1 | site:Id)"
)
meq_additive <- stats::as.formula(
  "timing_hour ~ site + meq_10_centered + (1 | site:Id)"
)
meq_interaction <- stats::as.formula(
  "timing_hour ~ site * meq_10_centered + (1 | site:Id)"
)

tibble(
  Instrument = rep(c("MCTQ MSFsc", "MEQ"), each = 3L),
  Role = rep(
    c("Site only", "Average chronotype association", "Chronotype-by-site interaction"),
    2L
  ),
  Formula = vapply(
    list(
      mctq_site_only,
      mctq_additive,
      mctq_interaction,
      meq_site_only,
      meq_additive,
      meq_interaction
    ),
    deparse1,
    character(1)
  )
) |>
  h09_gt()
Table 2: Exact Wilkinson formulas used for the principal H09 comparisons.
Instrument Role Formula
MCTQ MSFsc Site only timing_hour ~ site + (1 | site:Id)
MCTQ MSFsc Average chronotype association timing_hour ~ site + mctq_hour_centered + (1 | site:Id)
MCTQ MSFsc Chronotype-by-site interaction timing_hour ~ site * mctq_hour_centered + (1 | site:Id)
MEQ Site only timing_hour ~ site + (1 | site:Id)
MEQ Average chronotype association timing_hour ~ site + meq_10_centered + (1 | site:Id)
MEQ Chronotype-by-site interaction timing_hour ~ site * meq_10_centered + (1 | site:Id)

The centered predictor names encode simple changes of origin and scale. mctq_hour_centered is each participant’s MCTQ MSFsc expressed in clock hours minus the participant mean (4.114 h); an increase of one unit therefore means one hour later corrected midsleep. meq_10_centered is the MEQ score minus the participant mean (52.860) and divided by 10; an increase of one unit means 10 MEQ points toward greater morning preference. Centering places the model intercept at the observed mean chronotype. It does not change the chronotype slopes, their confidence intervals, or their tests.

Each coefficient is a signed-hour estimand. Positive values mean the timing outcome occurs later per one-hour later MCTQ MSFsc or per 10 points greater MEQ morning preference; negative values mean earlier. The different score directions are therefore retained in every interpretation.

Four inferential families were kept distinct for each placement: MCTQ average associations, MEQ average associations, MCTQ-by-site interactions, and MEQ-by-site interactions. Each complete family used FDR adjustment across the five registered timing outcomes. Statistical significance was decided at FDR-adjusted p < 0.050 before formatting. All displayed intervals are 95% Wald CIs. The mean-timing sensitivity reported later was not added to these families.

Exact fitted samples

Primary near-eye fits used 131–141 participants, 478–816 participant-days and observations, 11,325.5–18,851.0 derivation hours, and nine sites, depending on the outcome and instrument. Complementary chest fits used 149–154 participants, 547–902 participant-days and observations, 12,980.0–20,891.8 derivation hours, and eight sites. Every estimate below reports its exact fitted sample.

primary_registered |>
  transmute(
    .data$metric_order,
    Metric = .data$manuscript_name,
    Instrument = .data$instrument_name,
    Placement = .data$placement_label,
    `Participants / days / observations / hours / sites` = format_sample(
      .data$participants,
      .data$participant_days,
      .data$observations,
      .data$derivation_hours,
      .data$sites
    )
  ) |>
  arrange(.data$metric_order, .data$Instrument, .data$Placement) |>
  select(-.data$metric_order) |>
  h09_gt(
    note = "Participant-days and observations coincide because each fitted row is one participant-day outcome."
  )
Table 3: Exact primary-dataset fitted samples by registered outcome, instrument, and placement.
Metric Instrument Placement Participants / days / observations / hours / sites
Midpoint of the brightest 10 hours MCTQ MSFsc Chest 153 / 896 / 896 / 20752.4 h / 8
Midpoint of the brightest 10 hours MCTQ MSFsc Near eye 140 / 810 / 810 / 18711.7 h / 9
Midpoint of the brightest 10 hours MEQ Chest 154 / 902 / 902 / 20891.8 h / 8
Midpoint of the brightest 10 hours MEQ Near eye 141 / 816 / 816 / 18851.0 h / 9
Midpoint of the darkest 10 hours MCTQ MSFsc Chest 153 / 896 / 896 / 20752.4 h / 8
Midpoint of the darkest 10 hours MCTQ MSFsc Near eye 140 / 810 / 810 / 18711.7 h / 9
Midpoint of the darkest 10 hours MEQ Chest 154 / 902 / 902 / 20891.8 h / 8
Midpoint of the darkest 10 hours MEQ Near eye 141 / 816 / 816 / 18851.0 h / 9
First light timing above 250 lx melEDI MCTQ MSFsc Chest 153 / 797 / 797 / 18518.4 h / 8
First light timing above 250 lx melEDI MCTQ MSFsc Near eye 139 / 722 / 722 / 16716.3 h / 9
First light timing above 250 lx melEDI MEQ Chest 154 / 802 / 802 / 18634.7 h / 8
First light timing above 250 lx melEDI MEQ Near eye 140 / 727 / 727 / 16832.5 h / 9
Last light timing above 250 lx melEDI MCTQ MSFsc Chest 153 / 783 / 783 / 18263.8 h / 8
Last light timing above 250 lx melEDI MCTQ MSFsc Near eye 140 / 683 / 683 / 15900.8 h / 9
Last light timing above 250 lx melEDI MEQ Chest 154 / 787 / 787 / 18358.1 h / 8
Last light timing above 250 lx melEDI MEQ Near eye 141 / 687 / 687 / 15995.0 h / 9
Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc Chest 149 / 547 / 547 / 12980.0 h / 8
Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc Near eye 131 / 478 / 478 / 11325.5 h / 9
Midpoint of the longest continuous period above 250 lx melEDI MEQ Chest 150 / 549 / 549 / 13027.8 h / 8
Midpoint of the longest continuous period above 250 lx melEDI MEQ Near eye 132 / 482 / 482 / 11419.8 h / 9
Participant-days and observations coincide because each fitted row is one participant-day outcome.

Primary near-eye results

Both chronotype instruments showed the same substantive pattern in opposite score directions. Later MCTQ MSFsc was associated with later M10, L10, and first-above-250 timing. Greater MEQ morning preference was associated with earlier timing for those same three outcomes. The adjusted evidence did not support associations with last-above-250 timing or the midpoint of the longest continuous period.

include_project_graphics(file.path(
  root,
  "results",
  "images",
  "H09",
  "H09_primary_effects.png"
))
Two-panel forest plot separates Munich Chronotype Questionnaire corrected midsleep on free days from Morningness–Eveningness Questionnaire associations. Each panel shows primary near-eye and complementary chest estimates, distinguished by colour and shape, with 95% CIs for the five registered timing outcomes. Corrected-midsleep estimates for M10 midpoint, L10 midpoint, and first time above 250 lx melEDI tend positive, while morning-preference estimates for those outcomes tend negative.
Figure 1: Study-site-adjusted chronotype associations with midpoint of the brightest 10 hours (M10 midpoint), midpoint of the darkest 10 hours (L10 midpoint), first and last time above 250 lx melEDI, and midpoint of the longest continuous period above 250 lx melEDI. Near-eye estimates are primary and chest estimates complementary; colour and shape identify placement, and horizontal bars are 95% CIs.
result_table(primary_near, association_language = TRUE)
Table 4: Primary near-eye chronotype associations, 95% CIs, p-values, and exact fitted samples.
Metric Instrument Signed association, h (95% CI) Raw p FDR-adjusted p Fitted sample: participants / days / observations / hours / sites
Midpoint of the brightest 10 hours MCTQ MSFsc +0.205 (+0.045 to +0.366) 0.010 0.017 140 / 810 / 810 / 18711.7 h / 9
Midpoint of the brightest 10 hours MEQ -0.278 (-0.451 to -0.105) 0.001 0.002 141 / 816 / 816 / 18851.0 h / 9
Midpoint of the darkest 10 hours MCTQ MSFsc +0.276 (+0.118 to +0.435) <0.001 0.003 140 / 810 / 810 / 18711.7 h / 9
Midpoint of the darkest 10 hours MEQ -0.314 (-0.487 to -0.142) <0.001 0.001 141 / 816 / 816 / 18851.0 h / 9
First light timing above 250 lx melEDI MCTQ MSFsc +0.381 (+0.146 to +0.616) 0.001 0.003 139 / 722 / 722 / 16716.3 h / 9
First light timing above 250 lx melEDI MEQ -0.444 (-0.699 to -0.189) <0.001 0.001 140 / 727 / 727 / 16832.5 h / 9
Last light timing above 250 lx melEDI MCTQ MSFsc -0.025 (-0.264 to +0.214) 0.811 0.811 140 / 683 / 683 / 15900.8 h / 9
Last light timing above 250 lx melEDI MEQ +0.029 (-0.236 to +0.294) 0.814 0.814 141 / 687 / 687 / 15995.0 h / 9
Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc +0.161 (-0.113 to +0.434) 0.227 0.284 131 / 478 / 478 / 11325.5 h / 9
Midpoint of the longest continuous period above 250 lx melEDI MEQ -0.228 (-0.521 to +0.064) 0.108 0.135 132 / 482 / 482 / 11419.8 h / 9
MCTQ associations are per one-hour later MSFsc; MEQ associations are per 10 points greater morning preference. Positive associations indicate later timing and negative associations earlier. Intervals are 95% Wald CIs. Raw p-values are bold when raw p < 0.050; adjusted p-values are bold when FDR-adjusted p < 0.050 within the instrument-specific five-outcome family.

Primary effect figure source data and the complete numerical results retain the plotted estimates, intervals, tests, and exact samples.

Observed participant-day patterns

The complete ten-estimate display remains Figure 1. The composite below gives an observed-data view of the six primary near-eye associations that met the instrument-specific five-outcome FDR rule. Panel A keeps MCTQ MSFsc and MEQ separate. Its black lines are equal-site-average fixed-effect relationships from the selected site-adjusted mixed models, with random effects set to zero; ribbons are 95% CIs for those mean relationships. Panel B separately shows the descriptive participant-level distributions of MCTQ MSFsc and MEQ by site. It is not an inferential comparison among sites. The L10 coordinates retain the linear nighttime ordering used in the analysis, while the axis labels convert those coordinates back to clock time.

include_project_graphics(file.path(
  root,
  "results",
  "images",
  "H09",
  "H09_observed_timing_patterns.png"
))
Composite figure with two panels. Panel A contains six site-coloured scatterplots of primary near-eye participant-day timing: M10 midpoint, L10 midpoint, and first time above 250 lx melEDI against MCTQ corrected midsleep and MEQ score. Each scatterplot has a black equal-site-average mixed-model line and grey 95% confidence ribbon. Panel B has two horizontal site-ordered box-and-point displays showing participant-level MCTQ and MEQ distributions across nine sites.
Figure 2: Primary near-eye chronotype associations and chronotype distributions. Panel A shows the six associations that met the prespecified instrument-specific five-outcome FDR-adjusted p-value rule: midpoint of the brightest 10 hours (M10 midpoint), midpoint of the darkest 10 hours (L10 midpoint), and first time above 250 lx melEDI for MCTQ MSFsc and MEQ. Points are fitted participant-days, with colour and shape identifying study site. Black lines are equal-site-average fixed-effect relationships from the selected site-adjusted mixed models, and grey ribbons are 95% CIs for those mean relationships. Panel B shows descriptive participant-level MCTQ MSFsc and MEQ distributions by site and is not an inferential site comparison. The complete set of ten primary near-eye estimates is retained in Figure 1.

Composite figure source contains the exact de-identified participant-day points, stored-model line and interval coordinates, and participant-level chronotype rows used by the display.

Complementary chest results

Chest measurements were analysed with the same outcome definitions and instrument-specific five-outcome adjustment, without pooling with near-eye measurements. The first-above-250 association was supported for both instruments. MEQ associations were also supported for M10 and L10 midpoint; the chest MCTQ L10 result was close to but did not meet the adjusted rule (FDR-adjusted p = 0.055). Last timing and the longest-period midpoint were not supported.

result_table(primary_chest)
Table 5: Complementary chest chronotype associations, 95% CIs, p-values, and exact fitted samples.
Metric Instrument Signed effect, h (95% CI) Raw p FDR-adjusted p Fitted sample: participants / days / observations / hours / sites
Midpoint of the brightest 10 hours MCTQ MSFsc +0.117 (-0.038 to +0.272) 0.130 0.162 153 / 896 / 896 / 20752.4 h / 8
Midpoint of the brightest 10 hours MEQ -0.214 (-0.381 to -0.047) 0.010 0.017 154 / 902 / 902 / 20891.8 h / 8
Midpoint of the darkest 10 hours MCTQ MSFsc +0.174 (+0.022 to +0.327) 0.022 0.055 153 / 896 / 896 / 20752.4 h / 8
Midpoint of the darkest 10 hours MEQ -0.227 (-0.392 to -0.062) 0.006 0.015 154 / 902 / 902 / 20891.8 h / 8
First light timing above 250 lx melEDI MCTQ MSFsc +0.411 (+0.196 to +0.625) <0.001 <0.001 153 / 797 / 797 / 18518.4 h / 8
First light timing above 250 lx melEDI MEQ -0.462 (-0.696 to -0.228) <0.001 <0.001 154 / 802 / 802 / 18634.7 h / 8
Last light timing above 250 lx melEDI MCTQ MSFsc -0.187 (-0.404 to +0.031) 0.084 0.140 153 / 783 / 783 / 18263.8 h / 8
Last light timing above 250 lx melEDI MEQ +0.176 (-0.064 to +0.416) 0.139 0.173 154 / 787 / 787 / 18358.1 h / 8
Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc +0.058 (-0.189 to +0.306) 0.608 0.608 149 / 547 / 547 / 12980.0 h / 8
Midpoint of the longest continuous period above 250 lx melEDI MEQ -0.067 (-0.339 to +0.206) 0.607 0.607 150 / 549 / 549 / 13027.8 h / 8
MCTQ effects are per one-hour later MSFsc; MEQ effects are per 10 points greater morning preference. Positive effects mean later timing and negative effects earlier. Intervals are 95% Wald CIs. Raw p-values are bold when raw p < 0.050; adjusted p-values are bold when FDR-adjusted p < 0.050 within the instrument-specific five-outcome family.

Same-participant and same-day placement evidence

For this placement comparison, near-eye and chest models used the same participants and the same participant-days for every registered metric and instrument. The two associations were nevertheless fitted separately. The display compares their component estimates and component 95% CIs; it is not a direct placement-effect or equivalence test, does not pool placements, and does not estimate a between-placement difference interval. No equivalence margin was prespecified.

include_project_graphics(file.path(
  root,
  "results",
  "images",
  "H09",
  "H09_paired_placement_effects.png"
))
Equal-axis scatter plot compares near-eye estimates horizontally with chest estimates vertically for five timing metrics in separate MCTQ MSFsc and MEQ panels. Each point has horizontal and vertical 95% CIs, with zero lines and a dashed identity line.
Figure 3: Near-eye and chest chronotype associations fitted separately for the same participants and participant-days. Horizontal and vertical bars are component 95% CIs; the dashed diagonal is an identity reference and is not an equivalence boundary.
paired_effects |>
  arrange(.data$metric_order, .data$instrument_id) |>
  transmute(
    Metric = .data$manuscript_name,
    Instrument = .data$instrument_name,
    `Near-eye effect (95% CI), h` = format_effect(
      .data$estimate_glasses,
      .data$conf_low_glasses,
      .data$conf_high_glasses
    ),
    `Chest effect (95% CI), h` = format_effect(
      .data$estimate_chest,
      .data$conf_low_chest,
      .data$conf_high_chest
    ),
    `Exact paired participants / days / observations` = sprintf(
      "%d / %d / %d",
      .data$participants_glasses,
      .data$participant_days_glasses,
      .data$observations_glasses
    )
  ) |>
  h09_gt(
    note = paste(
      "All sample identities matched exactly. The intervals belong to the",
      "two separately fitted component effects; no interval for their",
      "difference was estimated."
    )
  )
Table 6: Exactly matched near-eye and chest component estimates.
Metric Instrument Near-eye effect (95% CI), h Chest effect (95% CI), h Exact paired participants / days / observations
Midpoint of the brightest 10 hours MCTQ MSFsc +0.118 (-0.061 to +0.298) +0.093 (-0.094 to +0.280) 111 / 637 / 637
Midpoint of the brightest 10 hours MEQ -0.225 (-0.426 to -0.023) -0.287 (-0.495 to -0.080) 112 / 643 / 643
Midpoint of the darkest 10 hours MCTQ MSFsc +0.190 (+0.011 to +0.369) +0.111 (-0.066 to +0.288) 111 / 637 / 637
Midpoint of the darkest 10 hours MEQ -0.256 (-0.459 to -0.054) -0.219 (-0.417 to -0.021) 112 / 643 / 643
First light timing above 250 lx melEDI MCTQ MSFsc +0.329 (+0.080 to +0.579) +0.353 (+0.099 to +0.607) 111 / 558 / 558
First light timing above 250 lx melEDI MEQ -0.412 (-0.692 to -0.132) -0.517 (-0.799 to -0.234) 112 / 563 / 563
Last light timing above 250 lx melEDI MCTQ MSFsc -0.081 (-0.339 to +0.176) -0.206 (-0.427 to +0.015) 111 / 520 / 520
Last light timing above 250 lx melEDI MEQ +0.082 (-0.217 to +0.381) +0.167 (-0.095 to +0.428) 112 / 524 / 524
Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc +0.125 (-0.195 to +0.445) +0.009 (-0.296 to +0.314) 104 / 355 / 355
Midpoint of the longest continuous period above 250 lx melEDI MEQ -0.103 (-0.470 to +0.264) -0.065 (-0.412 to +0.282) 105 / 357 / 357
All sample identities matched exactly. The intervals belong to the two separately fitted component effects; no interval for their difference was estimated.

Paired-placement source data retain exact sample counts and component intervals.

Chronotype-by-site interactions

No MCTQ-by-site or MEQ-by-site interaction survived its separate five-outcome FDR family at either placement. The smallest interaction-adjusted p-value was 0.070 for the near-eye longest-period midpoint with MEQ. Consequently, site-specific trends remain descriptive rather than separate inferential findings.

primary_registered |>
  transmute(
    .data$metric_order,
    Placement = .data$placement_label,
    Metric = .data$manuscript_name,
    Instrument = .data$instrument_name,
    `LRT statistic (df)` = sprintf(
      "%.3f (%d)",
      .data$interaction_chi_square,
      .data$interaction_df
    ),
    `Raw p` = format_p_cell(
      .data$interaction_p_raw,
      .data$interaction_raw_significant
    ),
    `FDR-adjusted p` = format_p_cell(
      .data$interaction_p_adjusted,
      .data$interaction_adjusted_significant
    )
  ) |>
  arrange(.data$metric_order, .data$Instrument, .data$Placement) |>
  select(-.data$metric_order) |>
  h09_gt(
    note = paste(
      "Raw p-values are bold when raw p < 0.050; adjusted p-values are bold",
      "when FDR-adjusted p < 0.050 within the relevant five-outcome",
      "instrument-by-placement interaction family. No adjusted value met",
      "the rule."
    )
  )
Table 7: Chronotype-by-site interaction tests for the primary dataset.
Placement Metric Instrument LRT statistic (df) Raw p FDR-adjusted p
Chest Midpoint of the brightest 10 hours MCTQ MSFsc 9.546 (7) 0.216 0.360
Near eye Midpoint of the brightest 10 hours MCTQ MSFsc 12.225 (8) 0.141 0.239
Chest Midpoint of the brightest 10 hours MEQ 9.597 (7) 0.213 0.587
Near eye Midpoint of the brightest 10 hours MEQ 7.968 (8) 0.437 0.718
Chest Midpoint of the darkest 10 hours MCTQ MSFsc 13.061 (7) 0.071 0.353
Near eye Midpoint of the darkest 10 hours MCTQ MSFsc 12.175 (8) 0.144 0.239
Chest Midpoint of the darkest 10 hours MEQ 4.615 (7) 0.707 0.707
Near eye Midpoint of the darkest 10 hours MEQ 3.332 (8) 0.912 0.912
Chest First light timing above 250 lx melEDI MCTQ MSFsc 10.119 (7) 0.182 0.360
Near eye First light timing above 250 lx melEDI MCTQ MSFsc 7.044 (8) 0.532 0.532
Chest First light timing above 250 lx melEDI MEQ 7.916 (7) 0.340 0.587
Near eye First light timing above 250 lx melEDI MEQ 9.737 (8) 0.284 0.710
Chest Last light timing above 250 lx melEDI MCTQ MSFsc 3.426 (7) 0.843 0.843
Near eye Last light timing above 250 lx melEDI MCTQ MSFsc 7.060 (8) 0.530 0.532
Chest Last light timing above 250 lx melEDI MEQ 4.829 (7) 0.681 0.707
Near eye Last light timing above 250 lx melEDI MEQ 6.653 (8) 0.574 0.718
Chest Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc 5.436 (7) 0.607 0.759
Near eye Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc 12.583 (8) 0.127 0.239
Chest Midpoint of the longest continuous period above 250 lx melEDI MEQ 7.783 (7) 0.352 0.587
Near eye Midpoint of the longest continuous period above 250 lx melEDI MEQ 19.168 (8) 0.014 0.070
Raw p-values are bold when raw p < 0.050; adjusted p-values are bold when FDR-adjusted p < 0.050 within the relevant five-outcome instrument-by-placement interaction family. No adjusted value met the rule.

The descriptive site-specific slopes and 95% intervals use the country-coded study site names and configured display order.

Model checks, dependence, influence, and limitations

The model-check registry (model diagnostics) assessed 16 domains for each of 24 primary targets. Eighteen targets were acceptable across every domain; six retained at least one explicit limitation. Visual review of the full near-eye and chest panels supported acceptable final assessments for response/residual distribution and residual heteroscedasticity. Conservative numerical screen flags remain in the model-check source data and are not erased by that visual assessment.

diagnostic_summary |>
  filter(.data$overall_assessment == "not acceptable") |>
  left_join(
    metric_registry |>
      select(.data$metric_id, .data$metric_order, .data$manuscript_name),
    by = "metric_id",
    relationship = "many-to-one"
  ) |>
  arrange(.data$metric_order, .data$instrument_id, .data$placement_label) |>
  transmute(
    Placement = .data$placement_label,
    Metric = .data$manuscript_name,
    Instrument = .data$instrument_id,
    `Retained limitation(s)` = str_replace_all(
      .data$not_acceptable_domain_names,
      "[|]",
      ";"
    ),
    `Participants / days / observations / hours / sites` = format_sample(
      .data$participants,
      .data$participant_days,
      .data$observations,
      .data$derivation_hours,
      .data$sites
    )
  ) |>
  h09_gt(
    note = paste(
      "Distribution and heteroscedasticity are not among these final",
      "limitations. A target can have more than one remaining domain."
    )
  )
Table 8: Primary targets retaining at least one explicit model-check limitation.
Placement Metric Instrument Retained limitation(s) Participants / days / observations / hours / sites
Near eye Last light timing above 250 lx melEDI MCTQ Participant influence ; Site influence 140 / 683 / 683 / 15900.8 h / 9
Near eye Last light timing above 250 lx melEDI MEQ Participant influence ; Site influence 141 / 687 / 687 / 15995.0 h / 9
Chest Midpoint of the longest continuous period above 250 lx melEDI MCTQ Prepared-data sensitivity 149 / 547 / 547 / 12980.0 h / 8
Near eye Midpoint of the longest continuous period above 250 lx melEDI MCTQ Prepared-data sensitivity 131 / 478 / 478 / 11325.5 h / 9
Chest Midpoint of the longest continuous period above 250 lx melEDI MEQ Convergence and Hessian ; Singularity and variance ; Prepared-data sensitivity 150 / 549 / 549 / 13027.8 h / 8
Near eye Midpoint of the longest continuous period above 250 lx melEDI MEQ Prepared-data sensitivity 132 / 482 / 482 / 11419.8 h / 9
Distribution and heteroscedasticity are not among these final limitations. A target can have more than one remaining domain.

The two near-eye last-timing slopes were close to zero and changed direction under some participant-deletion and leave-one-site-out checks, although no deletion changed a slope by the material 0.25-hour threshold. Chest longest-period/MEQ had a singular maximum-likelihood chronotype-by-site interaction fit; its additive main-effect fit remained estimable, but that interaction fit remains a model-check limitation. The registered longest-period outcome was unavailable in the gap-timing-unaware prepared artifact at both placements and for both instruments. Fixed-effect matrices were full rank and Hessians positive definite for all stored fits. Repeated observations closer in time may retain more similar residuals, a dependence called autocorrelation. A continuous-time first-order autoregressive, or AR(1), sensitivity represented this dependence as stronger at shorter elapsed-time gaps; its corrections changed slopes by at most 0.016 h.

include_project_graphics(file.path(
  root,
  "results",
  "images",
  "H09",
  "H09_diagnostics_near_eye.png"
))
Twenty-four near-eye model-check panels show residuals versus fitted timing in the upper half and normal-score Q-Q plots in the lower half. Some first-above-250 and L10 panels depart more at the tails than other outcomes, while the overall patterns were judged acceptable.
Figure 4: Near-eye residual-versus-fitted and normal-score Q-Q model checks for the six analysed timing outcomes and both chronotype instruments. Visual review supported acceptable response/residual distribution and heteroscedasticity assessments.
include_project_graphics(file.path(
  root,
  "results",
  "images",
  "H09",
  "H09_diagnostics_chest.png"
))
Twenty-four chest model-check panels show residuals versus fitted timing in the upper half and normal-score Q-Q plots in the lower half. Some first-above-250 and L10 panels depart more at the tails than other outcomes, while the overall patterns were judged acceptable.
Figure 5: Chest residual-versus-fitted and normal-score Q-Q model checks for the six analysed timing outcomes and both chronotype instruments. Visual review supported acceptable response/residual distribution and heteroscedasticity assessments.

The complete model-check registry, residual-check assessment record, participant-influence summary, and leave-one-site-out summary retain the thresholds, evidence, and final assessment basis.

Dataset sensitivity

For these comparisons, a common sample means that the primary and gap-timing-unaware datasets use the same participants and participant-days. For the four registered outcomes available in both datasets, the models were compared on those identical participant-day keys. Fifteen of 16 instrument-by-placement comparisons were stable within model uncertainty. Chest MCTQ L10 was precision-sensitive, with overlapping 95% intervals but a change in whether the interval excluded zero. The registered longest-period midpoint could not be evaluated because the gap-timing-unaware artifact contains neither that metric nor the endpoints needed to derive it.

gap_sensitivity |>
  filter(
    .data$metric_id != "mean_timing_above_250",
    .data$common_sample_stability != "stable within model uncertainty"
  ) |>
  arrange(.data$metric_order, .data$instrument_id, .data$placement_label) |>
  transmute(
    Placement = .data$placement_label,
    Metric = .data$manuscript_name,
    Instrument = .data$instrument_name,
    `Primary effect (95% CI), h` = format_effect(
      .data$primary_common_estimate,
      .data$primary_common_conf_low,
      .data$primary_common_conf_high
    ),
    `Gap-timing-unaware effect (95% CI), h` = format_effect(
      .data$gap_common_estimate,
      .data$gap_common_conf_low,
      .data$gap_common_conf_high
    ),
    `Common participants / days / observations / hours / sites` =
      format_sample(
        .data$primary_common_participants,
        .data$primary_common_participant_days,
        .data$primary_common_observations,
        .data$primary_common_derivation_hours,
        .data$primary_common_sites
      ),
    Assessment = str_to_sentence(.data$common_sample_stability)
  ) |>
  h09_gt(
    note = paste(
      "The other 15 estimable registered comparisons were stable within",
      "model uncertainty on exact common keys. Non-estimable rows retain",
      "the missing prepared-data limitation rather than an imputed result."
    )
  )
Table 9: Registered dataset-sensitivity comparisons requiring qualification.
Placement Metric Instrument Primary effect (95% CI), h Gap-timing-unaware effect (95% CI), h Common participants / days / observations / hours / sites Assessment
Chest Midpoint of the darkest 10 hours MCTQ MSFsc +0.175 (+0.022 to +0.328) +0.116 (-0.045 to +0.277) 153 / 888 / 888 / 20594.8 h / 8 Precision-sensitive
Chest Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc Not estimable Not estimable Not available Non-estimable
Near eye Midpoint of the longest continuous period above 250 lx melEDI MCTQ MSFsc Not estimable Not estimable Not available Non-estimable
Chest Midpoint of the longest continuous period above 250 lx melEDI MEQ Not estimable Not estimable Not available Non-estimable
Near eye Midpoint of the longest continuous period above 250 lx melEDI MEQ Not estimable Not estimable Not available Non-estimable
The other 15 estimable registered comparisons were stable within model uncertainty on exact common keys. Non-estimable rows retain the missing prepared-data limitation rather than an imputed result.

The complete dataset-sensitivity table contains all exact samples, intervals, family-adjusted decisions, and row-key identities.

Other named sensitivity analyses

The photoperiod sensitivity added centered within-site photoperiod to the participant-day model. It was stable for 21 of 24 targets; its two direction-sensitive results were the near-zero near-eye last-timing slopes, and one chest mean-timing estimate was precision-sensitive. The participant summary sensitivity replaced repeated participant-day outcomes with one equal-weight outcome mean per participant; it was stable for 20 of 24 targets, with direction changes confined to near-zero last-timing or chest longest-period slopes. All 24 continuous-time AR(1) checks of residual autocorrelation were stable. The L10 clock-cut sensitivity compared the registered strict >16:00 negative-hour conversion with a noon cut; slopes changed by at most 0.037 h, and all four checks were acceptable.

tibble(
  Sensitivity = c(
    "Within-site photoperiod adjustment",
    "Equal-weight participant summary",
    "Continuous-time AR(1)",
    "L10 clock cut"
  ),
  `Targets assessed` = c(
    nrow(photoperiod),
    nrow(participant_summary),
    nrow(ar1),
    nrow(l10_cut)
  ),
  Result = c(
    sprintf(
      "%d stable; %d direction-sensitive; %d precision-sensitive",
      sum(photoperiod$stability_classification ==
        "stable within model uncertainty"),
      sum(photoperiod$stability_classification == "direction-sensitive"),
      sum(photoperiod$stability_classification == "precision-sensitive")
    ),
    sprintf(
      "%d stable; %d direction-sensitive",
      sum(participant_summary$stability_classification ==
        "stable within model uncertainty"),
      sum(participant_summary$stability_classification ==
        "direction-sensitive")
    ),
    sprintf(
      "%d stable; maximum slope change %.3f h",
      sum(ar1$stability_classification == "stable within model uncertainty"),
      max(abs(ar1$ar1_effect_difference))
    ),
    sprintf(
      "%d acceptable; maximum slope change %.3f h",
      sum(l10_cut$clock_cut_assessment == "acceptable"),
      max(abs(l10_cut$estimate_difference))
    )
  )
) |>
  h09_gt()
Table 10: Summary of structured sensitivity classifications.
Sensitivity Targets assessed Result
Within-site photoperiod adjustment 24 21 stable; 2 direction-sensitive; 1 precision-sensitive
Equal-weight participant summary 24 20 stable; 4 direction-sensitive
Continuous-time AR(1) 24 24 stable; maximum slope change 0.016 h
L10 clock cut 4 4 acceptable; maximum slope change 0.037 h

The registered fifth outcome remained the midpoint of the longest continuous period. Mean timing across all supported exposure above 250 lx melEDI is a distinct sensitivity estimand and was not substituted for that outcome. Its near-eye MEQ estimate was −0.169 h per 10 MEQ points (95% CI −0.334 to −0.003; raw p = 0.041), but this raw result is outside the registered multiplicity families and does not alter the confirmatory conclusion.

mean_timing |>
  arrange(.data$placement, .data$instrument_id) |>
  transmute(
    Placement = .data$placement_label,
    Instrument = .data$instrument_name,
    `Effect in local clock hours (95% CI)` = format_effect(
      .data$estimate,
      .data$conf_low,
      .data$conf_high
    ),
    `Raw p` = format_p_cell(
      .data$main_p_raw,
      .data$main_p_raw < 0.05
    ),
    `Participants / days / observations / hours / sites` = format_sample(
      .data$participants,
      .data$participant_days,
      .data$observations,
      .data$derivation_hours,
      .data$sites
    )
  ) |>
  h09_gt(
    note = paste(
      "Intervals are 95% Wald confidence intervals. Raw p-values are bold",
      "when raw p < 0.050. No FDR-adjusted p-value is assigned because this",
      "distinct estimand is outside the registered five-outcome families."
    )
  )
Table 11: Distinct mean-timing sensitivity outside the registered five-metric families.
Placement Instrument Effect in local clock hours (95% CI) Raw p Participants / days / observations / hours / sites
Chest MCTQ MSFsc +0.108 (-0.037 to +0.254) 0.133 153 / 825 / 825 / 19196.8 h / 8
Chest MEQ -0.138 (-0.296 to +0.021) 0.080 154 / 831 / 831 / 19336.1 h / 8
Near eye MCTQ MSFsc +0.090 (-0.064 to +0.243) 0.240 140 / 736 / 736 / 17070.3 h / 9
Near eye MEQ -0.169 (-0.334 to -0.003) 0.041 141 / 742 / 742 / 17209.6 h / 9
Intervals are 95% Wald confidence intervals. Raw p-values are bold when raw p < 0.050. No FDR-adjusted p-value is assigned because this distinct estimand is outside the registered five-outcome families.

Photoperiod, participant-summary, continuous-time AR(1), L10 clock-cut, and mean-timing source tables retain exact samples and 95% CIs.

Interpretation

The primary near-eye evidence supports an association between chronotype and the timing of several, but not all, features of personal light exposure. Participants with later corrected midsleep tended to have later M10, L10, and first-above-250 timing; participants with greater morning preference tended to show the corresponding earlier timing. The agreement of these distinct chronotype constructs strengthens the directional interpretation without making them interchangeable.

The data did not support an association with last-above-250 timing or the registered longest-period midpoint, nor did they support chronotype-by-site interactions after FDR adjustment. Chest evidence was complementary and broadly consistent, but it neither replaces the primary near-eye results nor establishes placement equivalence. The analysis is observational and between participants; it does not establish that chronotype causes a change in personal light exposure timing.

The conclusion should be read alongside the direction instability of the near-zero near-eye last-timing estimates, the qualified chest longest-period/MEQ interaction fit, and the unavailable longest-period gap-timing-unaware comparison. These limitations do not change the supported M10, L10, and first-timing pattern, but they constrain claims about the unsupported outcomes and cross-dataset robustness.

Preregistration deviations

  • H09 chronotype predictors: MCTQ MSFsc and MEQ remain distinct chronotype constructs with separate models, score directions, and inferential families.
  • H09 site adjustment: study-site-adjusted average chronotype associations and chronotype-by-site interactions are fitted and interpreted as separate questions.
  • H09 multiplicity and model selection: the analysis uses four complete five-outcome FDR families;MCTQ average, MEQ average, MCTQ-by-site interaction, and MEQ-by-site interaction.