H01: Site differences in personal light exposure

Question and analysis sequence

This analysis estimates associations of study site, photoperiod, and latitude with personal light-exposure metrics. Near-eye measurements define the primary analysis. Chest measurements, placement-matched samples, and the alternative preprocessing dataset address distinct sensitivity questions.

The notebook reads the datasets derived in the preparation pages, fits the specified models, checks their assumptions, evaluates sensitivities, and calculates bootstrap uncertainty. Model objects, exact fitted samples, and numerical results are saved under results/ as they are produced.

Data and model guide

The metric preparation and analysis-dataset preparation supply 17 outcomes, their units, metric-specific support and participant/site keys. Interdaily stability and intradaily variability have one outcome per participant; the other outcomes have one per eligible participant-day. Missing support stays missing. The primary and alternative preprocessing datasets are fitted separately at each sensor position, using both all-available and placement-matched samples.

The site model includes study site and centred civil photoperiod. The latitude model replaces site with centred absolute latitude per 10 degrees on the same observations. Site and latitude are not independent predictors within one model. Gaussian participant-level models use ordinary regression; participant-day Gaussian or Tweedie models include participant random intercepts. Nested comparisons use maximum likelihood, with final Gaussian mixed-model coefficients obtained by restricted maximum likelihood. The exact response families, transforms and formulas are displayed below.

Four complete 17-test FDR families address site, photoperiod, latitude and the adequacy of latitude relative to site. Site follow-ups require a supported omnibus test and use equal-site averaging. Conditional R² includes participant variation; marginal R² describes fixed predictors. Their difference is the participant-associated share. Term-specific part-R² values can overlap. Joint model-refit bootstraps provide uncertainty; the central quick profile reduces their count to 50. Residual, influence, metric-definition and preprocessing checks qualify the findings.

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

Libraries and shared settings

library(dplyr)

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

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

    intersect, setdiff, setequal, union
library(tidyr)
library(tibble)
library(readr)
library(gt)
source("scripts/project.R")
analysis_setup()
root <- getOption("nh.root")
source("scripts/pipeline/paths_io.R")
source("scripts/pipeline/assertions.R")
source("scripts/pipeline/multiplicity.R")
source("scripts/hypotheses/H01/h01_contract.R")
source("scripts/hypotheses/H01/h01_modeling.R")
source("scripts/hypotheses/H01/h01_result_helpers.R")
producer <- "analyses/H01-site-differences.qmd"
model_root <- file.path(root, "results/models/H01")
diagnostic_root <- file.path(root, "results/csv/diagnostics/H01")
table_root <- file.path(root, "results/tables/H01")
figure_root <- file.path(root, "results/images/H01")
source_root <- file.path(root, "results/csv/source_data/H01")
for (directory in c(model_root, diagnostic_root, table_root, figure_root, source_root)) {
  dir.create(directory, recursive = TRUE, showWarnings = FALSE)
}
save_plots <- TRUE
bootstrap_refits <- bootstrap_count(1000L)
bootstrap_cores <- analysis_workers()

Prepared data and model specifications

Both preprocessing variants are loaded from the outputs of the preparation notebooks. The registry specifies each response scale, model family, and analysis unit. Scenario identifiers are held fixed so that each bootstrap uses the same numerical seed on repeated runs.

input_contract <- h01_input_contract(root)
objects <- list(
  main = readRDS(input_contract$main$path),
  alternative_preprocessing = readRDS(input_contract$alternative_preprocessing$path)
)
metric_registry <- h01_metric_registry()
h01_validate_registry(metric_registry)
display_registry <- objects$main$metric_contract |>
  dplyr::select(
    metric_order,
    metric_id,
    manuscript_name,
    abbreviation,
    manuscript_category,
    display_unit,
    variant_label,
    value_definition
  )
metric_registry <- dplyr::left_join(
  metric_registry,
  display_registry,
  by = c("metric_order", "metric_id"),
  relationship = "one-to-one"
)
if (any(!stats::complete.cases(
  metric_registry[c("manuscript_name", "display_unit")]
))) {
  h01_abort("H01 display registry does not cover all 17 metrics")
}


run_registry <- h01_run_registry()
metric_registry |> select(manuscript_name, analysis_unit, response_family, response_transform) |> gt()
manuscript_name analysis_unit response_family response_transform
Interdaily stability participant gaussian logit
Intradaily variability participant gaussian identity
Mean melEDI participant_day gaussian log10_offset_0.1
Brightest 10 h mean participant_day gaussian log10_offset_0.1
Darkest 10 h mean participant_day gaussian log10_offset_0.1
Time above 1,000 lx melEDI participant_day tweedie_log identity
Time above 250 lx melEDI during wake participant_day tweedie_log identity
Time below 10 lx melEDI before sleep participant_day gaussian identity
Time below 1 lx melEDI during sleep participant_day tweedie_log identity
Longest continuous period above 250 lx melEDI participant_day gaussian log10_offset_0.1
Midpoint of the brightest 10 hours participant_day gaussian clock_hours
Midpoint of the darkest 10 hours participant_day gaussian clock_hours_midnight_after_16
Mean timing of exposure above 250 lx melEDI participant_day gaussian clock_hours
First light timing above 250 lx melEDI participant_day gaussian clock_hours
Last light timing above 250 lx melEDI participant_day gaussian clock_hours
melEDI dose participant_day gaussian log10_offset_0.1
Melanopic daylight efficacy ratio participant_day gaussian identity

Fit the specified models

Fit every declared metric and sample using its specified response model. Each saved model is paired with the exact rows used to fit it.

result_lists <- list(
  tests = list(),
  term_effects = list(),
  site_estimates = list(),
  site_deviations = list(),
  marginalization = list(),
  samples = list(),
  samples_by_site = list(),
  diagnostics = list(),
  influence = list(),
  latitude_loo = list(),
  random_site = list(),
  r2_point = list(),
  model_specifications = list(),
  preregistered_scope = list(),
  exactly_identified_period = list(),
  l10_noon_tests = list(),
  l10_noon_term_effects = list(),
  l10_noon_site_estimates = list(),
  l10_noon_site_deviations = list(),
  l10_noon_samples = list(),
  l10_noon_diagnostics = list(),
  l10_noon_r2 = list(),
  l10_noon_model_specifications = list()
)
result_index <- stats::setNames(
  rep(1L, length(result_lists)),
  names(result_lists)
)
add_result <- function(name, value) {
  if (nrow(value) == 0L) {
    return(invisible(NULL))
  }
  result_lists[[name]][[result_index[[name]]]] <<- value
  result_index[[name]] <<- result_index[[name]] + 1L
  invisible(NULL)
}

for (run_index in seq_len(nrow(run_registry))) {
  run <- run_registry[run_index, , drop = FALSE]
  object <- objects[[run$data_scenario_id]]
  message("Fitting H01 run: ", run$run_id)
  for (metric_index in seq_len(nrow(metric_registry))) {
    spec <- metric_registry[metric_index, , drop = FALSE]
    message("  ", spec$metric_order, "/17 ", spec$metric_id)
    frame <- h01_prepare_model_frame(
      object,
      spec,
      placement = run$placement,
      sample_scenario = run$sample_scenario
    )
    run_path <- file.path(
      run$data_scenario_id,
      run$placement,
      run$sample_scenario
    )
    model_directory <- file.path(model_root, run_path)
    diagnostic_directory <- file.path(diagnostic_root, run_path)
    source_directory <- file.path(source_root, run_path)
    invisible(vapply(
      c(model_directory, diagnostic_directory, source_directory),
      dir.create,
      logical(1),
      recursive = TRUE,
      showWarnings = FALSE
    ))
    frame_path <- file.path(
      model_directory,
      paste0(spec$metric_id, "_model_frame.rds")
    )
    bundle_path <- file.path(
      model_directory,
      paste0(spec$metric_id, "_models.rds")
    )
    if (nrow(frame) == 0L) {
      empty <- h01_empty_metric_rows(run, spec)
      add_result("tests", empty$tests)
      add_result("diagnostics", empty$diagnostics)
      add_result("samples", empty$samples)
      h01_write_rds(
        list(
          status = "NON_ESTIMABLE",
          spec = spec,
          run = run,
          reason = "Prepared scenario is unavailable for this metric"
        ),
        bundle_path
      )
      h01_write_rds(frame, frame_path)
      next
    }
    bundle <- h01_fit_metric_models(frame, spec)
    add_result("model_specifications", h01_model_specification_rows(bundle, run, spec))
    h01_write_rds(frame, frame_path)
    h01_write_rds(bundle, bundle_path)
    frame_csv <- frame |>
      dplyr::select(
        .model_row_id,
        site,
        participant_key,
        local_date,
        value,
        response_value,
        photoperiod_hours,
        photoperiod_centered_hours,
        latitude_deg,
        absolute_latitude_10deg_centered,
        metric_support_available,
        metric_support_valid_minutes,
        metric_support_expected_minutes,
        metric_any_censored
      )
    h01_write_csv(
      frame_csv,
      file.path(
        source_directory,
        paste0(spec$metric_id, "_model_frame.csv")
      )
    )


  }
}
Fitting H01 run: alternative_preprocessing__chest__all_available
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
boundary (singular) fit: see help('isSingular')
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Fitting H01 run: alternative_preprocessing__chest__paired_common_sample
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
boundary (singular) fit: see help('isSingular')
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
boundary (singular) fit: see help('isSingular')
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Fitting H01 run: alternative_preprocessing__glasses__all_available
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
boundary (singular) fit: see help('isSingular')
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
boundary (singular) fit: see help('isSingular')
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
boundary (singular) fit: see help('isSingular')
  17/17 mder_mean_of_viable_ratios
Fitting H01 run: alternative_preprocessing__glasses__paired_common_sample
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
boundary (singular) fit: see help('isSingular')
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
boundary (singular) fit: see help('isSingular')
  17/17 mder_mean_of_viable_ratios
Fitting H01 run: main__chest__all_available
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
boundary (singular) fit: see help('isSingular')
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Fitting H01 run: main__chest__paired_common_sample
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
boundary (singular) fit: see help('isSingular')
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
boundary (singular) fit: see help('isSingular')
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Fitting H01 run: main__glasses__all_available
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
boundary (singular) fit: see help('isSingular')
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
boundary (singular) fit: see help('isSingular')
  17/17 mder_mean_of_viable_ratios
Fitting H01 run: main__glasses__paired_common_sample
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
boundary (singular) fit: see help('isSingular')
  17/17 mder_mean_of_viable_ratios
bind_rows(result_lists$model_specifications) |> count(estimation_stage, converged, status) |> gt()
estimation_stage converged status n
comparison FALSE FITTED 14
comparison FALSE NON_ESTIMABLE 8
comparison TRUE FITTED 618
final TRUE FITTED 512

Estimate associations and represented variation

Compare the fitted site and latitude specifications, estimate effects, and record model-specific samples and variance components.

for (run_index in seq_len(nrow(run_registry))) {
  run <- run_registry[run_index, , drop = FALSE]
  object <- objects[[run$data_scenario_id]]
  for (metric_index in seq_len(nrow(metric_registry))) {
    spec <- metric_registry[metric_index, , drop = FALSE]
    run_path <- file.path(run$data_scenario_id, run$placement, run$sample_scenario)
    model_directory <- file.path(model_root, run_path)
    source_directory <- file.path(source_root, run_path)
    diagnostic_directory <- file.path(figure_root, "diagnostics", run_path)
    dir.create(diagnostic_directory, recursive = TRUE, showWarnings = FALSE)
    frame <- readRDS(file.path(model_directory, paste0(spec$metric_id, "_model_frame.rds")))
    bundle <- readRDS(file.path(model_directory, paste0(spec$metric_id, "_models.rds")))
    if (identical(bundle$status, "NON_ESTIMABLE") || nrow(frame) == 0L) next
    site_model <- h01_unwrap_model(bundle, "final", "site_full")
    seed <- h01_primary_seed(spec$metric_order, run$data_scenario_id,
                             run$placement, run$sample_scenario)
tests <- h01_model_tests(bundle) |>
  dplyr::left_join(
    h01_family_registry(),
    by = c("comparison_id" = "comparison"),
    relationship = "many-to-one"
  )
add_result("tests", h01_add_identity(tests, run, spec))

site_model <- h01_unwrap_model(bundle, "final", "site_full")
latitude_model <- h01_unwrap_model(
  bundle,
  "final",
  "latitude_full"
)
effects <- dplyr::bind_rows(
  h01_extract_term_effect(
    site_model,
    "photoperiod_centered_hours",
    "Photoperiod per hour",
    spec
  ),
  h01_extract_term_effect(
    latitude_model,
    "absolute_latitude_10deg_centered",
    "Absolute latitude per 10 degrees",
    spec
  )
)
add_result(
  "term_effects",
  h01_add_identity(effects, run, spec)
)

site <- h01_site_summaries(site_model, frame, spec)
add_result(
  "site_estimates",
  h01_add_identity(site$estimates, run, spec)
)
add_result(
  "site_deviations",
  h01_add_identity(site$deviations, run, spec)
)
add_result(
  "marginalization",
  h01_add_identity(site$marginalization, run, spec)
)

samples <- h01_sample_summary(frame, spec)
add_result(
  "samples",
  h01_add_identity(
    dplyr::mutate(samples$overall, sample_status = "FITTED"),
    run,
    spec
  )
)
add_result(
  "samples_by_site",
  h01_add_identity(samples$by_site, run, spec)
)

add_result(
  "random_site",
  h01_add_identity(
    h01_fit_random_site_summary(bundle),
    run,
    spec
  )
)
add_result(
  "r2_point",
  h01_add_identity(
    h01_r2_point_summary(bundle),
    run,
    spec
  )
)


  }
}

bind_rows(result_lists$term_effects) |> select(run_id, metric_id, everything()) |> head()
# A tibble: 6 × 23
  run_id    metric_id data_scenario_id placement sample_scenario analytical_role
  <chr>     <chr>     <chr>            <chr>     <chr>           <chr>          
1 alternat… interdai… alternative_pre… chest     all_available   complementary_…
2 alternat… interdai… alternative_pre… chest     all_available   complementary_…
3 alternat… intradai… alternative_pre… chest     all_available   complementary_…
4 alternat… intradai… alternative_pre… chest     all_available   complementary_…
5 alternat… daily_ge… alternative_pre… chest     all_available   complementary_…
6 alternat… daily_ge… alternative_pre… chest     all_available   complementary_…
# ℹ 17 more variables: metric_order <int>, analysis_unit <chr>,
#   response_family <chr>, response_transform <chr>, term <chr>,
#   term_label <chr>, estimate_model <dbl>, std_error <dbl>,
#   conf_low_model <dbl>, conf_high_model <dbl>, p_raw <dbl>,
#   effect_type <chr>, estimate_practical <dbl>, conf_low_practical <dbl>,
#   conf_high_practical <dbl>, interval_method <chr>, status <chr>

Check model assumptions

Evaluate residual distributions, prediction support, and other diagnostics. Failed model checks remain explicit and prevent unqualified inference.

for (run_index in seq_len(nrow(run_registry))) {
  run <- run_registry[run_index, , drop = FALSE]
  object <- objects[[run$data_scenario_id]]
  for (metric_index in seq_len(nrow(metric_registry))) {
    spec <- metric_registry[metric_index, , drop = FALSE]
    run_path <- file.path(run$data_scenario_id, run$placement, run$sample_scenario)
    model_directory <- file.path(model_root, run_path)
    source_directory <- file.path(source_root, run_path)
    diagnostic_directory <- file.path(figure_root, "diagnostics", run_path)
    dir.create(diagnostic_directory, recursive = TRUE, showWarnings = FALSE)
    frame <- readRDS(file.path(model_directory, paste0(spec$metric_id, "_model_frame.rds")))
    bundle <- readRDS(file.path(model_directory, paste0(spec$metric_id, "_models.rds")))
    if (identical(bundle$status, "NON_ESTIMABLE") || nrow(frame) == 0L) next
    site_model <- h01_unwrap_model(bundle, "final", "site_full")
    seed <- h01_primary_seed(spec$metric_order, run$data_scenario_id,
                             run$placement, run$sample_scenario)
seed <- h01_primary_seed(
  spec$metric_order,
  run$data_scenario_id,
  run$placement,
  run$sample_scenario
)
diagnostics <- h01_model_diagnostics(bundle, frame, seed)
add_result(
  "diagnostics",
  h01_add_identity(diagnostics, run, spec)
)
diagnostic_data <- h01_diagnostic_plot_data(site_model, frame)
diagnostic_source_path <- file.path(
  source_directory,
  paste0(spec$metric_id, "_diagnostic_plot_data.csv")
)
h01_write_csv(diagnostic_data, diagnostic_source_path)
if (save_plots) {
  h01_save_diagnostic_plot(
    diagnostic_data,
    file.path(
      diagnostic_directory,
      paste0(spec$metric_id, "_diagnostics.png")
    ),
    paste(
      "H01",
      spec$manuscript_name,
      run$data_scenario_id,
      run$placement,
      run$sample_scenario,
      sep = "; "
    )
  )
}


  }
}
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'
bind_rows(result_lists$diagnostics) |> count(diagnostic_status) |> gt()
diagnostic_status n
NON_ESTIMABLE 8
PASS 31
WARN_REVIEW 97

Evaluate sensitivity to analytical choices

Repeat the relevant fits after removing influential participants or individual sites, restricting to exactly identified bright periods, changing the midnight conversion, and applying the preregistered scope.

for (run_index in seq_len(nrow(run_registry))) {
  run <- run_registry[run_index, , drop = FALSE]
  object <- objects[[run$data_scenario_id]]
  for (metric_index in seq_len(nrow(metric_registry))) {
    spec <- metric_registry[metric_index, , drop = FALSE]
    run_path <- file.path(run$data_scenario_id, run$placement, run$sample_scenario)
    model_directory <- file.path(model_root, run_path)
    source_directory <- file.path(source_root, run_path)
    diagnostic_directory <- file.path(figure_root, "diagnostics", run_path)
    dir.create(diagnostic_directory, recursive = TRUE, showWarnings = FALSE)
    frame <- readRDS(file.path(model_directory, paste0(spec$metric_id, "_model_frame.rds")))
    bundle <- readRDS(file.path(model_directory, paste0(spec$metric_id, "_models.rds")))
    if (identical(bundle$status, "NON_ESTIMABLE") || nrow(frame) == 0L) next
    site_model <- h01_unwrap_model(bundle, "final", "site_full")
    seed <- h01_primary_seed(spec$metric_order, run$data_scenario_id,
                             run$placement, run$sample_scenario)
tests <- h01_model_tests(bundle)
      add_result(
        "influence",
        h01_add_identity(
          h01_participant_influence(bundle, frame),
          run,
          spec
        )
      )
      add_result(
        "latitude_loo",
        h01_add_identity(
          h01_latitude_leave_one_site_out(bundle, frame),
          run,
          spec
        )
      )
      if (
        run$data_scenario_id == "main" &&
          run$placement == "glasses" &&
          run$sample_scenario == "all_available"
      ) {
        registered <- h01_fit_registered_scope(frame, spec, tests)
        add_result(
          "preregistered_scope",
          h01_add_identity(registered, run, spec)
        )
      }

      if (spec$metric_id == "longest_bout_above_250") {
        exact_frame <- h01_prepare_model_frame(
          object,
          spec,
          placement = run$placement,
          sample_scenario = run$sample_scenario,
          exactly_identified_only = TRUE
        )
        if (nrow(exact_frame) > 0L) {
          exact_bundle <- h01_fit_metric_models(exact_frame, spec)
          exact_tests <- h01_model_tests(exact_bundle)
          exact_samples <- h01_sample_summary(exact_frame, spec)$overall
          exact_diagnostics <- h01_model_diagnostics(
            exact_bundle,
            exact_frame,
            seed + 500000L
          )
          exact_output <- dplyr::bind_cols(
            exact_tests,
            exact_samples[rep(1L, nrow(exact_tests)), , drop = FALSE],
            exact_diagnostics[
              rep(1L, nrow(exact_tests)),
              ,
              drop = FALSE
            ]
          ) |>
            dplyr::mutate(
              sensitivity_id = "exactly_identified_longest_period",
              excluded_censored_rows = nrow(frame) - nrow(exact_frame)
            )
          add_result(
            "exactly_identified_period",
            h01_add_identity(exact_output, run, spec)
          )
          h01_write_rds(
            exact_bundle,
            file.path(
              model_directory,
              paste0(
                spec$metric_id,
                "_exactly_identified_period_sensitivity_models.rds"
              )
            )
          )
        }
      }

      if (spec$metric_id == "l10_midpoint") {
        noon_spec <- spec
        noon_spec$response_transform <- "clock_hours_midnight_after_12"
        noon_frame <- h01_prepare_model_frame(
          object,
          noon_spec,
          placement = run$placement,
          sample_scenario = run$sample_scenario
        )
        if (!identical(frame$.model_row_id, noon_frame$.model_row_id)) {
          h01_abort(
            "The H01 L10 noon sensitivity changed the primary model rows"
          )
        }
        noon_bundle <- h01_fit_metric_models(noon_frame, noon_spec)
        noon_tests <- h01_model_tests(noon_bundle) |>
          dplyr::mutate(
            sensitivity_id = "l10_midpoint_noon_conversion",
            multiplicity_role = "unadjusted_sensitivity_not_family_member"
          )
        noon_site_model <- h01_unwrap_model(
          noon_bundle,
          "final",
          "site_full"
        )
        noon_latitude_model <- h01_unwrap_model(
          noon_bundle,
          "final",
          "latitude_full"
        )
        noon_effects <- dplyr::bind_rows(
          h01_extract_term_effect(
            noon_site_model,
            "photoperiod_centered_hours",
            "Photoperiod per hour",
            noon_spec
          ),
          h01_extract_term_effect(
            noon_latitude_model,
            "absolute_latitude_10deg_centered",
            "Absolute latitude per 10 degrees",
            noon_spec
          )
        ) |>
          dplyr::mutate(
            sensitivity_id = "l10_midpoint_noon_conversion"
          )
        noon_site <- h01_site_summaries(
          noon_site_model,
          noon_frame,
          noon_spec
        )
        noon_samples <- h01_sample_summary(noon_frame, noon_spec)$overall |>
          dplyr::mutate(
            sensitivity_id = "l10_midpoint_noon_conversion",
            sample_status = "FITTED"
          )
        noon_diagnostics <- h01_model_diagnostics(
          noon_bundle,
          noon_frame,
          seed + 600000L
        ) |>
          dplyr::mutate(
            sensitivity_id = "l10_midpoint_noon_conversion"
          )
        noon_r2 <- h01_r2_point_summary(noon_bundle) |>
          dplyr::mutate(
            sensitivity_id = "l10_midpoint_noon_conversion"
          )
        add_result(
          "l10_noon_tests",
          h01_add_identity(noon_tests, run, noon_spec)
        )
        add_result(
          "l10_noon_term_effects",
          h01_add_identity(noon_effects, run, noon_spec)
        )
        add_result(
          "l10_noon_site_estimates",
          h01_add_identity(
            dplyr::mutate(
              noon_site$estimates,
              sensitivity_id = "l10_midpoint_noon_conversion"
            ),
            run,
            noon_spec
          )
        )
        add_result(
          "l10_noon_site_deviations",
          h01_add_identity(
            dplyr::mutate(
              noon_site$deviations,
              sensitivity_id = "l10_midpoint_noon_conversion"
            ),
            run,
            noon_spec
          )
        )
        add_result(
          "l10_noon_samples",
          h01_add_identity(noon_samples, run, noon_spec)
        )
        add_result(
          "l10_noon_diagnostics",
          h01_add_identity(noon_diagnostics, run, noon_spec)
        )
        add_result(
          "l10_noon_r2",
          h01_add_identity(noon_r2, run, noon_spec)
        )
        add_result(
          "l10_noon_model_specifications",
          h01_model_specification_rows(noon_bundle, run, noon_spec) |>
            dplyr::mutate(
              sensitivity_id = "l10_midpoint_noon_conversion"
            )
        )
        h01_write_rds(
          noon_frame,
          file.path(
            model_directory,
            paste0(
              spec$metric_id,
              "_noon_conversion_sensitivity_model_frame.rds"
            )
          )
        )
        h01_write_rds(
          noon_bundle,
          file.path(
            model_directory,
            paste0(
              spec$metric_id,
              "_noon_conversion_sensitivity_models.rds"
            )
          )
        )
        h01_write_csv(
          noon_frame |>
            dplyr::select(
              .model_row_id,
              site,
              participant_key,
              local_date,
              value,
              response_value,
              photoperiod_hours,
              photoperiod_centered_hours,
              latitude_deg,
              absolute_latitude_10deg_centered
            ),
          file.path(
            source_directory,
            paste0(
              spec$metric_id,
              "_noon_conversion_sensitivity_model_frame.csv"
            )
          )
        )
      }

  }
}
boundary (singular) fit: see help('isSingular')
boundary (singular) fit: see help('isSingular')
boundary (singular) fit: see help('isSingular')
bind_rows(result_lists$preregistered_scope) |> head()
# A tibble: 6 × 20
  run_id data_scenario_id placement sample_scenario analytical_role metric_order
  <chr>  <chr>            <chr>     <chr>           <chr>                  <int>
1 main_… main             glasses   all_available   primary                    1
2 main_… main             glasses   all_available   primary                    1
3 main_… main             glasses   all_available   primary                    1
4 main_… main             glasses   all_available   primary                    2
5 main_… main             glasses   all_available   primary                    2
6 main_… main             glasses   all_available   primary                    2
# ℹ 14 more variables: metric_id <chr>, analysis_unit <chr>,
#   response_family <chr>, response_transform <chr>, comparison_id <chr>,
#   statistic <dbl>, df <dbl>, p_raw <dbl>, log_lik_reduced <dbl>,
#   log_lik_full <dbl>, n_obs_reduced <int>, n_obs_full <int>,
#   comparison_status <chr>, scope_change <chr>

Control multiplicity and export estimates

Apply the specified false-discovery-rate families and save the numerical estimates and diagnostic results. Model specifications and sample counts remain available alongside the estimates.

results <- lapply(result_lists, dplyr::bind_rows)
results$tests <- h01_adjust_primary_families(results$tests)
invalid_model_runs <- unique(
  results$diagnostics$run_id[
    results$diagnostics$diagnostic_status == "MODEL_CHECK_FAILED"
  ]
)
results$tests <- results$tests |>
  dplyr::mutate(
    family_status = ifelse(
      .data$run_id %in% invalid_model_runs,
      "INVALID_MODEL_CHECK",
      "COMPLETE"
    ),
    p_adjusted = ifelse(
      .data$run_id %in% invalid_model_runs,
      NA_real_,
      .data$p_adjusted
    )
  )
site_support <- results$tests |>
  dplyr::filter(family_id == "H01-F1-site") |>
  dplyr::select(
    run_id,
    metric_id,
    overall_site_p_adjusted = p_adjusted
  )
results$site_deviations <- results$site_deviations |>
  dplyr::left_join(
    site_support,
    by = c("run_id", "metric_id"),
    relationship = "many-to-one"
  ) |>
  dplyr::mutate(
    inferential_followup_supported =
      !is.na(overall_site_p_adjusted) &
        overall_site_p_adjusted < 0.05
  )

if (nrow(results$preregistered_scope) > 0L) {
  scope_family <- dplyr::case_when(
    results$preregistered_scope$comparison_id ==
      "site_full_vs_no_site" ~ "H01-S1-registered-site",
    results$preregistered_scope$comparison_id ==
      "latitude_full_vs_no_latitude" ~ "H01-S2-registered-latitude",
    results$preregistered_scope$comparison_id ==
      "site_full_vs_latitude_full" ~ "H01-S3-registered-adequacy",
    results$preregistered_scope$comparison_id ==
      "site_full_vs_no_photoperiod" ~
        "H01-S4-registered-photoperiod",
    TRUE ~ NA_character_
  )
  results$preregistered_scope$family_id <- scope_family
  results$preregistered_scope$family_n <- ifelse(
    scope_family == "H01-S4-registered-photoperiod",
    5L,
    17L
  )
  results$preregistered_scope$family_instance_id <- paste(
    results$preregistered_scope$run_id,
    scope_family,
    sep = "::"
  )
  results$preregistered_scope <- results$preregistered_scope |>
    dplyr::filter(!is.na(family_id))
  results$preregistered_scope <- adjust_result_families(
    results$preregistered_scope,
    family_col = "family_instance_id",
    p_col = "p_raw",
    family_n_col = "family_n",
    output_col = "p_adjusted",
    method = "BH"
  ) |>
    dplyr::mutate(
      family_status = ifelse(
        .data$run_id %in% invalid_model_runs,
        "INVALID_MODEL_CHECK",
        "COMPLETE"
      ),
      p_adjusted = ifelse(
        .data$run_id %in% invalid_model_runs,
        NA_real_,
        .data$p_adjusted
      )
    )
}

output_names <- c(
  tests = "H01_model_level_tests.csv",
  term_effects = "H01_term_effects.csv",
  site_estimates = "H01_site_estimates.csv",
  site_deviations = "H01_site_deviations.csv",
  marginalization = "H01_marginalization_comparison.csv",
  samples = "H01_exact_samples.csv",
  samples_by_site = "H01_exact_samples_by_site.csv",
  diagnostics = "H01_model_diagnostics.csv",
  influence = "H01_participant_influence.csv",
  latitude_loo = "H01_latitude_leave_one_site_out.csv",
  random_site = "H01_random_site_descriptions.csv",
  r2_point = "H01_r2_point_summaries.csv",
  model_specifications = "H01_model_specifications.csv",
  preregistered_scope = "H01_preregistered_scope_sensitivity.csv",
  exactly_identified_period =
    "H01_exactly_identified_period_sensitivity.csv",
  l10_noon_tests = "H01_l10_noon_conversion_model_tests.csv",
  l10_noon_term_effects =
    "H01_l10_noon_conversion_term_effects.csv",
  l10_noon_site_estimates =
    "H01_l10_noon_conversion_site_estimates.csv",
  l10_noon_site_deviations =
    "H01_l10_noon_conversion_site_deviations.csv",
  l10_noon_samples = "H01_l10_noon_conversion_samples.csv",
  l10_noon_diagnostics =
    "H01_l10_noon_conversion_diagnostics.csv",
  l10_noon_r2 = "H01_l10_noon_conversion_r2.csv",
  l10_noon_model_specifications =
    "H01_l10_noon_conversion_model_specifications.csv"
)
for (name in names(output_names)) {
  h01_write_csv(
    results[[name]],
    file.path(
      if (name %in% c(
        "diagnostics",
        "influence",
        "latitude_loo",
        "l10_noon_diagnostics"
      )) {
        diagnostic_root
      } else {
        table_root
      },
      output_names[[name]]
    )
  )
}
h01_write_rds(
  results,
  file.path(model_root, "H01_fit_results.rds")
)
results$tests |> count(family_id, family_status) |> gt()
family_id family_status n
H01-F1-site COMPLETE 136
H01-F2-photoperiod COMPLETE 136
H01-F3-latitude COMPLETE 136
H01-F4-site-latitude-adequacy COMPLETE 136

Estimate bootstrap uncertainty

Generate parametric replicates and refit the linked models jointly. Full reproduction uses 1,000 successful refits for each estimable metric and scenario; the quick profile uses the central reduced count. Each attempt has a fixed seed, and refitting stops once the requested number of successful attempts is available.

point_path <- file.path(table_root, "H01_r2_point_summaries.csv")
diagnostic_path <- file.path(
  diagnostic_root,
  "H01_model_diagnostics.csv"
)
if (!file.exists(point_path)) {
  h01_abort("H01 bootstrap requires the completed model-fit R2 summaries")
}
if (!file.exists(diagnostic_path)) {
  h01_abort("H01 bootstrap requires the completed model-fit diagnostics")
}
failed_model_checks <- readr::read_csv(
  diagnostic_path,
  show_col_types = FALSE
) |>
  dplyr::filter(
    .data$run_id %in% run_registry$run_id,
    .data$metric_id %in% metric_registry$metric_id,
    .data$diagnostic_status == "MODEL_CHECK_FAILED"
  )
if (nrow(failed_model_checks) > 0L) {
  failed_model_labels <- unique(paste(
    failed_model_checks$run_id,
    failed_model_checks$metric_id,
    sep = "::"
  ))
  h01_abort(
    paste0(
      "H01 bootstrap cannot proceed after a failed model check: %s. ",
      "Inspect the model diagnostics before interpreting its uncertainty."
    ),
    paste(failed_model_labels, collapse = ", ")
  )
}
point_results <- readr::read_csv(point_path, show_col_types = FALSE)
bootstrap_summaries <- list()
bootstrap_audits <- list()
bootstrap_failures <- list()
summary_index <- 1L
audit_index <- 1L
failure_index <- 1L
for (run_index in seq_len(nrow(run_registry))) {
  run <- run_registry[run_index, , drop = FALSE]
  message("Bootstrapping H01 run: ", run$run_id)
  for (metric_index in seq_len(nrow(metric_registry))) {
    spec <- metric_registry[metric_index, , drop = FALSE]
    run_path <- file.path(
      run$data_scenario_id,
      run$placement,
      run$sample_scenario
    )
    model_directory <- file.path(model_root, run_path)
    bundle_path <- file.path(
      model_directory,
      paste0(spec$metric_id, "_models.rds")
    )
    frame_path <- file.path(
      model_directory,
      paste0(spec$metric_id, "_model_frame.rds")
    )
    if (!file.exists(bundle_path) || !file.exists(frame_path)) {
      h01_abort(
        "H01 bootstrap input is missing for `%s` in `%s`",
        spec$metric_id,
        run$run_id
      )
    }
    bundle <- readRDS(bundle_path)
    frame <- readRDS(frame_path)
    if (
      identical(bundle$status, "NON_ESTIMABLE") ||
        nrow(frame) == 0L
    ) {
      next
    }
    message("  ", spec$metric_order, "/17 ", spec$metric_id)
    seed <- h01_primary_seed(
      spec$metric_order,
      run$data_scenario_id,
      run$placement,
      run$sample_scenario
    )
    bootstrap <- h01_bootstrap_r2(
      bundle,
      frame,
      seed = seed,
      successful_refits = bootstrap_refits,
      cores = bootstrap_cores
    )
    draws_path <- file.path(
      model_directory,
      paste0(spec$metric_id, "_r2_bootstrap_draws.rds")
    )
    h01_write_rds(bootstrap$draws, draws_path)
    point <- point_results |>
      dplyr::filter(
        run_id == run$run_id,
        metric_id == spec$metric_id
      )
    summary <- h01_summarize_bootstrap(point, bootstrap$draws)
    bootstrap_summaries[[summary_index]] <- h01_add_identity(
      summary,
      run,
      spec
    )
    summary_index <- summary_index + 1L
    bootstrap_audits[[audit_index]] <- h01_add_identity(
      bootstrap$audit,
      run,
      spec
    )
    audit_index <- audit_index + 1L
    if (nrow(bootstrap$failures) > 0L) {
      bootstrap_failures[[failure_index]] <- h01_add_identity(
        bootstrap$failures,
        run,
        spec
      )
      failure_index <- failure_index + 1L
    }
  }
}
Bootstrapping H01 run: alternative_preprocessing__chest__all_available
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Bootstrapping H01 run: alternative_preprocessing__chest__paired_common_sample
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Bootstrapping H01 run: alternative_preprocessing__glasses__all_available
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Bootstrapping H01 run: alternative_preprocessing__glasses__paired_common_sample
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Bootstrapping H01 run: main__chest__all_available
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Bootstrapping H01 run: main__chest__paired_common_sample
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Bootstrapping H01 run: main__glasses__all_available
  1/17 interdaily_stability
  2/17 intradaily_variability
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
Bootstrapping H01 run: main__glasses__paired_common_sample
  3/17 daily_geometric_mean_medi
  4/17 m10_mean_medi
  5/17 l10_mean_medi
  6/17 duration_above_1000
  7/17 duration_above_250_wake
  8/17 duration_below_10_pre_sleep
  9/17 duration_below_1_sleep_environment
  10/17 longest_bout_above_250
  11/17 m10_midpoint
  12/17 l10_midpoint
  13/17 mean_timing_above_250
  14/17 first_timing_above_250
  15/17 last_timing_above_250
  16/17 dose_time_sensitive_corrected_medi
  17/17 mder_mean_of_viable_ratios
h01_write_csv(
  dplyr::bind_rows(bootstrap_summaries),
  file.path(table_root, "H01_r2_bootstrap_summaries.csv")
)
h01_write_csv(
  dplyr::bind_rows(bootstrap_audits),
  file.path(diagnostic_root, "H01_r2_bootstrap_audit.csv")
)
h01_write_csv(
  dplyr::bind_rows(bootstrap_failures),
  file.path(diagnostic_root, "H01_r2_bootstrap_failures.csv")
)
bind_rows(bootstrap_audits) |> count(used_refits, status) |> gt()
used_refits status n
1000 PASS 128

Assemble the reader summaries

Load the estimates produced above and confirm that the intended model and multiplicity families are complete.

library(ggplot2)
library(ggridges)
library(stringr)
source("scripts/hypotheses/H01/h01_reporting_helpers.R")
primary_run <- "main__glasses__all_available"

chest_run <- "main__chest__all_available"

selected_runs <- c(primary_run, chest_run)

run_registry <- tibble::tribble(
  ~run_id, ~run_order, ~run_label, ~data_label, ~placement_label, ~sample_label,
  "main__glasses__all_available", 1L,
  "Main near-eye", "Main", "Near eye", "All available",
  "main__chest__all_available", 2L,
  "Main chest", "Main", "Chest", "All available",
  "main__glasses__paired_common_sample", 3L,
  "Main near-eye, paired/common sample", "Main", "Near eye", "Paired/common",
  "main__chest__paired_common_sample", 4L,
  "Main chest, paired/common sample", "Main", "Chest", "Paired/common",
  "alternative_preprocessing__glasses__all_available", 5L,
  "Alternative preprocessing near-eye", "Alternative preprocessing", "Near eye", "All available",
  "alternative_preprocessing__chest__all_available", 6L,
  "Alternative preprocessing chest", "Alternative preprocessing", "Chest", "All available",
  "alternative_preprocessing__glasses__paired_common_sample", 7L,
  "Alternative preprocessing near-eye, paired/common sample",
  "Alternative preprocessing", "Near eye", "Paired/common",
  "alternative_preprocessing__chest__paired_common_sample", 8L,
  "Alternative preprocessing chest, paired/common sample",
  "Alternative preprocessing", "Chest", "Paired/common"
)

question_registry <- tibble::tribble(
  ~family_id, ~question_order, ~question_id, ~question_label,
  "H01-F1-site", 1L, "site", "Overall site",
  "H01-F2-photoperiod", 2L, "photoperiod", "Photoperiod",
  "H01-F3-latitude", 3L, "latitude", "Latitude",
  "H01-F4-site-latitude-adequacy", 4L, "adequacy",
  "Site versus linear latitude"
)

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

metric_contract <- read_required(
  "results/intermediate/model_data/H01/metric_contract.csv"
) |>
  select(
    "metric_order", "metric_id", "manuscript_name", "abbreviation",
    "manuscript_category", "display_unit", "variant_label"
  )

metric_registry <- h01_metric_registry() |>
  left_join(
    metric_contract,
    by = c("metric_order", "metric_id"),
    relationship = "one-to-one"
  ) |>
  mutate(
    family_label = format_family(
      .data$response_family,
      .data$response_transform
    ),
    analysis_unit_label = if_else(
      .data$analysis_unit == "participant",
      "Participant",
      "Participant-day"
    )
  )

if (nrow(metric_registry) != 17L || anyNA(metric_registry$manuscript_name)) {
  stop("The H01 reporting metric registry is incomplete", call. = FALSE)
}

tests <- read_required("results/tables/H01/H01_model_level_tests.csv")

term_effects <- read_required("results/tables/H01/H01_term_effects.csv")

site_deviations <- read_required("results/tables/H01/H01_site_deviations.csv")

samples <- read_required("results/tables/H01/H01_exact_samples.csv")

samples_by_site <- read_required(
  "results/tables/H01/H01_exact_samples_by_site.csv"
)

r2 <- read_required("results/tables/H01/H01_r2_bootstrap_summaries.csv")

diagnostics <- read_required(
  "results/csv/diagnostics/H01/H01_model_diagnostics.csv"
)

bootstrap_audit <- read_required(
  "results/csv/diagnostics/H01/H01_r2_bootstrap_audit.csv"
)

model_specifications <- read_required(
  "results/tables/H01/H01_model_specifications.csv"
)

noon_tests <- read_required(
  "results/tables/H01/H01_l10_noon_conversion_model_tests.csv"
)

noon_effects <- read_required(
  "results/tables/H01/H01_l10_noon_conversion_term_effects.csv"
)

noon_samples <- read_required(
  "results/tables/H01/H01_l10_noon_conversion_samples.csv"
)

period_sensitivity <- read_required(
  "results/tables/H01/H01_exactly_identified_period_sensitivity.csv"
)

scope_sensitivity <- read_required(
  "results/tables/H01/H01_preregistered_scope_sensitivity.csv"
)

participant_influence <- read_required(
  "results/csv/diagnostics/H01/H01_participant_influence.csv"
)

latitude_loo <- read_required(
  "results/csv/diagnostics/H01/H01_latitude_leave_one_site_out.csv"
)

marginalization <- read_required(
  "results/tables/H01/H01_marginalization_comparison.csv"
)

descriptive_metric_summary <- read_required(
  "results/tables/descriptives/metric_descriptive_summary_display.csv"
)

descriptive_metric_values <- read_required(
  "results/csv/source_data/descriptives/metric_plot_values.csv"
)

expected_bootstrap_keys <- read_required(
  "results/tables/H01/H01_r2_point_summaries.csv"
) |>
  filter(.data$status == "PASS") |>
  distinct(.data$run_id, .data$metric_id)
bootstrap_keys <- bootstrap_audit |>
  distinct(.data$run_id, .data$metric_id)
if (nrow(anti_join(expected_bootstrap_keys, bootstrap_keys,
                  by = c("run_id", "metric_id"))) > 0L) {
  stop("Bootstrap summaries are missing for estimable fitted models", call. = FALSE)
}

if (
  nrow(bootstrap_audit) != nrow(expected_bootstrap_keys) ||
    any(bootstrap_audit$status != "PASS") ||
    any(bootstrap_audit$used_refits < bootstrap_count(1000L))
) {
  stop(
    "Stored H01 bootstrap outputs do not pass the reporting completeness check",
    call. = FALSE
  )
}

if (
  any(tests$family_n != 17L) ||
    any(tests$family_status != "COMPLETE") ||
    nrow(tests) != 8L * 17L * 4L
) {
  stop("The stored H01 multiplicity families are incomplete", call. = FALSE)
}


metric_registry |> select(metric_id, family_label, analysis_unit_label) |> gt()
metric_id family_label analysis_unit_label
interdaily_stability Gaussian after logit transformation Participant
intradaily_variability Gaussian on the identity scale Participant
daily_geometric_mean_medi Gaussian after log10(value + 0.1) Participant-day
m10_mean_medi Gaussian after log10(value + 0.1) Participant-day
l10_mean_medi Gaussian after log10(value + 0.1) Participant-day
duration_above_1000 Tweedie with log link Participant-day
duration_above_250_wake Tweedie with log link Participant-day
duration_below_10_pre_sleep Gaussian on the identity scale Participant-day
duration_below_1_sleep_environment Tweedie with log link Participant-day
longest_bout_above_250 Gaussian after log10(value + 0.1) Participant-day
m10_midpoint Gaussian on the linear clock scale Participant-day
l10_midpoint Gaussian on the linear clock scale (strict after-16:00 cut) Participant-day
mean_timing_above_250 Gaussian on the linear clock scale Participant-day
first_timing_above_250 Gaussian on the linear clock scale Participant-day
last_timing_above_250 Gaussian on the linear clock scale Participant-day
dose_time_sensitive_corrected_medi Gaussian after log10(value + 0.1) Participant-day
mder_mean_of_viable_ratios Gaussian on the identity scale Participant-day

Combine estimates and model diagnostics

Join estimates, exact samples, and diagnostic assessments for the primary and complementary models.

test_wide <- tests |>
  filter(.data$run_id %in% selected_runs) |>
  left_join(question_registry, by = "family_id", relationship = "many-to-one") |>
  select(
    "run_id", "metric_id", "question_id", "statistic", "df", "p_raw",
    "p_adjusted", "comparison_status", "family_status"
  ) |>
  pivot_wider(
    names_from = "question_id",
    values_from = c(
      "statistic", "df", "p_raw", "p_adjusted",
      "comparison_status", "family_status"
    ),
    names_glue = "{question_id}_{.value}"
  )

effect_wide <- term_effects |>
  filter(
    .data$run_id %in% selected_runs,
    .data$term %in% c(
      "photoperiod_centered_hours",
      "absolute_latitude_10deg_centered"
    )
  ) |>
  mutate(
    effect_id = if_else(
      .data$term == "photoperiod_centered_hours",
      "photoperiod",
      "latitude"
    )
  ) |>
  select(
    "run_id", "metric_id", "effect_id", "effect_type",
    "estimate_practical", "conf_low_practical", "conf_high_practical",
    "p_raw", "interval_method", "status"
  ) |>
  pivot_wider(
    names_from = "effect_id",
    values_from = c(
      "effect_type", "estimate_practical", "conf_low_practical",
      "conf_high_practical", "p_raw", "interval_method", "status"
    ),
    names_glue = "{effect_id}_{.value}"
  )

diagnostic_assessment <- diagnostics |>
  filter(.data$run_id %in% selected_runs) |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  mutate(
    assessment = case_when(
      .data$diagnostic_status == "PASS" ~ "Acceptable",
      .data$diagnostic_status == "WARN_REVIEW" ~
        "Acceptable with limitations",
      .data$diagnostic_status == "NON_ESTIMABLE" ~ "Not fitted",
      TRUE ~ "Not acceptable"
    ),
    residual_assessment = case_when(
      .data$residual_status == "PASS" ~ "No flagged residual issue",
      .data$residual_status == "WARN_GAUSSIAN_DIAGNOSTIC" ~
        "Gaussian residual-shape warning",
      .data$residual_status == "WARN_STRONG_GAUSSIAN_MISFIT" ~
        "Strong Gaussian residual-shape warning",
      .data$residual_status == "WARN_TWEEDIE_DIAGNOSTIC" ~
        "Tweedie simulation-diagnostic warning",
      .data$residual_status == "WARN_STRONG_TWEEDIE_MISFIT" ~
        "Strong Tweedie simulation-diagnostic warning",
      TRUE ~ "Not available"
    ),
    bound_assessment = case_when(
      .data$prediction_bound_status == "PASS" ~ "Prediction bounds passed",
      .data$prediction_bound_status == "WARN_PREDICTED_BOUND" ~
        "Predicted values crossed a physical bound",
      .data$prediction_bound_status == "UPPER_BOUND_UNAVAILABLE" ~
        "No verified upper bound available",
      TRUE ~ "Not available"
    )
  ) |>
  arrange(.data$run_order, .data$metric_order)

diagnostic_details <- diagnostic_assessment |>
  transmute(
    .data$run_id,
    .data$run_order,
    .data$run_label,
    .data$placement_label,
    .data$metric_order,
    .data$metric_id,
    .data$manuscript_category,
    .data$manuscript_name,
    response_family = .data$response_family.x,
    response_transform = .data$response_transform.x,
    .data$assessment,
    .data$residual_assessment,
    .data$bound_assessment,
    .data$residual_status,
    .data$diagnostic_status,
    .data$shapiro_p,
    .data$residual_variance_ratio,
    .data$standardized_residual_over_3_fraction,
    .data$standardized_residual_over_4_fraction,
    .data$dharma_uniformity_p,
    .data$dharma_dispersion_p,
    .data$dharma_zero_inflation_p,
    .data$dharma_outlier_p,
    .data$observed_zero_fraction,
    .data$simulated_zero_fraction,
    .data$zero_fraction_ratio,
    .data$prediction_bound_status,
    .data$predicted_below_bound_n,
    .data$predicted_above_bound_n
  ) |>
  arrange(.data$run_order, .data$metric_order)

model_results <- samples |>
  filter(.data$run_id %in% selected_runs) |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  left_join(test_wide, by = c("run_id", "metric_id"), relationship = "one-to-one") |>
  left_join(effect_wide, by = c("run_id", "metric_id"), relationship = "one-to-one") |>
  left_join(
    diagnostic_assessment |>
      select("run_id", "metric_id", "assessment", "residual_assessment", "bound_assessment"),
    by = c("run_id", "metric_id"),
    relationship = "one-to-one"
  ) |>
  arrange(.data$run_order, .data$metric_order)

if (nrow(model_results) != 34L) {
  stop("H01 reporting requires 17 main near-eye and 17 main chest rows", call. = FALSE)
}

primary_publication_summary <- model_results |>
  filter(.data$run_id == primary_run) |>
  transmute(
    .data$metric_order,
    .data$metric_id,
    .data$manuscript_category,
    .data$manuscript_name,
    .data$display_unit,
    .data$site_p_adjusted,
    .data$photoperiod_effect_type,
    .data$photoperiod_estimate_practical,
    .data$photoperiod_conf_low_practical,
    .data$photoperiod_conf_high_practical,
    .data$photoperiod_p_adjusted,
    .data$latitude_effect_type,
    .data$latitude_estimate_practical,
    .data$latitude_conf_low_practical,
    .data$latitude_conf_high_practical,
    .data$latitude_p_adjusted,
    .data$adequacy_p_adjusted,
    .data$participants,
    .data$participant_days,
    .data$observations,
    .data$sites
  ) |>
  arrange(.data$metric_order)


primary_publication_summary |> head()
# A tibble: 6 × 21
  metric_order metric_id        manuscript_category manuscript_name display_unit
         <dbl> <chr>            <chr>               <chr>           <chr>       
1            1 interdaily_stab… dynamics-based      Interdaily sta… dimensionle…
2            2 intradaily_vari… dynamics-based      Intradaily var… dimensionle…
3            3 daily_geometric… level-based         Mean melEDI     lx          
4            4 m10_mean_medi    level-based         Brightest 10 h… lx          
5            5 l10_mean_medi    level-based         Darkest 10 h m… lx          
6            6 duration_above_… duration-based      Time above 1,0… h           
# ℹ 16 more variables: site_p_adjusted <dbl>, photoperiod_effect_type <chr>,
#   photoperiod_estimate_practical <dbl>, photoperiod_conf_low_practical <dbl>,
#   photoperiod_conf_high_practical <dbl>, photoperiod_p_adjusted <dbl>,
#   latitude_effect_type <chr>, latitude_estimate_practical <dbl>,
#   latitude_conf_low_practical <dbl>, latitude_conf_high_practical <dbl>,
#   latitude_p_adjusted <dbl>, adequacy_p_adjusted <dbl>, participants <dbl>,
#   participant_days <dbl>, observations <dbl>, sites <dbl>

Summarise site contrasts and represented variation

Report hierarchical site contrasts and bootstrap intervals for the model components. The tables keep unestimable quantities explicit.

site_sample_support <- samples_by_site |>
  filter(.data$run_id %in% selected_runs) |>
  transmute(
    .data$run_id,
    .data$metric_order,
    .data$metric_id,
    .data$site,
    site_participants = as.integer(.data$participants),
    site_participant_days = as.integer(.data$participant_days),
    site_observations = as.integer(.data$observations)
  )

if (
  anyDuplicated(
    site_sample_support[c("run_id", "metric_order", "metric_id", "site")]
  ) ||
    any(site_sample_support$site_observations <= 0L)
) {
  stop("The stored H01 per-site fitted samples are invalid", call. = FALSE)
}

site_contrasts <- site_deviations |>
  filter(
    .data$run_id %in% selected_runs,
    .data$inferential_followup_supported
  ) |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  left_join(site_registry, by = "site", relationship = "many-to-one") |>
  left_join(
    site_sample_support,
    by = c("run_id", "metric_order", "metric_id", "site"),
    relationship = "one-to-one"
  ) |>
  mutate(
    null_value = if_else(.data$effect_type == "ratio", 1, 0),
    supported_within_metric = .data$p_adjusted_within_metric < 0.05,
    support_display = if_else(
      .data$supported_within_metric,
      "Adjusted p < 0.050",
      "Adjusted p ≥ 0.050"
    ),
    scale_group = if_else(.data$effect_type == "ratio", "Ratios", "Differences"),
    figure_panel_tag = if_else(.data$scale_group == "Ratios", "A", "B"),
    figure_panel_label = paste0(.data$figure_panel_tag, ". ", .data$scale_group),
    metric_facet_label = .data$manuscript_name,
    site_panel_key = paste(.data$metric_id, .data$site, sep = "__"),
    site_axis_label = paste0(
      sub("\\)$", "", .data$display_name),
      ", n=", .data$site_observations, ")"
    )
  ) |>
  group_by(.data$run_id, .data$metric_id) |>
  mutate(
    display_half_range = 1.08 * max(
      abs(c(
        .data$conf_low_practical - first(.data$null_value),
        .data$conf_high_practical - first(.data$null_value)
      )),
      na.rm = TRUE
    ),
    display_half_range = pmax(
      .data$display_half_range,
      if_else(first(.data$null_value) == 1, 0.05, 0.10)
    ),
    display_x_min = .data$null_value - .data$display_half_range,
    display_x_max = .data$null_value + .data$display_half_range
  ) |>
  ungroup() |>
  arrange(.data$run_order, .data$metric_order, .data$display_order)

if (
  anyNA(site_contrasts[c(
    "site_participants", "site_participant_days", "site_observations",
    "scale_group", "figure_panel_tag", "figure_panel_label",
    "metric_facet_label", "display_x_min", "display_x_max"
  )]) ||
    any(site_contrasts$display_half_range <= 0)
) {
  stop("The H01 site-contrast display metadata are incomplete", call. = FALSE)
}

question_support <- tests |>
  select("run_id", "metric_id", "family_id", "p_adjusted") |>
  left_join(question_registry, by = "family_id", relationship = "many-to-one") |>
  mutate(supported = !is.na(.data$p_adjusted) & .data$p_adjusted < 0.05) |>
  select("run_id", "metric_id", "question_id", "supported") |>
  pivot_wider(
    names_from = "question_id",
    values_from = "supported",
    names_glue = "{question_id}_supported"
  )

r2_reporting <- r2 |>
  filter(
    .data$run_id %in% selected_runs,
    .data$approximation == "lognormal"
  ) |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  left_join(
    question_support,
    by = c("run_id", "metric_id"),
    relationship = "many-to-one"
  ) |>
  mutate(
    term_supported = case_when(
      .data$measure == "site_part_r2" ~ .data$site_supported,
      .data$measure == "photoperiod_part_r2" ~ .data$photoperiod_supported,
      .data$measure == "latitude_part_r2" ~ .data$latitude_supported,
      TRUE ~ NA
    )
  ) |>
  arrange(.data$run_order, .data$metric_order, .data$measure)

r2_table_measures <- c(
  "conditional_r2", "marginal_r2", "participant_associated_share",
  "unrepresented_share", "site_part_r2", "photoperiod_part_r2",
  "latitude_part_r2"
)

r2_term_measures <- c(
  "site_part_r2", "photoperiod_part_r2", "latitude_part_r2"
)

r2_table_metrics <- r2_reporting |>
  filter(.data$measure %in% r2_table_measures) |>
  transmute(
    .data$run_id,
    .data$run_order,
    .data$run_label,
    .data$placement_label,
    row_type = "Metric",
    .data$metric_order,
    .data$metric_id,
    .data$manuscript_category,
    .data$manuscript_name,
    .data$measure,
    .data$estimate,
    .data$conf_low,
    .data$conf_high,
    .data$bootstrap_successful_used,
    .data$term_supported,
    supported_n = if_else(
      .data$measure %in% r2_term_measures & .data$term_supported %in% TRUE,
      1L,
      if_else(.data$measure %in% r2_term_measures, 0L, NA_integer_)
    ),
    unsupported_n = if_else(
      .data$measure %in% r2_term_measures & .data$term_supported %in% FALSE,
      1L,
      if_else(.data$measure %in% r2_term_measures, 0L, NA_integer_)
    )
  )

r2_table_grand <- r2_table_metrics |>
  mutate(is_term_measure = .data$measure %in% r2_term_measures) |>
  group_by(
    .data$run_id, .data$run_order, .data$run_label, .data$placement_label,
    .data$measure, .data$is_term_measure
  ) |>
  summarise(
    row_type = "Grand average",
    metric_order = 0L,
    metric_id = "grand_average",
    manuscript_category = "Grand average",
    manuscript_name = "Grand average",
    estimate = if_else(
      dplyr::first(.data$is_term_measure),
      mean(.data$estimate[.data$term_supported %in% TRUE], na.rm = TRUE),
      mean(.data$estimate, na.rm = TRUE)
    ),
    conf_low = NA_real_,
    conf_high = NA_real_,
    bootstrap_successful_used = NA_integer_,
    supported_n = if_else(
      dplyr::first(.data$is_term_measure),
      sum(.data$term_supported %in% TRUE),
      NA_integer_
    ),
    unsupported_n = if_else(
      dplyr::first(.data$is_term_measure),
      sum(.data$term_supported %in% FALSE),
      NA_integer_
    ),
    term_supported = NA,
    .groups = "drop"
  ) |>
  select(-"is_term_measure")

r2_table <- bind_rows(r2_table_grand, r2_table_metrics) |>
  arrange(.data$run_order, .data$metric_order, .data$measure)


r2_table |> filter(row_type == "Grand average") |> gt()
run_id run_order run_label placement_label measure row_type metric_order metric_id manuscript_category manuscript_name estimate conf_low conf_high bootstrap_successful_used supported_n unsupported_n term_supported
main__glasses__all_available 1 Main near-eye Near eye conditional_r2 Grand average 0 grand_average Grand average Grand average 0.37752606 NA NA NA NA NA NA
main__glasses__all_available 1 Main near-eye Near eye latitude_part_r2 Grand average 0 grand_average Grand average Grand average 0.03816220 NA NA NA 7 10 NA
main__glasses__all_available 1 Main near-eye Near eye marginal_r2 Grand average 0 grand_average Grand average Grand average 0.14693681 NA NA NA NA NA NA
main__glasses__all_available 1 Main near-eye Near eye participant_associated_share Grand average 0 grand_average Grand average Grand average 0.26133448 NA NA NA NA NA NA
main__glasses__all_available 1 Main near-eye Near eye photoperiod_part_r2 Grand average 0 grand_average Grand average Grand average 0.05856216 NA NA NA 12 5 NA
main__glasses__all_available 1 Main near-eye Near eye site_part_r2 Grand average 0 grand_average Grand average Grand average 0.08668528 NA NA NA 10 7 NA
main__glasses__all_available 1 Main near-eye Near eye unrepresented_share Grand average 0 grand_average Grand average Grand average 0.62247394 NA NA NA NA NA NA
main__chest__all_available 2 Main chest Chest conditional_r2 Grand average 0 grand_average Grand average Grand average 0.35351385 NA NA NA NA NA NA
main__chest__all_available 2 Main chest Chest latitude_part_r2 Grand average 0 grand_average Grand average Grand average 0.06136357 NA NA NA 9 8 NA
main__chest__all_available 2 Main chest Chest marginal_r2 Grand average 0 grand_average Grand average Grand average 0.12111625 NA NA NA NA NA NA
main__chest__all_available 2 Main chest Chest participant_associated_share Grand average 0 grand_average Grand average Grand average 0.26338395 NA NA NA NA NA NA
main__chest__all_available 2 Main chest Chest photoperiod_part_r2 Grand average 0 grand_average Grand average Grand average 0.03336278 NA NA NA 11 6 NA
main__chest__all_available 2 Main chest Chest site_part_r2 Grand average 0 grand_average Grand average Grand average 0.09679802 NA NA NA 13 4 NA
main__chest__all_available 2 Main chest Chest unrepresented_share Grand average 0 grand_average Grand average Grand average 0.64648615 NA NA NA NA NA NA

Combine descriptive and model summaries

Pair each metric’s observed distribution with its model estimates and exact inferential sample.

synthesis_r2_measures <- c(
  "marginal_r2", "conditional_r2", "participant_associated_share",
  "site_part_r2", "photoperiod_part_r2", "latitude_part_r2"
)

synthesis_r2 <- r2_reporting |>
  filter(
    .data$run_id == primary_run,
    .data$measure %in% synthesis_r2_measures
  ) |>
  select(
    "metric_id", "measure", "estimate", "conf_low", "conf_high",
    "bootstrap_successful_used", "term_supported"
  ) |>
  pivot_wider(
    names_from = "measure",
    values_from = c(
      "estimate", "conf_low", "conf_high", "bootstrap_successful_used",
      "term_supported"
    ),
    names_glue = "{.value}_{measure}"
  )

descriptive_overall <- descriptive_metric_summary |>
  filter(.data$placement == "near_eye", .data$site == "Overall") |>
  transmute(
    .data$metric_id,
    descriptive_metric_order = as.integer(.data$metric_order),
    descriptive_name = .data$manuscript_name,
    metric_description = .data$meaning_and_relevance,
    descriptive_analysis_unit = .data$analysis_unit,
    descriptive_unit = .data$unit,
    descriptive_scaling = .data$scaling,
    descriptive_median = .data$median,
    descriptive_q1 = .data$q1,
    descriptive_q3 = .data$q3,
    descriptive_median_display = .data$median_formatted,
    descriptive_q1_display = .data$q1_formatted,
    descriptive_q3_display = .data$q3_formatted,
    descriptive_participants = as.integer(.data$n_participants),
    descriptive_participant_days = as.integer(.data$n_participant_days),
    descriptive_observations = as.integer(.data$n_observations)
  )

primary_metric_synthesis <- primary_publication_summary |>
  left_join(
    descriptive_overall,
    by = "metric_id",
    relationship = "one-to-one"
  ) |>
  left_join(synthesis_r2, by = "metric_id", relationship = "one-to-one") |>
  mutate(
    density_artifact_path = paste0(
      "results/images/H01/reporting/metric_density/",
      "H01_metric_density_", .data$metric_id, ".png"
    ),
    site_supported = !is.na(.data$site_p_adjusted) &
      .data$site_p_adjusted < 0.05,
    photoperiod_supported = !is.na(.data$photoperiod_p_adjusted) &
      .data$photoperiod_p_adjusted < 0.05,
    latitude_supported = !is.na(.data$latitude_p_adjusted) &
      .data$latitude_p_adjusted < 0.05
  ) |>
  arrange(.data$metric_order)

if (
  nrow(descriptive_overall) != 17L ||
    nrow(primary_metric_synthesis) != 17L ||
    anyDuplicated(primary_metric_synthesis$metric_id) ||
    !setequal(primary_metric_synthesis$metric_id, metric_registry$metric_id) ||
    anyNA(primary_metric_synthesis[c(
      "metric_description", "descriptive_analysis_unit", "descriptive_unit",
      "descriptive_scaling",
      "descriptive_median", "descriptive_q1", "descriptive_q3",
      "descriptive_median_display", "descriptive_q1_display",
      "descriptive_q3_display", "descriptive_participants",
      "descriptive_participant_days", "descriptive_observations"
    )]) ||
    any(primary_metric_synthesis$sites != 9L) ||
    any(
      primary_metric_synthesis$bootstrap_successful_used_marginal_r2 < bootstrap_count(1000L) |
        primary_metric_synthesis$bootstrap_successful_used_conditional_r2 < bootstrap_count(1000L) |
        primary_metric_synthesis$bootstrap_successful_used_site_part_r2 < bootstrap_count(1000L) |
        primary_metric_synthesis$bootstrap_successful_used_photoperiod_part_r2 < bootstrap_count(1000L) |
        primary_metric_synthesis$bootstrap_successful_used_latitude_part_r2 < bootstrap_count(1000L)
    ) ||
    any(
      primary_metric_synthesis$site_supported !=
        primary_metric_synthesis$term_supported_site_part_r2 |
        primary_metric_synthesis$photoperiod_supported !=
          primary_metric_synthesis$term_supported_photoperiod_part_r2 |
        primary_metric_synthesis$latitude_supported !=
          primary_metric_synthesis$term_supported_latitude_part_r2
    )
) {
  stop("The H01 primary metric synthesis failed its source contract", call. = FALSE)
}


primary_metric_synthesis |> select(metric_id, participants, participant_days) |> head()
# A tibble: 6 × 3
  metric_id                 participants participant_days
  <chr>                            <dbl>            <dbl>
1 interdaily_stability               141              816
2 intradaily_variability             141              816
3 daily_geometric_mean_medi          141              816
4 m10_mean_medi                      141              816
5 l10_mean_medi                      141              816
6 duration_above_1000                141              816

Summarise sensitivity and influence analyses

Compare the preprocessing and sample variants, influential participants, latitude leverage, period identification, and clock-time conversions.

exact_samples <- samples |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  arrange(.data$run_order, .data$metric_order)

exact_samples_by_site <- samples_by_site |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  left_join(site_registry, by = "site", relationship = "many-to-one") |>
  arrange(.data$run_order, .data$metric_order, .data$display_order)

formula_specification <- model_specifications |>
  distinct(
    .data$analysis_unit,
    .data$model_name,
    .data$estimation_stage,
    .data$formula,
    .data$engine,
    .data$estimation_method,
    .data$family,
    .data$link
  ) |>
  arrange(.data$analysis_unit, .data$model_name, .data$estimation_stage, .data$engine)

support_summary <- tests |>
  left_join(question_registry, by = "family_id", relationship = "many-to-one") |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  group_by(
    .data$run_id, .data$run_order, .data$run_label, .data$data_label,
    .data$placement_label, .data$sample_label, .data$question_order,
    .data$family_id, .data$question_label
  ) |>
  summarise(
    planned_tests = dplyr::n(),
    supported_metrics = sum(.data$p_adjusted < 0.05, na.rm = TRUE),
    nonestimable_metrics = sum(is.na(.data$p_adjusted)),
    family_status = paste(unique(.data$family_status), collapse = "; "),
    .groups = "drop"
  ) |>
  arrange(.data$run_order, .data$question_order)

primary_support_reference <- tests |>
  filter(.data$run_id == primary_run) |>
  transmute(
    .data$metric_id,
    .data$family_id,
    primary_supported = !is.na(.data$p_adjusted) & .data$p_adjusted < 0.05
  )

sensitivity_classification <- tests |>
  filter(.data$run_id != primary_run) |>
  left_join(
    primary_support_reference,
    by = c("metric_id", "family_id"),
    relationship = "many-to-one"
  ) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  mutate(
    target_supported = case_when(
      is.na(.data$p_adjusted) ~ NA,
      .data$p_adjusted < 0.05 ~ TRUE,
      TRUE ~ FALSE
    )
  ) |>
  group_by(
    .data$run_id, .data$run_order, .data$run_label, .data$data_label,
    .data$placement_label, .data$sample_label
  ) |>
  summarise(
    evaluated_cells = dplyr::n(),
    nonestimable_cells = sum(is.na(.data$target_supported)),
    support_switches = sum(
      !is.na(.data$target_supported) &
        .data$target_supported != .data$primary_supported
    ),
    classification = case_when(
      .data$support_switches == 0L & .data$nonestimable_cells == 0L ~ "Stable",
      .data$support_switches == 0L ~ "Inconclusive",
      TRUE ~ "Qualitatively sensitive"
    ),
    .groups = "drop"
  ) |>
  arrange(.data$run_order)

support_matrix <- tests |>
  filter(.data$run_id %in% selected_runs) |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  left_join(question_registry, by = "family_id", relationship = "many-to-one") |>
  mutate(
    support_status = case_when(
      is.na(.data$p_adjusted) ~ "Not estimable",
      .data$p_adjusted < 0.05 ~ "Supported",
      TRUE ~ "Not supported"
    ),
    support_symbol = case_when(
      .data$support_status == "Supported" ~ "✓",
      .data$support_status == "Not estimable" ~ "?",
      TRUE ~ "–"
    )
  ) |>
  arrange(.data$run_order, .data$metric_order, .data$question_order)

diagnostic_matrix <- diagnostic_assessment |>
  transmute(
    .data$run_id,
    .data$run_order,
    .data$placement_label,
    .data$metric_order,
    .data$manuscript_name,
    `Convergence and Hessian` = case_when(
      coalesce(.data$converged, FALSE) &
        coalesce(.data$positive_definite_hessian, FALSE) ~ "Pass",
      TRUE ~ "Fail"
    ),
    `Random-effect singularity` = case_when(
      is.na(.data$singular) ~ "Not applicable",
      !.data$singular ~ "Pass",
      TRUE ~ "Review"
    ),
    `Residual distribution` = case_when(
      .data$residual_status == "PASS" ~ "Pass",
      is.na(.data$residual_status) ~ "Not applicable",
      TRUE ~ "Review"
    ),
    `Prediction bounds` = case_when(
      .data$prediction_bound_status == "PASS" ~ "Pass",
      .data$prediction_bound_status == "UPPER_BOUND_UNAVAILABLE" ~
        "Not applicable",
      is.na(.data$prediction_bound_status) ~ "Not applicable",
      TRUE ~ "Review"
    ),
    `Construct check` = case_when(
      .data$audit_threshold_status == "PASS" ~ "Pass",
      .data$audit_threshold_status == "NOT_APPLICABLE" ~ "Not applicable",
      is.na(.data$audit_threshold_status) ~ "Not applicable",
      TRUE ~ "Review"
    ),
    `Clock-time cut` = case_when(
      .data$timing_status == "PASS" ~ "Pass",
      .data$timing_status == "NOT_APPLICABLE" ~ "Not applicable",
      is.na(.data$timing_status) ~ "Not applicable",
      TRUE ~ "Review"
    )
  ) |>
  pivot_longer(
    cols = c(
      "Convergence and Hessian", "Random-effect singularity",
      "Residual distribution", "Prediction bounds", "Construct check",
      "Clock-time cut"
    ),
    names_to = "diagnostic_check",
    values_to = "check_status"
  ) |>
  mutate(
    check_order = match(
      .data$diagnostic_check,
      c(
        "Convergence and Hessian", "Random-effect singularity",
        "Residual distribution", "Prediction bounds", "Construct check",
        "Clock-time cut"
      )
    ),
    check_symbol = recode(
      .data$check_status,
      Pass = "P",
      Review = "R",
      Fail = "F",
      `Not applicable` = "n/a"
    )
  )

influence_summary <- participant_influence |>
  filter(.data$run_id %in% selected_runs) |>
  group_by(.data$run_id, .data$metric_order, .data$metric_id) |>
  arrange(desc(.data$maximum_absolute_dfbeta), .by_group = TRUE) |>
  summarise(
    refits = dplyr::n(),
    successful_refits = sum(.data$refit_status == "PASS"),
    maximum_absolute_dfbeta = dplyr::first(.data$maximum_absolute_dfbeta),
    most_influential_participant = dplyr::first(.data$omitted_participant),
    maximum_dfbeta_term = dplyr::first(.data$maximum_dfbeta_term),
    .groups = "drop"
  ) |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  arrange(.data$run_order, .data$metric_order)

latitude_loo_summary <- latitude_loo |>
  filter(.data$run_id %in% selected_runs) |>
  group_by(
    .data$run_id, .data$metric_order, .data$metric_id, .data$effect_type
  ) |>
  summarise(
    omitted_site_refits = dplyr::n(),
    successful_refits = sum(.data$status == "PASS" & is.na(.data$refit_error)),
    minimum_estimate = min(.data$estimate_practical, na.rm = TRUE),
    maximum_estimate = max(.data$estimate_practical, na.rm = TRUE),
    .groups = "drop"
  ) |>
  mutate(
    null_value = if_else(.data$effect_type == "ratio", 1, 0),
    range_crosses_null =
      .data$minimum_estimate <= .data$null_value &
      .data$maximum_estimate >= .data$null_value
  ) |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  arrange(.data$run_order, .data$metric_order)

primary_l10_tests <- tests |>
  filter(
    .data$run_id %in% selected_runs,
    .data$metric_id == "l10_midpoint"
  ) |>
  left_join(question_registry, by = "family_id", relationship = "many-to-one") |>
  transmute(
    .data$run_id,
    variant = "Primary strict-after-16:00 conversion",
    .data$question_order,
    .data$question_label,
    .data$p_raw,
    adjusted_p = .data$p_adjusted,
    multiplicity = "Primary 17-test BH family"
  )

noon_l10_tests <- noon_tests |>
  filter(.data$run_id %in% selected_runs) |>
  mutate(
    question_id = recode(
      .data$comparison_id,
      site_full_vs_no_site = "site",
      site_full_vs_no_photoperiod = "photoperiod",
      latitude_full_vs_no_latitude = "latitude",
      site_full_vs_latitude_full = "adequacy"
    )
  ) |>
  left_join(question_registry, by = "question_id", relationship = "many-to-one") |>
  transmute(
    .data$run_id,
    variant = "Noon-cut sensitivity",
    .data$question_order,
    .data$question_label,
    .data$p_raw,
    adjusted_p = NA_real_,
    multiplicity = "Outside primary multiplicity families"
  )

l10_sensitivity <- bind_rows(primary_l10_tests, noon_l10_tests) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  arrange(.data$run_order, .data$variant, .data$question_order)

period_sensitivity_reporting <- period_sensitivity |>
  filter(.data$run_id %in% selected_runs) |>
  mutate(
    question_id = recode(
      .data$comparison_id,
      site_full_vs_no_site = "site",
      site_full_vs_no_photoperiod = "photoperiod",
      latitude_full_vs_no_latitude = "latitude",
      site_full_vs_latitude_full = "adequacy"
    )
  ) |>
  left_join(question_registry, by = "question_id", relationship = "many-to-one") |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  arrange(.data$run_order, .data$question_order)

scope_sensitivity_reporting <- scope_sensitivity |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(question_registry, by = "family_id", relationship = "many-to-one") |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  arrange(.data$run_order, .data$metric_order, .data$question_order)

marginalization_reporting <- marginalization |>
  filter(.data$run_id %in% selected_runs) |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  left_join(run_registry, by = "run_id", relationship = "many-to-one") |>
  arrange(.data$run_order, .data$metric_order)


sensitivity_classification |> gt()
run_id run_order run_label data_label placement_label sample_label evaluated_cells nonestimable_cells support_switches classification
main__chest__all_available 2 Main chest Main Chest All available 68 0 22 Qualitatively sensitive
main__glasses__paired_common_sample 3 Main near-eye, paired/common sample Main Near eye Paired/common 68 8 11 Qualitatively sensitive
main__chest__paired_common_sample 4 Main chest, paired/common sample Main Chest Paired/common 68 8 15 Qualitatively sensitive
alternative_preprocessing__glasses__all_available 5 Alternative preprocessing near-eye Alternative preprocessing Near eye All available 68 0 7 Qualitatively sensitive
alternative_preprocessing__chest__all_available 6 Alternative preprocessing chest Alternative preprocessing Chest All available 68 0 23 Qualitatively sensitive
alternative_preprocessing__glasses__paired_common_sample 7 Alternative preprocessing near-eye, paired/common sample Alternative preprocessing Near eye Paired/common 68 8 11 Qualitatively sensitive
alternative_preprocessing__chest__paired_common_sample 8 Alternative preprocessing chest, paired/common sample Alternative preprocessing Chest Paired/common 68 8 14 Qualitatively sensitive

Describe departures from preregistration

Record the scientific differences between the preregistered or expected approach and the analysis performed here.

deviations <- tibble::tribble(
  ~topic, ~registered_or_expected, ~analysis_used,
  "Placement",
  "Chest measurements were primary and near-eye measurements a robustness repeat.",
  "Near-eye measurements are primary; chest measurements are complementary and placements are not pooled.",
  "Inclusion and support",
  "Protocol eligibility and fixed daily/hourly coverage exclusions defined the sample.",
  "Verified participant-day coverage is followed by metric-specific support; every fitted model reports its exact rows and support hours.",
  "Sleep and non-wear",
  "Logged non-wear and sleep exclusions were applied without a fully specified state hierarchy.",
  "Diary sleep has precedence, invalid non-wear is masked consistently, and sleep measurements are described as the bedside environment rather than ocular exposure.",
  "Upper light boundary",
  "Values above 120,000 lx were to be removed.",
  "The verified analytical melEDI signal retains values strictly below 100,000 lx.",
  "Darkest-window level",
  "The level metric used the five darkest hours.",
  "The specified metric is the mean melEDI during the darkest 10 hours.",
  "Threshold timing",
  "The timing outcome was the midpoint of the longest period above 250 lx.",
  "The registered midpoint of the longest qualifying period is retained. Mean timing above 250 lx melEDI is a distinct circular duration-weighted metric and is labelled as an adapted sensitivity; period construction follows verified continuity and support rules.",
  "Site and latitude",
  "One model included both site and latitude.",
  "Because each site has one latitude, fixed-site and linear-latitude models are fitted separately on identical rows and compared for adequacy.",
  "Photoperiod scope",
  "Photoperiod adjustment was specified for duration metrics.",
  "Photoperiod is included in the common model implementation for all 17 metrics.",
  "Response models",
  "Linear mixed models were specified generically.",
  "Each metric uses its specified Gaussian transformation or Tweedie log-link response model.",
  "Multiplicity",
  "False-discovery-rate control was required within H1 but the exact vectors were not specified.",
  "Four separate complete 17-test Benjamini–Hochberg families are used for site, photoperiod, latitude, and site-versus-latitude adequacy.",
  "Site follow-ups",
  "Site coefficients were reported without a fixed hierarchical follow-up rule.",
  "Only after a supported overall site test, each site is compared with the equally weighted overall site mean and the site contrasts are adjusted within metric.",
  "Variation, uncertainty, and exact samples",
  "Conditional R² and significance-dependent component summaries were used without joint interval estimation or exact model-specific sample reporting.",
  "Marginal and conditional R², participant-associated share, and non-overlapping term part-R² summaries use 1,000 successful joint bootstrap refits in the full run and 95% intervals; exact model-specific samples are reported.",
  "Model comparison",
  "Fixed-effect structures had been compared using REML-derived criteria.",
  "Gaussian fixed-effect comparisons use maximum likelihood; final Gaussian estimation uses REML where applicable.",
  "Full-day construct",
  "The intended relation between worn exposure and sleep-period environmental measurement was implicit.",
  "The 24-hour record retains both constructs but keeps their interpretations distinct.",
  "Melanopic daylight efficacy ratio",
  "MDER summarizes momentary melEDI-to-photopic-illuminance ratios; the preregistration did not specify a ratio-of-integrals definition.",
  "MDER is the arithmetic mean of viable one-minute melEDI-to-photopic-illuminance ratios. Both channels must be finite and strictly positive, and at least 720 viable minutes are required on the complete 1,440-minute local wall-clock grid.",
  "Interdaily stability and intradaily variability",
  "Incomplete repeated-day support could enter the dynamics metrics.",
  "Dynamics metrics use verified temporal support and report participant-level model rows plus contributing participant-days.",
  "Windows, periods, and timing",
  "Missing intervals could be bridged or incomplete windows summarized.",
  "Windows and continuous periods use support, continuity, gap, wrapping, and tie rules fixed before modelling.",
  "melEDI dose",
  "Dose could be a partial sum without a defined support denominator.",
  "Dose is time-sensitive and retained only with the specified interval support.",
  "Time axes and source epochs",
  "A single local time axis and one assumed source epoch were used.",
  "Absolute time governs ordering and duration, local wall time governs clock metrics, repeated fall-back bins are handled explicitly, and each source epoch is respected.",
  "Participant-day plausibility",
  "The preregistration uses coverage and signal-validity rules but does not specify exclusion of an otherwise eligible complete exact-zero melEDI day.",
  "An otherwise eligible participant-day is excluded only when every finite one-minute melEDI value is exactly 0 lx; individual zeros remain valid and retaining these days is a sensitivity analysis.",
  "Pre-sleep duration",
  "The label implied a single three-hour window before sleep.",
  "The outcome is calendar-day cumulative time below 10 lx melEDI across every diary-defined pre-sleep interval; values strictly above six hours trigger a diagnostic warning but are not capped.",
  "Darkest-10-hour midpoint",
  "The clock response was linearized around noon.",
  "The primary conversion subtracts 24 hours only for values strictly after 16:00; the noon cut is a registered same-row sensitivity."
)


deviations |> select(topic, analysis_used) |> head()
# A tibble: 6 × 2
  topic                 analysis_used                                           
  <chr>                 <chr>                                                   
1 Placement             Near-eye measurements are primary; chest measurements a…
2 Inclusion and support Verified participant-day coverage is followed by metric…
3 Sleep and non-wear    Diary sleep has precedence, invalid non-wear is masked …
4 Upper light boundary  The verified analytical melEDI signal retains values st…
5 Darkest-window level  The specified metric is the mean melEDI during the dark…
6 Threshold timing      The registered midpoint of the longest qualifying perio…

Compare matched sensor positions

Use the same participant-days to compare near-eye and chest effects, with observed photoperiod and latitude as context.

near_photoperiod <- read_required(
  "results/csv/source_data/descriptives/latitude_photoperiod.csv"
) |>
  transmute(
    placement = "glasses",
    .data$site,
    participant_key = .data$Id,
    .data$local_date,
    .data$photoperiod_hours,
    .data$absolute_latitude_deg,
    .data$deterministic_plot_offset_deg,
    .data$plot_latitude_deg
  )

h01_data <- readRDS(file.path(root, "results/intermediate/model_data/H01.rds"))

chest_photoperiod <- h01_data$model_rows |>
  filter(
    .data$placement == "chest",
    .data$scenario == "all_available",
    .data$metric_id == "daily_geometric_mean_medi",
    .data$scenario_estimable,
    .data$site_photoperiod_included,
    .data$latitude_photoperiod_included
  ) |>
  arrange(.data$site, .data$participant_key, .data$local_date) |>
  group_by(.data$site) |>
  mutate(
    deterministic_plot_offset_deg =
      seq(-0.2, 0.2, length.out = 17)[(dplyr::row_number() - 1L) %% 17L + 1L],
    absolute_latitude_deg = abs(.data$latitude_deg),
    plot_latitude_deg =
      .data$absolute_latitude_deg + .data$deterministic_plot_offset_deg
  ) |>
  ungroup() |>
  transmute(
    placement = "chest",
    .data$site,
    .data$participant_key,
    .data$local_date,
    .data$photoperiod_hours,
    .data$absolute_latitude_deg,
    .data$deterministic_plot_offset_deg,
    .data$plot_latitude_deg
  )

photoperiod_source <- bind_rows(near_photoperiod, chest_photoperiod) |>
  left_join(site_registry, by = "site", relationship = "many-to-one") |>
  arrange(.data$placement, .data$display_order, .data$participant_key, .data$local_date)

photoperiod_expected <- samples |>
  filter(
    .data$run_id %in% selected_runs,
    .data$metric_id == "daily_geometric_mean_medi"
  ) |>
  transmute(
    placement = .data$placement,
    expected_observations = as.integer(.data$observations)
  )

photoperiod_observed <- photoperiod_source |>
  count(.data$placement, name = "observed_observations") |>
  left_join(
    photoperiod_expected,
    by = "placement",
    relationship = "one-to-one"
  )

if (
  nrow(photoperiod_observed) != length(selected_runs) ||
    anyNA(photoperiod_observed$expected_observations) ||
    any(
      photoperiod_observed$observed_observations !=
        photoperiod_observed$expected_observations
    )
) {
  stop("The reporting photoperiod source does not match exact fitted samples", call. = FALSE)
}

photoperiod_bounds <- read_required(
  paste0(
    "results/csv/source_data/descriptives/",
    "photoperiod_latitude_bounds.csv"
  )
)

paired_near_run <- "main__glasses__paired_common_sample"

paired_chest_run <- "main__chest__paired_common_sample"

paired_terms <- c(
  "photoperiod_centered_hours",
  "absolute_latitude_10deg_centered"
)

paired_effect_base <- term_effects |>
  filter(
    .data$run_id %in% c(paired_near_run, paired_chest_run),
    .data$term %in% paired_terms,
    is.finite(.data$estimate_practical),
    is.finite(.data$conf_low_practical),
    is.finite(.data$conf_high_practical)
  ) |>
  transmute(
    .data$run_id,
    .data$metric_order,
    .data$metric_id,
    .data$analysis_unit,
    .data$response_family,
    .data$response_transform,
    .data$term,
    .data$effect_type,
    .data$estimate_practical,
    .data$conf_low_practical,
    .data$conf_high_practical,
    .data$p_raw,
    .data$status
  )

paired_test_base <- tests |>
  filter(
    .data$run_id %in% c(paired_near_run, paired_chest_run),
    .data$family_id %in% c("H01-F2-photoperiod", "H01-F3-latitude")
  ) |>
  transmute(
    .data$run_id,
    .data$metric_id,
    term = if_else(
      .data$family_id == "H01-F2-photoperiod",
      "photoperiod_centered_hours",
      "absolute_latitude_10deg_centered"
    ),
    model_level_p_raw = .data$p_raw,
    model_level_bh_adjusted_p = .data$p_adjusted
  )

paired_effect_base <- paired_effect_base |>
  left_join(
    paired_test_base,
    by = c("run_id", "metric_id", "term"),
    relationship = "one-to-one"
  )

paired_near_effect <- paired_effect_base |>
  filter(.data$run_id == paired_near_run) |>
  select(-"run_id") |>
  rename_with(
    ~ paste0("near_", .x),
    -c("metric_order", "metric_id", "term")
  )

paired_chest_effect <- paired_effect_base |>
  filter(.data$run_id == paired_chest_run) |>
  select(-"run_id") |>
  rename_with(
    ~ paste0("chest_", .x),
    -c("metric_order", "metric_id", "term")
  )

paired_near_sample <- samples |>
  filter(.data$run_id == paired_near_run) |>
  transmute(
    .data$metric_id,
    near_participants = as.integer(.data$participants),
    near_participant_days = as.integer(.data$participant_days),
    near_observations = as.integer(.data$observations),
    near_sites = as.integer(.data$sites)
  )

paired_chest_sample <- samples |>
  filter(.data$run_id == paired_chest_run) |>
  transmute(
    .data$metric_id,
    chest_participants = as.integer(.data$participants),
    chest_participant_days = as.integer(.data$participant_days),
    chest_observations = as.integer(.data$observations),
    chest_sites = as.integer(.data$sites)
  )

paired_placement <- paired_near_effect |>
  inner_join(
    paired_chest_effect,
    by = c("metric_order", "metric_id", "term"),
    relationship = "one-to-one"
  ) |>
  left_join(paired_near_sample, by = "metric_id", relationship = "many-to-one") |>
  left_join(paired_chest_sample, by = "metric_id", relationship = "many-to-one") |>
  left_join(metric_registry, by = c("metric_order", "metric_id")) |>
  mutate(
    predictor = recode(
      .data$term,
      photoperiod_centered_hours = "Photoperiod",
      absolute_latitude_10deg_centered = "Latitude per 10°"
    ),
    predictor_order = if_else(.data$term == "photoperiod_centered_hours", 1L, 2L),
    effect_scale = if_else(.data$near_effect_type == "ratio", "Ratio", "Difference"),
    null_value = if_else(.data$near_effect_type == "ratio", 1, 0),
    point_label = paste0(
      .data$abbreviation,
      if_else(.data$term == "photoperiod_centered_hours", " · P", " · L")
    ),
    sample_exactly_matched =
      .data$near_participants == .data$chest_participants &
      .data$near_participant_days == .data$chest_participant_days &
      .data$near_observations == .data$chest_observations &
      .data$near_sites == .data$chest_sites
  ) |>
  arrange(.data$effect_scale, .data$metric_order, .data$predictor_order)

if (
  nrow(paired_placement) != 30L ||
    any(!paired_placement$sample_exactly_matched) ||
    any(paired_placement$near_response_family != paired_placement$chest_response_family) ||
    any(paired_placement$near_response_transform != paired_placement$chest_response_transform) ||
    any(paired_placement$near_effect_type != paired_placement$chest_effect_type) ||
    any(paired_placement$near_analysis_unit != paired_placement$chest_analysis_unit)
) {
  stop(
    "Stored H01 paired-placement estimands do not meet the matched-display rule",
    call. = FALSE
  )
}


paired_placement |> head()
# A tibble: 6 × 54
  metric_order metric_id                 near_analysis_unit near_response_family
         <dbl> <chr>                     <chr>              <chr>               
1            8 duration_below_10_pre_sl… participant_day    gaussian            
2            8 duration_below_10_pre_sl… participant_day    gaussian            
3           11 m10_midpoint              participant_day    gaussian            
4           11 m10_midpoint              participant_day    gaussian            
5           12 l10_midpoint              participant_day    gaussian            
6           12 l10_midpoint              participant_day    gaussian            
# ℹ 50 more variables: near_response_transform <chr>, term <chr>,
#   near_effect_type <chr>, near_estimate_practical <dbl>,
#   near_conf_low_practical <dbl>, near_conf_high_practical <dbl>,
#   near_p_raw <dbl>, near_status <chr>, near_model_level_p_raw <dbl>,
#   near_model_level_bh_adjusted_p <dbl>, chest_analysis_unit <chr>,
#   chest_response_family <chr>, chest_response_transform <chr>,
#   chest_effect_type <chr>, chest_estimate_practical <dbl>, …

Save the reader tables and figure data

Save the assembled summaries and the source data used for the following figures. Styling functions preserve scales, labels, and units.

table_root <- file.path(root, "results/tables/H01/reporting")

figure_root <- file.path(root, "results/images/H01/reporting")

density_root <- file.path(figure_root, "metric_density")

source_root <- file.path(root, "results/csv/source_data/H01/reporting")

diagnostic_root <- file.path(root, "results/csv/diagnostics/H01/reporting")

dir.create(table_root, recursive = TRUE, showWarnings = FALSE)

dir.create(figure_root, recursive = TRUE, showWarnings = FALSE)

dir.create(density_root, recursive = TRUE, showWarnings = FALSE)

dir.create(source_root, recursive = TRUE, showWarnings = FALSE)

dir.create(diagnostic_root, recursive = TRUE, showWarnings = FALSE)

density_figure_paths <- vapply(
  seq_len(nrow(primary_metric_synthesis)),
  function(index) {
    row <- primary_metric_synthesis[index, , drop = FALSE]
    path <- file.path(
      density_root,
      paste0("H01_metric_density_", row$metric_id[[1]], ".png")
    )
    ggsave(
      path,
      make_metric_density_plot(
        row$metric_id[[1]],
        row$descriptive_scaling[[1]]
      ),
      width = 2.7,
      height = 1.5,
      units = "in",
      dpi = 320,
      bg = "white"
    )
    path
  },
  character(1)
)
Picking joint bandwidth of 0.045
Picking joint bandwidth of 0.189
Picking joint bandwidth of 0.135
Picking joint bandwidth of 0.192
Picking joint bandwidth of 0.0703
Picking joint bandwidth of 0.195
Picking joint bandwidth of 0.179
Picking joint bandwidth of 0.119
Picking joint bandwidth of 0.0393
Picking joint bandwidth of 0.161
Picking joint bandwidth of 34.6
Picking joint bandwidth of 28.8
Picking joint bandwidth of 35.5
Picking joint bandwidth of 40.4
Picking joint bandwidth of 46.1
Picking joint bandwidth of 0.196
Picking joint bandwidth of 0.0346
if (
  length(density_figure_paths) != 17L ||
    any(!file.exists(density_figure_paths)) ||
    any(file.info(density_figure_paths)$size <= 0L) ||
    !identical(
      substring(density_figure_paths, nchar(root) + 2L),
      primary_metric_synthesis$density_artifact_path
    )
) {
  stop("The H01 metric-density thumbnails are incomplete", call. = FALSE)
}

representative_diagnostic_relative_paths <- c(
  "results/images/H01/diagnostics/main/glasses/all_available/daily_geometric_mean_medi_diagnostics.png",
  "results/images/H01/diagnostics/main/glasses/all_available/duration_below_10_pre_sleep_diagnostics.png",
  "results/images/H01/diagnostics/main/glasses/all_available/duration_above_250_wake_diagnostics.png",
  "results/images/H01/diagnostics/main/glasses/all_available/duration_below_1_sleep_environment_diagnostics.png"
)

representative_diagnostic_source_relative_paths <- c(
  "results/csv/source_data/H01/main/glasses/all_available/daily_geometric_mean_medi_diagnostic_plot_data.csv",
  "results/csv/source_data/H01/main/glasses/all_available/duration_below_10_pre_sleep_diagnostic_plot_data.csv",
  "results/csv/source_data/H01/main/glasses/all_available/duration_above_250_wake_diagnostic_plot_data.csv",
  "results/csv/source_data/H01/main/glasses/all_available/duration_below_1_sleep_environment_diagnostic_plot_data.csv"
)

representative_diagnostic_paths <- file.path(
  root,
  representative_diagnostic_relative_paths
)

representative_diagnostic_source_paths <- file.path(
  root,
  representative_diagnostic_source_relative_paths
)

if (
  !all(file.exists(representative_diagnostic_paths)) ||
    !all(file.exists(representative_diagnostic_source_paths))
) {
  stop("A preserved representative diagnostic plot or source file is missing", call. = FALSE)
}

figure_display_registry <- tibble::tribble(
  ~figure_id, ~artifact_path, ~reader_display_status, ~reason,
  "model_support", "results/images/H01/reporting/H01_model_support.png", "displayed", "Compact overview of the four corrected inferential families.",
  "site_contrasts_near_eye", "results/images/H01/reporting/H01_site_contrasts_near_eye.png", "displayed", "Primary hierarchical site contrasts.",
  "site_contrasts_chest", "results/images/H01/reporting/H01_site_contrasts_chest.png", "displayed", "Complementary hierarchical site contrasts.",
  "r2_intervals", "results/images/H01/reporting/H01_r2_intervals.png", "displayed", "Variation represented by the fitted models.",
  "diagnostic_assessment", "results/images/H01/reporting/H01_diagnostic_assessment.png", "displayed", "Overview of diagnostic review status.",
  "paired_placement", "results/images/H01/reporting/H01_paired_placement.png", "displayed", "Matched near-eye-versus-chest placement comparison.",
  "photoperiod_latitude_near_eye", "results/images/H01/reporting/H01_photoperiod_latitude_near_eye.png", "retained_not_displayed", "The descriptive report displays the observed latitude and photoperiod coverage.",
  "photoperiod_latitude_chest", "results/images/H01/reporting/H01_photoperiod_latitude_chest.png", "retained_not_displayed", "The descriptive report displays the observed latitude and photoperiod coverage.",
  "diagnostic_daily_geometric_mean_medi", representative_diagnostic_relative_paths[[1]], "displayed", "Representative Gaussian review example.",
  "diagnostic_duration_below_10_pre_sleep", representative_diagnostic_relative_paths[[2]], "displayed", "Representative Gaussian review example.",
  "diagnostic_duration_above_250_wake", representative_diagnostic_relative_paths[[3]], "displayed", "Representative Tweedie review example.",
  "diagnostic_duration_below_1_sleep_environment", representative_diagnostic_relative_paths[[4]], "displayed", "Representative strong Tweedie and prediction-bound review example."
)

tables <- list(
  H01_metric_registry = metric_registry,
  H01_model_results = model_results,
  H01_primary_publication_summary = primary_publication_summary,
  H01_primary_metric_synthesis = primary_metric_synthesis,
  H01_site_contrasts = site_contrasts,
  H01_r2 = r2_reporting,
  H01_r2_table = r2_table,
  H01_diagnostic_details = diagnostic_details,
  H01_figure_display_registry = figure_display_registry,
  H01_exact_samples = exact_samples,
  H01_exact_samples_by_site = exact_samples_by_site,
  H01_formula_specification = formula_specification,
  H01_sensitivity_support = support_summary,
  H01_sensitivity_classification = sensitivity_classification,
  H01_paired_placement = paired_placement,
  H01_l10_noon_sensitivity = l10_sensitivity,
  H01_exact_period_sensitivity = period_sensitivity_reporting,
  H01_scope_sensitivity = scope_sensitivity_reporting,
  H01_influence_summary = influence_summary,
  H01_latitude_loo_summary = latitude_loo_summary,
  H01_marginalization = marginalization_reporting,
  H01_deviations = deviations
)

table_paths <- vapply(names(tables), function(name) {
  path <- file.path(table_root, paste0(name, ".csv"))
  write_csv_artifact(tables[[name]], path, producer = producer)
  path
}, character(1))

source_objects <- list(
  H01_model_support_figure_source = support_matrix,
  H01_site_contrast_figure_source = site_contrasts,
  H01_r2_figure_source = r2_reporting |>
    filter(.data$measure %in% c(
      "marginal_r2", "conditional_r2", "participant_associated_share",
      "unrepresented_share"
    )),
  H01_diagnostic_figure_source = diagnostic_matrix,
  H01_photoperiod_latitude_source = photoperiod_source,
  H01_photoperiod_latitude_bounds = photoperiod_bounds,
  H01_l10_noon_effect_source = noon_effects |>
    filter(.data$run_id %in% selected_runs),
  H01_l10_noon_sample_source = noon_samples |>
    filter(.data$run_id %in% selected_runs),
  H01_latitude_leave_one_site_out_source = latitude_loo |>
    filter(.data$run_id %in% selected_runs),
  H01_participant_influence_source = participant_influence |>
    filter(.data$run_id %in% selected_runs),
  H01_paired_placement_figure_source = paired_placement
)

source_paths <- vapply(names(source_objects), function(name) {
  path <- file.path(source_root, paste0(name, ".csv"))
  write_csv_artifact(source_objects[[name]], path, producer = producer)
  path
}, character(1))

Draw the model and sensitivity figures

Generate the support matrix, site contrasts, R-squared intervals, diagnostic display, and matched-placement comparison from the regenerated results.

metric_levels <- rev(metric_registry$manuscript_name)

support_plot <- support_matrix |>
  mutate(
    manuscript_name = factor(.data$manuscript_name, levels = metric_levels),
    question_label = factor(
      .data$question_label,
      levels = question_registry$question_label
    ),
    placement_label = factor(
      .data$placement_label,
      levels = c("Near eye", "Chest")
    )
  ) |>
  ggplot(aes(x = .data$question_label, y = .data$manuscript_name)) +
  geom_tile(aes(fill = .data$support_status), colour = "white", linewidth = 0.35) +
  geom_text(aes(label = .data$support_symbol), size = 4.0, colour = "#111111") +
  facet_wrap(vars(.data$placement_label), ncol = 2) +
  scale_fill_manual(
    values = c(
      Supported = "#88CCEE",
      `Not supported` = "#ECECEC",
      `Not estimable` = "#CC6677"
    ),
    drop = FALSE
  ) +
  labs(x = NULL, y = NULL, fill = "FDR-adjusted result") +
  theme_minimal(base_size = 9) +
  theme(
    panel.grid = element_blank(),
    axis.text.x = element_text(size = 11.2, angle = 28, hjust = 1),
    axis.text.y = element_text(size = 11.2),
    strip.text = element_text(size = 11.2, face = "bold"),
    legend.text = element_text(size = 11.2),
    legend.title = element_text(size = 11.2),
    legend.position = "bottom"
  )

site_colors <- stats::setNames(site_registry$color_hex, site_registry$display_name)

contrast_near_plot <- make_contrast_plot(site_contrasts, "Near eye")

contrast_chest_plot <- make_contrast_plot(site_contrasts, "Chest")

r2_measure_labels <- c(
  marginal_r2 = "Marginal R²",
  conditional_r2 = "Conditional R²",
  participant_associated_share = "Participant-associated share",
  unrepresented_share = "Not represented"
)

r2_plot_data <- source_objects$H01_r2_figure_source |>
  filter(.data$status == "PASS") |>
  mutate(
    manuscript_name = factor(.data$manuscript_name, levels = metric_levels),
    measure_label = factor(
      unname(r2_measure_labels[.data$measure]),
      levels = unname(r2_measure_labels)
    ),
    placement_label = factor(
      .data$placement_label,
      levels = c("Near eye", "Chest")
    )
  )

r2_plot <- ggplot(
  r2_plot_data,
  aes(
    x = .data$estimate,
    y = .data$manuscript_name,
    colour = .data$measure_label,
    shape = .data$measure_label
  )
) +
  geom_errorbar(
    aes(xmin = .data$conf_low, xmax = .data$conf_high),
    width = 0,
    orientation = "y",
    position = position_dodge(width = 0.62),
    linewidth = 0.4
  ) +
  geom_point(position = position_dodge(width = 0.62), size = 1.55) +
  facet_wrap(vars(.data$placement_label), ncol = 2) +
  scale_colour_manual(values = c("#117733", "#332288", "#CC6677", "#777777")) +
  scale_shape_manual(values = c(16, 17, 15, 18)) +
  coord_cartesian(xlim = c(0, 1)) +
  labs(x = "Share of outcome variance (95% bootstrap interval)", y = NULL) +
  theme_minimal(base_size = 9.5) +
  theme(
    panel.grid.minor = element_blank(),
    strip.text = element_text(face = "bold"),
    legend.position = "bottom",
    legend.title = element_blank()
  )

diagnostic_plot <- diagnostic_matrix |>
  mutate(
    manuscript_name = factor(.data$manuscript_name, levels = metric_levels),
    diagnostic_check = factor(
      .data$diagnostic_check,
      levels = unique(diagnostic_matrix$diagnostic_check[order(diagnostic_matrix$check_order)])
    ),
    placement_label = factor(
      .data$placement_label,
      levels = c("Near eye", "Chest")
    )
  ) |>
  ggplot(aes(x = .data$diagnostic_check, y = .data$manuscript_name)) +
  geom_tile(aes(fill = .data$check_status), colour = "white", linewidth = 0.35) +
  geom_text(aes(label = .data$check_symbol), size = 4.3) +
  facet_wrap(vars(.data$placement_label), ncol = 2) +
  scale_fill_manual(
    values = c(
      Pass = "#88CCEE",
      Review = "#DDCC77",
      Fail = "#CC6677",
      `Not applicable` = "#ECECEC"
    ),
    drop = FALSE
  ) +
  labs(x = NULL, y = NULL, fill = "Assessment") +
  theme_minimal(base_size = 9.5) +
  theme(
    panel.grid = element_blank(),
    axis.text.x = element_text(size = 12.2, angle = 28, hjust = 1),
    axis.text.y = element_text(size = 12.2),
    strip.text = element_text(size = 12.2, face = "bold"),
    legend.text = element_text(size = 12.2),
    legend.title = element_text(size = 12.2),
    legend.position = "bottom"
  )

photoperiod_near_plot <- make_photoperiod_plot(photoperiod_source, "glasses")

photoperiod_chest_plot <- make_photoperiod_plot(photoperiod_source, "chest")

paired_difference_plot <- make_paired_placement_panel(paired_placement, "Difference")

paired_ratio_plot <- make_paired_placement_panel(paired_placement, "Ratio")

paired_placement_plot <- cowplot::plot_grid(
  paired_difference_plot,
  paired_ratio_plot,
  nrow = 1,
  align = "hv",
  axis = "tblr",
  rel_widths = c(1, 1)
)

figure_specs <- list(
  H01_model_support = list(plot = support_plot, width = 10.5, height = 7.4, bg = "white"),
  H01_site_contrasts_near_eye = list(plot = contrast_near_plot, width = 11.5, height = 14.5, bg = "white"),
  H01_site_contrasts_chest = list(plot = contrast_chest_plot, width = 11.5, height = 21.0, bg = "white"),
  H01_r2_intervals = list(plot = r2_plot, width = 10.5, height = 8.0, bg = "white"),
  H01_diagnostic_assessment = list(plot = diagnostic_plot, width = 11.5, height = 7.6, bg = "white"),
  H01_paired_placement = list(plot = paired_placement_plot, width = 12, height = 6.8, bg = "white"),
  H01_photoperiod_latitude_near_eye = list(plot = photoperiod_near_plot, width = 6, height = 6, bg = "black"),
  H01_photoperiod_latitude_chest = list(plot = photoperiod_chest_plot, width = 6, height = 6, bg = "black")
)

figure_paths <- density_figure_paths

for (name in names(figure_specs)) {
  spec <- figure_specs[[name]]
  png_path <- file.path(figure_root, paste0(name, ".png"))
  svg_path <- file.path(figure_root, paste0(name, ".svg"))
  ggsave(
    png_path,
    spec$plot,
    width = spec$width,
    height = spec$height,
    units = "in",
    dpi = 320,
    bg = spec$bg
  )
  ggsave(
    svg_path,
    spec$plot,
    width = spec$width,
    height = spec$height,
    units = "in",
    bg = spec$bg
  )
  figure_paths <- c(figure_paths, png_path, svg_path)
}


support_plot

Prepare the displayed estimates

The following extracts bind the displayed tables and prose to the calculated results.

source("scripts/hypotheses/H01/h01_reader_helpers.R")
library(dplyr)

library(gt)

library(readr)

library(stringr)

library(tidyr)

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

if (!file.exists(file.path(root, "renv.lock"))) {
  stop("H01.qmd must execute from the project root", call. = FALSE)
}

if (!identical(as.character(getRversion()), "4.6.1")) {
  stop("H01.qmd requires R 4.6.1", call. = FALSE)
}

source(file.path(root, "scripts/hypotheses/H01/h01_contract.R"))

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

table_root <- file.path(root, "results/tables/H01/reporting")

source_root <- file.path(root, "results/csv/source_data/H01/reporting")

metric_registry <- read_reporting("H01_metric_registry")

model_results <- read_reporting("H01_model_results")

primary_publication_summary <- read_reporting(
  "H01_primary_publication_summary"
)

primary_metric_synthesis <- read_reporting(
  "H01_primary_metric_synthesis"
)

site_contrasts <- read_reporting("H01_site_contrasts")

r2_table <- read_reporting("H01_r2_table")

diagnostic_details <- read_reporting("H01_diagnostic_details")

figure_display_registry <- read_reporting("H01_figure_display_registry")

exact_samples <- read_reporting("H01_exact_samples")

formula_specification <- read_reporting("H01_formula_specification")

sensitivity_support <- read_reporting("H01_sensitivity_support")

sensitivity_classification <- read_reporting(
  "H01_sensitivity_classification"
)

paired_placement <- read_reporting("H01_paired_placement")

l10_noon <- read_reporting("H01_l10_noon_sensitivity")

period_sensitivity <- read_reporting("H01_exact_period_sensitivity")

scope_sensitivity <- read_reporting("H01_scope_sensitivity")

influence_summary <- read_reporting("H01_influence_summary")

latitude_loo_summary <- read_reporting("H01_latitude_loo_summary")

marginalization <- read_reporting("H01_marginalization")

deviations <- read_reporting("H01_deviations")

l10_noon_effects <- read_source("H01_l10_noon_effect_source")

l10_noon_samples <- read_source("H01_l10_noon_sample_source")

primary_run <- "main__glasses__all_available"

chest_run <- "main__chest__all_available"

category_order <- c(
  "duration-based",
  "dynamics-based",
  "exposure-history-based",
  "level-based",
  "spectrum-based",
  "timing-based",
  "Grand average"
)

category_labels <- c(
  `duration-based` = "Duration",
  `dynamics-based` = "Dynamics",
  `exposure-history-based` = "Exposure history",
  `level-based` = "Level",
  `spectrum-based` = "Spectrum",
  `timing-based` = "Timing",
  `Grand average` = "Grand average"
)

primary_support <- sensitivity_support |>
  filter(.data$run_id == primary_run) |>
  arrange(.data$question_order)

chest_support <- sensitivity_support |>
  filter(.data$run_id == chest_run) |>
  arrange(.data$question_order)

diagnostic_counts <- model_results |>
  count(.data$placement_label, .data$assessment)

primary_mean_medi <- primary_publication_summary |>
  filter(.data$metric_id == "daily_geometric_mean_medi")

primary_pre_sleep <- primary_publication_summary |>
  filter(.data$metric_id == "duration_below_10_pre_sleep")

primary_last_light <- primary_publication_summary |>
  filter(.data$metric_id == "last_timing_above_250")

primary_mder <- primary_publication_summary |>
  filter(.data$metric_id == "mder_mean_of_viable_ratios")

primary_r2_metrics <- r2_table |>
  filter(.data$run_id == primary_run, .data$row_type == "Metric")

primary_r2_grand <- r2_table |>
  filter(.data$run_id == primary_run, .data$row_type == "Grand average")

fixed_r2_range <- range(
  primary_r2_metrics$estimate[
    primary_r2_metrics$measure == "marginal_r2"
  ],
  na.rm = TRUE
)

model_r2_range <- range(
  primary_r2_metrics$estimate[
    primary_r2_metrics$measure == "conditional_r2"
  ],
  na.rm = TRUE
)

supported_part_r2 <- primary_r2_grand |>
  filter(.data$measure %in% c(
    "site_part_r2", "photoperiod_part_r2", "latitude_part_r2"
  )) |>
  select(.data$measure, .data$estimate, .data$supported_n, .data$unsupported_n)
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"measure"` instead of `.data$measure`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"estimate"` instead of `.data$estimate`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"supported_n"` instead of `.data$supported_n`
Warning: Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
ℹ Please use `"unsupported_n"` instead of `.data$unsupported_n`
paired_summary <- paired_placement |>
  mutate(
    same_side_of_null = sign(.data$near_estimate_practical - .data$null_value) ==
      sign(.data$chest_estimate_practical - .data$null_value),
    support_differs =
      (.data$near_model_level_bh_adjusted_p < 0.05) !=
      (.data$chest_model_level_bh_adjusted_p < 0.05)
  ) |>
  summarise(
    estimands = dplyr::n(),
    same_side = sum(.data$same_side_of_null, na.rm = TRUE),
    support_switches = sum(.data$support_differs, na.rm = TRUE),
    participant_min = min(.data$near_participants, na.rm = TRUE),
    participant_max = max(.data$near_participants, na.rm = TRUE),
    day_min = min(.data$near_participant_days, na.rm = TRUE),
    day_max = max(.data$near_participant_days, na.rm = TRUE),
    observation_min = min(.data$near_observations, na.rm = TRUE),
    observation_max = max(.data$near_observations, na.rm = TRUE),
    sites = unique(.data$near_sites)
  )

gap_primary_summary <- sensitivity_classification |>
  filter(
    .data$run_id ==
      "alternative_preprocessing__glasses__all_available"
  )

primary_category_support <- primary_publication_summary |>
  group_by(.data$manuscript_category) |>
  summarise(
    metrics = dplyr::n(),
    site = sum(.data$site_p_adjusted < 0.05, na.rm = TRUE),
    photoperiod = sum(.data$photoperiod_p_adjusted < 0.05, na.rm = TRUE),
    latitude = sum(.data$latitude_p_adjusted < 0.05, na.rm = TRUE),
    adequacy = sum(.data$adequacy_p_adjusted < 0.05, na.rm = TRUE),
    .groups = "drop"
  )

photoperiod_supported_ratio_range <- primary_publication_summary |>
  filter(
    .data$photoperiod_p_adjusted < 0.05,
    .data$photoperiod_effect_type == "ratio"
  ) |>
  summarise(
    minimum = min(.data$photoperiod_estimate_practical, na.rm = TRUE),
    maximum = max(.data$photoperiod_estimate_practical, na.rm = TRUE)
  )

participant_share_range <- range(
  primary_r2_metrics$estimate[
    primary_r2_metrics$measure == "participant_associated_share"
  ],
  na.rm = TRUE
)

h01_support_orientation <- exact_samples |>
  filter(.data$run_id %in% c(primary_run, chest_run)) |>
  summarise(
    primary_participant_min = min(
      .data$participants[.data$run_id == primary_run],
      na.rm = TRUE
    ),
    primary_participant_max = max(
      .data$participants[.data$run_id == primary_run],
      na.rm = TRUE
    ),
    primary_day_min = min(
      .data$participant_days[.data$run_id == primary_run],
      na.rm = TRUE
    ),
    primary_day_max = max(
      .data$participant_days[.data$run_id == primary_run],
      na.rm = TRUE
    ),
    primary_sites = max(
      .data$sites[.data$run_id == primary_run],
      na.rm = TRUE
    ),
    chest_participant_min = min(
      .data$participants[.data$run_id == chest_run],
      na.rm = TRUE
    ),
    chest_participant_max = max(
      .data$participants[.data$run_id == chest_run],
      na.rm = TRUE
    ),
    chest_day_min = min(
      .data$participant_days[.data$run_id == chest_run],
      na.rm = TRUE
    ),
    chest_day_max = max(
      .data$participant_days[.data$run_id == chest_run],
      na.rm = TRUE
    ),
    chest_sites = max(
      .data$sites[.data$run_id == chest_run],
      na.rm = TRUE
    )
  )


primary_support |> gt()
run_id run_order run_label data_label placement_label sample_label question_order family_id question_label planned_tests supported_metrics nonestimable_metrics family_status
main__glasses__all_available 1 Main near-eye Main Near eye All available 1 H01-F1-site Overall site 17 10 0 COMPLETE
main__glasses__all_available 1 Main near-eye Main Near eye All available 2 H01-F2-photoperiod Photoperiod 17 12 0 COMPLETE
main__glasses__all_available 1 Main near-eye Main Near eye All available 3 H01-F3-latitude Latitude 17 7 0 COMPLETE
main__glasses__all_available 1 Main near-eye Main Near eye All available 4 H01-F4-site-latitude-adequacy Site versus linear latitude 17 9 0 COMPLETE

Hypothesis and analytical question

The preregistered H1 hypothesis was:

“Personal light-exposure metrics differ across sites after accounting for latitude and photoperiod.”

The scientific question is whether the 17 personal-light-exposure metrics show site, photoperiod, or latitude associations across the international study sites. Melanopic equivalent daylight illuminance (melEDI) is an illuminance weighted for melanopsin-related sensitivity. The near-eye sensor position is primary because it samples light closer to the eyes during wear, but it does not directly measure retinal exposure. The chest sensor position provides complementary evidence about light measured at the chest, not ocular exposure, and is not pooled with near-eye measurements.

The analysis treats site and latitude as separate explanations. Each site has one latitude, so a fixed-site model and a linear-latitude model cannot be interpreted as independent covariates in one ordinary fixed-effects model. Instead, both models use the same observations and photoperiod adjustment, and their adequacy is compared on that common frame.

NoteAnswer in brief

After false-discovery-rate (FDR) adjustment in four separate complete 17-test families, the primary near-eye analysis supported 10 metrics for overall site, 12 for photoperiod, and 7 for latitude.

Per additional hour of photoperiod, consequential examples were a ×1.18 [1.11–1.26] ratio in mean melEDI, and -0.13 [-0.20–-0.07] h less calendar-day cumulative time below 10 lx melEDI before sleep; last light above 250 lx melEDI occurred 0.32 [0.19–0.46] h later, and each bracketed range is a 95% confidence interval (95% CI).

Fixed effects represented 5.9–27.8% of outcome variation across metrics, while complete models represented 5.9–61.1%.

MDER was supported for overall site (FDR-adjusted p <0.001), photoperiod (FDR-adjusted p <0.001), latitude (FDR-adjusted p = 0.013), and site-versus-linear-latitude adequacy (FDR-adjusted p = 0.010). MDER increased by 0.024 per additional hour of photoperiod (95% CI 0.017 to 0.031) and decreased by -0.014 per 10° absolute latitude (95% CI -0.024 to -0.004).

Complementary chest evidence retained the broad geographic pattern; among the same participants and participant-days at both sensor positions, 26 of 30 matched photoperiod or latitude estimates lay on the same side of the null, although 5 FDR-adjusted support decisions differed; relative to the primary dataset, which for this contrast can be interpreted as a time-sensitive primary metric dataset, the gap-timing-unaware near-eye sensitivity still passed the general 50%-per-hour and 80%-per-day coverage rules but did not use the timing of remaining missing observations for metric-specific adjustment; it changed 7 of 68 metric-question decisions.

What was analysed

The response package contains 17 metrics spanning temporal dynamics, level, duration, exposure history, spectrum, and timing. A participant-day is one participant contributing one retained daily record on one local calendar day under the H01 day definition. Participant-day outcomes use a participant random intercept: this random effect represents remaining between-participant variation after the reported predictors are considered. Participant-level dynamics outcomes use ordinary Gaussian models because each participant contributes one analytical observation. Each response uses its declared Gaussian transformation or Tweedie log-link family; the same implementation is used for both data scenarios.

MDER is calculated as the arithmetic mean of viable one-minute melEDI-to-illuminance ratios. A minute is viable when both channels are finite and strictly positive, and a day contributes MDER when at least 720 viable minute ratios are available.

The all-available sample uses all retained metric-specific observations at one sensor position. Across these outcomes, the primary near-eye samples used 137–141 participants and 655–816 participant-days across 9 sites. The complementary chest samples used 152–154 participants and 732–902 participant-days across 8 sites. A matched sample restricts each metric to the same participants and participant-days at the near-eye and chest positions; the positions are still fitted separately. Exact metric-specific samples are retained in the detailed analysis record.

After the first explanation above, the time-sensitive dataset is called the primary dataset and the alternative sensitivity is called the gap-timing-unaware dataset. The complete preregistration deviations are retained later in this report.

Metric derivation is documented in Preparation 04, and the model-ready rows and sample construction are documented in Preparation 06.

Statistical models

Site, photoperiod, latitude, and site-versus-linear-latitude adequacy are four separate model-level questions. Their raw p-values are adjusted as four separate complete 17-test FDR families. A site-specific follow-up is made only when the corresponding overall site test has a FDR-adjusted p < 0.050. That follow-up compares each site with the site-average estimate, an average across sites that gives each site equal weight, reports a difference or ratio with a 95% CI, and applies a within-metric FDR adjustment.

Gaussian fixed-effect comparisons use maximum likelihood; final Gaussian participant-day fits use restricted maximum likelihood. Tweedie models use a log link and maximum likelihood throughout. Gaussian responses use their declared identity, log10(value + 0.1), logit, or shifted linear-clock transformation. A back-transformed estimate returns a model-scale estimate to its displayed original or practical unit. Exact evaluated formula objects, engines, and estimation methods are retained in the technical reproducibility section.

Results overview

In the primary near-eye analysis, the four complete correction families supported 10 metrics for overall site, 12 for photoperiod, 7 for latitude, and 9 for site-versus-linear-latitude adequacy. The complementary chest analysis supported 13, 11, 9, and 11 metrics, respectively. This overview uses each placement’s all-available sample, so it describes where each analysis retained support but does not by itself isolate a placement difference.

Two-panel matrix for near-eye and chest results. Rows are the 17 personal-light-exposure metrics and columns are overall site, photoperiod, latitude, and site-versus-linear-latitude adequacy. FDR adjustment was applied separately across each complete 17-test family. Blue cells with check marks indicate FDR-adjusted p below 0.050; grey cells with dashes indicate FDR-adjusted p at least 0.050, so colour is not the only cue.
Figure 1: Support after FDR adjustment across four separate complete 17-test families for the primary near-eye and complementary chest analyses: overall site, photoperiod, latitude, and site-versus-linear-latitude adequacy. A check mark denotes FDR-adjusted p below 0.050 and a dash denotes FDR-adjusted p at least 0.050; symbols accompany colour throughout.

Source data for Figure 1

Primary near-eye summary

primary_metric_synthesis_gt(primary_metric_synthesis_display())
Table 1: Primary near-eye metric synthesis: definitions, overall distributions, site, photoperiod and latitude associations, variation represented, and exact fitted samples.
Descriptive summary
Primary near-eye associations
Modelled variation Exact fitted sample
Overall value Distribution Overall site Photoperiod Latitude
Dynamics
Interdaily stability
Day-to-day regularity of the light–dark pattern; higher regularity supports circadian stability.
0.308 [0.248–0.38] dimensionless
nparticipants = 141; nparticipant-days = 816
Not supported
FDR-adjusted p = 0.228
Part R²: 6.9% [4.1–20.4] (not FDR-supported)
Not supported
Effect per 1 h: ×0.98 [0.94–1.02]
FDR-adjusted p = 0.401
Part R²: 0.4% [0.0–4.6] (not FDR-supported)
Not supported
Effect per 10°: ×1.01 [0.96–1.06]
FDR-adjusted p = 0.903
Part R²: 0.1% [0.0–3.5] (not FDR-supported)
Fixed effects (marginal R²): 15.4% [10.4–30.8]
Full model (conditional R²): 15.4% [10.4–30.8]
Participant random-intercept share: Not applicable
nparticipants = 141
nparticipant-days = 816
nobservations = 141
nsites = 9
Intradaily variability
Within-day fragmentation of light exposure; higher values indicate less consolidated light–dark input.
1.253 [0.93–1.502] dimensionless
nparticipants = 141; nparticipant-days = 816
Not supported
FDR-adjusted p = 0.483
Part R²: 5.1% [3.8–18.7] (not FDR-supported)
Not supported
Effect per 1 h: -0.02 [-0.06–0.02]
FDR-adjusted p = 0.327
Part R²: 0.7% [0.0–5.7] (not FDR-supported)
Not supported
Effect per 10°: -0.04 [-0.08–0.01]
FDR-adjusted p = 0.139
Part R²: 2.1% [0.0–8.9] (not FDR-supported)
Fixed effects (marginal R²): 5.9% [4.6–21.2]
Full model (conditional R²): 5.9% [4.6–21.2]
Participant random-intercept share: Not applicable
nparticipants = 141
nparticipant-days = 816
nobservations = 141
nsites = 9
Level
Mean melEDI
Geometric average of daily melEDI values, including zeros; summarizes overall exposure while reducing peak influence.
5.154 [2.831–9.225] lx
nparticipants = 141; nparticipant-days = 816
Supported
FDR-adjusted p <0.001
Part R²: 8.4% [4.8–16.6]
Supported
Effect per 1 h: ×1.18 [1.11–1.26]
FDR-adjusted p <0.001
Part R²: 7.7% [2.9–13.9]
Supported
Effect per 10°: ×1.15 [1.07–1.25]
FDR-adjusted p = 0.002
Part R²: 3.6% [0.8–8.2]
Fixed effects (marginal R²): 25.4% [18.6–34.4]
Full model (conditional R²): 57.7% [51.9–65.1]
Participant random-intercept share: 32.3% [24.5–39.4]
nparticipants = 141
nparticipant-days = 816
nobservations = 816
nsites = 9
Brightest 10 h mean
Mean of the brightest 10 hours; reflects the strength of the main daytime light episode.
110.566 [41.513–243.212] lx
nparticipants = 141; nparticipant-days = 816
Supported
FDR-adjusted p = 0.011
Part R²: 5.7% [3.3–12.7]
Supported
Effect per 1 h: ×1.24 [1.13–1.36]
FDR-adjusted p <0.001
Part R²: 5.8% [1.9–11.1]
Supported
Effect per 10°: ×1.24 [1.11–1.39]
FDR-adjusted p = 0.001
Part R²: 3.6% [0.8–8.1]
Fixed effects (marginal R²): 17.6% [12.1–26.0]
Full model (conditional R²): 46.0% [39.6–54.3]
Participant random-intercept share: 28.4% [20.9–35.4]
nparticipants = 141
nparticipant-days = 816
nobservations = 816
nsites = 9
Darkest 10 h mean
Mean of the darkest 10 hours; lower values during the biological night favour melatonin preservation and sleep.
0.103 [0.02–0.253] lx
nparticipants = 141; nparticipant-days = 816
Supported
FDR-adjusted p <0.001
Part R²: 13.7% [9.2–23.9]
Supported
Effect per 1 h: ×1.09 [1.03–1.14]
FDR-adjusted p = 0.002
Part R²: 3.4% [0.5–8.3]
Not supported
Effect per 10°: ×1.04 [0.97–1.11]
FDR-adjusted p = 0.400
Part R²: 0.5% [0.0–3.2] (not FDR-supported)
Fixed effects (marginal R²): 22.0% [15.8–31.8]
Full model (conditional R²): 61.1% [55.3–68.1]
Participant random-intercept share: 39.1% [30.4–46.2]
nparticipants = 141
nparticipant-days = 816
nobservations = 816
nsites = 9
Duration
Time above 1,000 lx melEDI
Bright-light exposure duration; relevant to daytime alerting and circadian entrainment.
00:41 [00:11–01:34] HH:MM
nparticipants = 141; nparticipant-days = 816
Not supported
FDR-adjusted p = 0.071
Part R²: 4.7% [2.6–12.1] (not FDR-supported)
Supported
Effect per 1 h: ×1.21 [1.13–1.29]
FDR-adjusted p <0.001
Part R²: 9.0% [3.7–15.7]
Not supported
Effect per 10°: ×1.03 [0.94–1.12]
FDR-adjusted p = 0.769
Part R²: 0.0% [-0.0–2.0] (not FDR-supported)
Fixed effects (marginal R²): 21.5% [16.3–32.3]
Full model (conditional R²): 50.1% [40.2–57.6]
Participant random-intercept share: 28.6% [17.7–34.2]
nparticipants = 141
nparticipant-days = 816
nobservations = 816
nsites = 9
Time above 250 lx melEDI during wake
Waking time in recommended daytime light; relevant to alertness, entrainment, and subsequent sleep.
02:25 [00:51–04:37] HH:MM
nparticipants = 141; nparticipant-days = 737
Supported
FDR-adjusted p = 0.007
Part R²: 8.6% [5.2–18.7]
Supported
Effect per 1 h: ×1.14 [1.07–1.20]
FDR-adjusted p <0.001
Part R²: 6.4% [2.0–12.7]
Supported
Effect per 10°: ×1.12 [1.04–1.20]
FDR-adjusted p = 0.013
Part R²: 3.1% [0.3–8.3]
Fixed effects (marginal R²): 18.0% [12.9–29.1]
Full model (conditional R²): 50.1% [41.6–58.0]
Participant random-intercept share: 32.1% [20.7–36.7]
nparticipants = 141
nparticipant-days = 737
nobservations = 737
nsites = 9
Time below 10 lx melEDI before sleep
Low-light time before bed; limits evening melatonin suppression and circadian delay.
01:53 [01:02–02:35] HH:MM
nparticipants = 139; nparticipant-days = 655
Supported
FDR-adjusted p = 0.049
Part R²: 5.4% [3.2–13.4]
Supported
Effect per 1 h: -0.13 [-0.20–-0.07] h
FDR-adjusted p <0.001
Part R²: 4.9% [1.2–10.5]
Not supported
Effect per 10°: 0.00 [-0.09–0.09] h
FDR-adjusted p = 0.962
Part R²: 0.0% [0.0–1.7] (not FDR-supported)
Fixed effects (marginal R²): 7.6% [4.6–17.2]
Full model (conditional R²): 38.3% [31.8–48.5]
Participant random-intercept share: 30.7% [21.7–38.1]
nparticipants = 139
nparticipant-days = 655
nobservations = 655
nsites = 9
Time below 1 lx melEDI during sleep
Darkness during sleep; supports nocturnal melatonin and an undisturbed sleep environment.
07:08 [05:58–08:15] HH:MM
nparticipants = 141; nparticipant-days = 778
Not supported
FDR-adjusted p = 0.058
Part R²: 5.4% [3.2–14.2] (not FDR-supported)
Not supported
Effect per 1 h: ×0.99 [0.97–1.01]
FDR-adjusted p = 0.227
Part R²: 0.6% [0.0–3.8] (not FDR-supported)
Not supported
Effect per 10°: ×1.00 [0.97–1.02]
FDR-adjusted p = 0.903
Part R²: 0.0% [0.0–1.6] (not FDR-supported)
Fixed effects (marginal R²): 8.0% [5.1–18.2]
Full model (conditional R²): 40.9% [32.8–48.5]
Participant random-intercept share: 32.9% [22.0–37.8]
nparticipants = 141
nparticipant-days = 778
nobservations = 778
nsites = 9
Longest continuous period above 250 lx melEDI
Longest sustained bright-light bout; captures continuity of daytime circadian stimulation.
00:38 [00:17–01:12] HH:MM
nparticipants = 141; nparticipant-days = 816
Not supported
FDR-adjusted p = 0.398
Part R²: 2.2% [1.4–8.1] (not FDR-supported)
Supported
Effect per 1 h: ×1.12 [1.07–1.19]
FDR-adjusted p <0.001
Part R²: 4.9% [1.3–9.6]
Not supported
Effect per 10°: ×1.06 [0.99–1.13]
FDR-adjusted p = 0.139
Part R²: 0.8% [0.0–3.4] (not FDR-supported)
Fixed effects (marginal R²): 10.0% [6.1–17.9]
Full model (conditional R²): 36.3% [29.9–45.3]
Participant random-intercept share: 26.3% [18.7–33.0]
nparticipants = 141
nparticipant-days = 816
nobservations = 816
nsites = 9
Timing
Midpoint of the brightest 10 hours
Centre time of the brightest 10 hours; indexes the main daily circadian light cue.
13:44 [12:48–15:00] HH:MM clock time
nparticipants = 141; nparticipant-days = 816
Supported
FDR-adjusted p <0.001
Part R²: 6.5% [4.4–12.6]
Not supported
Effect per 1 h: 0.05 [-0.05–0.15] h
FDR-adjusted p = 0.305
Part R²: 0.2% [0.0–1.8] (not FDR-supported)
Supported
Effect per 10°: 0.19 [0.06–0.32] h
FDR-adjusted p = 0.013
Part R²: 1.9% [0.2–5.0]
Fixed effects (marginal R²): 6.7% [4.6–13.0]
Full model (conditional R²): 22.9% [16.9–31.6]
Participant random-intercept share: 16.2% [9.6–22.4]
nparticipants = 141
nparticipant-days = 816
nobservations = 816
nsites = 9
Midpoint of the darkest 10 hours
Centre time of the darkest 10 hours; indexes the main daily darkness cue.
02:54 [02:01–03:50] HH:MM clock time
nparticipants = 141; nparticipant-days = 816
Supported
FDR-adjusted p = 0.020
Part R²: 4.6% [3.0–10.5]
Supported
Effect per 1 h: -0.15 [-0.25–-0.05] h
FDR-adjusted p = 0.004
Part R²: 2.1% [0.3–5.6]
Not supported
Effect per 10°: 0.12 [-0.00–0.24] h
FDR-adjusted p = 0.114
Part R²: 0.9% [0.0–3.4] (not FDR-supported)
Fixed effects (marginal R²): 8.6% [5.9–15.8]
Full model (conditional R²): 28.2% [22.1–36.7]
Participant random-intercept share: 19.6% [12.6–25.8]
nparticipants = 141
nparticipant-days = 816
nobservations = 816
nsites = 9
Mean timing of exposure above 250 lx melEDI
Average bright-light timing; summarizes the phase of daily circadian stimulation.
13:29 [12:29–14:39] HH:MM clock time
nparticipants = 141; nparticipant-days = 742
Supported
FDR-adjusted p <0.001
Part R²: 12.7% [9.4–19.2]
Supported
Effect per 1 h: 0.12 [0.03–0.21] h
FDR-adjusted p = 0.013
Part R²: 1.2% [0.1–3.6]
Supported
Effect per 10°: 0.33 [0.21–0.46] h
FDR-adjusted p <0.001
Part R²: 6.0% [2.7–9.7]
Fixed effects (marginal R²): 14.7% [10.7–21.9]
Full model (conditional R²): 25.7% [19.8–34.2]
Participant random-intercept share: 11.0% [5.3–17.2]
nparticipants = 141
nparticipant-days = 742
nobservations = 742
nsites = 9
First light timing above 250 lx melEDI
First waking bright-light exposure; morning timing can advance circadian phase and promote alertness.
09:08 [08:02–10:40] HH:MM clock time
nparticipants = 140; nparticipant-days = 727
Not supported
FDR-adjusted p = 0.112
Part R²: 3.8% [2.3–10.2] (not FDR-supported)
Not supported
Effect per 1 h: -0.10 [-0.25–0.05] h
FDR-adjusted p = 0.227
Part R²: 0.5% [0.0–2.8] (not FDR-supported)
Not supported
Effect per 10°: 0.02 [-0.17–0.21] h
FDR-adjusted p = 0.903
Part R²: 0.0% [-0.0–1.3] (not FDR-supported)
Fixed effects (marginal R²): 6.8% [4.4–14.4]
Full model (conditional R²): 31.0% [24.6–40.5]
Participant random-intercept share: 24.2% [16.2–31.5]
nparticipants = 140
nparticipant-days = 727
nobservations = 727
nsites = 9
Last light timing above 250 lx melEDI
Last bright-light exposure; later timing may delay circadian phase and sleep onset.
18:08 [16:27–19:42] HH:MM clock time
nparticipants = 141; nparticipant-days = 687
Supported
FDR-adjusted p <0.001
Part R²: 11.6% [8.3–18.6]
Supported
Effect per 1 h: 0.32 [0.19–0.46] h
FDR-adjusted p <0.001
Part R²: 4.5% [1.5–8.5]
Supported
Effect per 10°: 0.46 [0.27–0.64] h
FDR-adjusted p <0.001
Part R²: 5.7% [2.2–9.9]
Fixed effects (marginal R²): 22.2% [17.0–30.2]
Full model (conditional R²): 37.9% [31.6–46.3]
Participant random-intercept share: 15.8% [9.7–22.3]
nparticipants = 141
nparticipant-days = 687
nobservations = 687
nsites = 9
Exposure history
melEDI dose
Intensity–duration-weighted melanopic exposure; summarizes cumulative non-visual retinal light input.
4.96 [1.936–12.313] klx·h
nparticipants = 141; nparticipant-days = 761
Not supported
FDR-adjusted p = 0.209
Part R²: 2.8% [1.7–8.6] (not FDR-supported)
Supported
Effect per 1 h: ×1.28 [1.17–1.39]
FDR-adjusted p <0.001
Part R²: 7.5% [3.1–12.9]
Not supported
Effect per 10°: ×1.02 [0.91–1.13]
FDR-adjusted p = 0.903
Part R²: 0.0% [-0.0–1.2] (not FDR-supported)
Fixed effects (marginal R²): 11.8% [7.7–19.7]
Full model (conditional R²): 33.8% [26.7–42.8]
Participant random-intercept share: 22.0% [14.5–28.7]
nparticipants = 141
nparticipant-days = 761
nobservations = 761
nsites = 9
Spectrum
Melanopic daylight efficacy ratio
Mean of viable one-minute melEDI/illuminance ratios; indicates melanopic efficacy relative to visual light.
0.724 [0.643–0.795] dimensionless
nparticipants = 137; nparticipant-days = 687
Supported
FDR-adjusted p <0.001
Part R²: 9.6% [6.0–17.8]
Supported
Effect per 1 h: 0.02 [0.02–0.03]
FDR-adjusted p <0.001
Part R²: 13.0% [6.7–20.6]
Supported
Effect per 10°: -0.01 [-0.02–-0.00]
FDR-adjusted p = 0.013
Part R²: 2.7% [0.3–6.9]
Fixed effects (marginal R²): 27.8% [21.3–38.1]
Full model (conditional R²): 60.5% [54.6–68.2]
Participant random-intercept share: 32.7% [24.1–40.0]
nparticipants = 137
nparticipant-days = 702
nobservations = 702
nsites = 9
Overall values are medians [interquartile ranges] from the prepared near-eye descriptive dataset; their participant and participant-day counts are therefore shown separately from each model’s exact fitted sample. The MDER uses the mean of viable minute-level ratios in both the descriptive summary and geographic-association model cells. Participant-days equal observations for the 15 participant-day models. The two participant-level dynamics models use one observation per participant, with participant-days giving the contributing repeated-day support. All primary models use nine sites. Bold FDR-adjusted p-values meet the p < 0.050 rule within the explicitly labelled complete 17-test family. Grey part R² values are shown for context but their corresponding term was not FDR-supported. Site, photoperiod, and latitude part R² values can overlap and must not be summed. Marginal R² represents fixed effects, conditional R² represents the full model, and the participant random-intercept share is conditional minus marginal R². Its 95% CIs and all other R² CIs use 1,000 successful joint bootstrap refits. Density thumbnails use metric-specific display scales and preserve the registered site order and colours; display transformations do not alter source values.

Source data for Table 1 and metric-level distribution source

The compact synthesis above is followed by a wider inferential summary that retains the four complete 17-test family decisions and their exact fitted samples without the descriptive context.

primary_publication_gt(primary_publication_display())
Table 2: Primary near-eye site, photoperiod, latitude, and site-versus-latitude results.
Overall site FDR-adjusted p
Photoperiod
Latitude
Site-versus-latitude FDR-adjusted p Exact fitted sample
Photoperiod association (95% CI) Photoperiod FDR-adjusted p Latitude association per 10° (95% CI) Latitude FDR-adjusted p
Dynamics
Interdaily stability 0.228 ×0.98 [0.94–1.02] 0.401 ×1.01 [0.96–1.06] 0.903 0.175 nparticipants = 141; nparticipant-days = 816
Intradaily variability 0.483 -0.02 [-0.06–0.02] 0.327 -0.04 [-0.08–0.01] 0.139 0.723 nparticipants = 141; nparticipant-days = 816
Level
Mean melEDI <0.001 ×1.18 [1.11–1.26] <0.001 ×1.15 [1.07–1.25] 0.002 0.032 nparticipants = 141; nparticipant-days = 816
Brightest 10 h mean 0.011 ×1.24 [1.13–1.36] <0.001 ×1.24 [1.11–1.39] 0.001 0.325 nparticipants = 141; nparticipant-days = 816
Darkest 10 h mean <0.001 ×1.09 [1.03–1.14] 0.002 ×1.04 [0.97–1.11] 0.400 <0.001 nparticipants = 141; nparticipant-days = 816
Duration
Time above 1,000 lx melEDI 0.071 ×1.21 [1.13–1.29] <0.001 ×1.03 [0.94–1.12] 0.769 0.053 nparticipants = 141; nparticipant-days = 816
Time above 250 lx melEDI during wake 0.007 ×1.14 [1.07–1.20] <0.001 ×1.12 [1.04–1.20] 0.013 0.053 nparticipants = 141; nparticipant-days = 737
Time below 10 lx melEDI before sleep 0.049 -0.13 [-0.20–-0.07] h <0.001 0.00 [-0.09–0.09] h 0.962 0.040 nparticipants = 139; nparticipant-days = 655
Time below 1 lx melEDI during sleep 0.058 ×0.99 [0.97–1.01] 0.227 ×1.00 [0.97–1.02] 0.903 0.043 nparticipants = 141; nparticipant-days = 778
Longest continuous period above 250 lx melEDI 0.398 ×1.12 [1.07–1.19] <0.001 ×1.06 [0.99–1.13] 0.139 0.626 nparticipants = 141; nparticipant-days = 816
Timing
Midpoint of the brightest 10 hours <0.001 0.05 [-0.05–0.15] h 0.305 0.19 [0.06–0.32] h 0.013 0.010 nparticipants = 141; nparticipant-days = 816
Midpoint of the darkest 10 hours 0.020 -0.15 [-0.25–-0.05] h 0.004 0.12 [-0.00–0.24] h 0.114 0.043 nparticipants = 141; nparticipant-days = 816
Mean timing of exposure above 250 lx melEDI <0.001 0.12 [0.03–0.21] h 0.013 0.33 [0.21–0.46] h <0.001 <0.001 nparticipants = 141; nparticipant-days = 742
First light timing above 250 lx melEDI 0.112 -0.10 [-0.25–0.05] h 0.227 0.02 [-0.17–0.21] h 0.903 0.077 nparticipants = 140; nparticipant-days = 727
Last light timing above 250 lx melEDI <0.001 0.32 [0.19–0.46] h <0.001 0.46 [0.27–0.64] h <0.001 <0.001 nparticipants = 141; nparticipant-days = 687
Exposure history
melEDI dose 0.209 ×1.28 [1.17–1.39] <0.001 ×1.02 [0.91–1.13] 0.903 0.156 nparticipants = 141; nparticipant-days = 761
Spectrum
Melanopic daylight efficacy ratio <0.001 0.02 [0.02–0.03] <0.001 -0.01 [-0.02–-0.00] 0.013 0.010 nparticipants = 137; nparticipant-days = 702
This summary combines the model-level decisions, photoperiod and latitude associations, 95% CIs, and exact fitted samples. Each displayed p-value is FDR-adjusted within its explicitly labelled complete 17-test family; bold values meet the FDR-adjusted p < 0.050 rule. Full raw p-values and test statistics remain in the detailed tables below. Exact fitted samples use italic n with participant and participant-day subscripts. For the 15 participant-day models, the participant-day count equals the number of fitted observations. The two participant-level dynamics models use one fitted observation per participant and retain the participant-day count as contributing repeated-day support. All models include nine sites.

Primary near-eye results

Model-level tests

model_test_gt(model_test_display(primary_run))
Table 3: Primary near-eye model-level tests with raw and FDR-adjusted p-values.
Overall site
Photoperiod
Latitude
Site versus linear latitude
Statistic (df) Raw p FDR-adjusted p Statistic (df) Raw p FDR-adjusted p Statistic (df) Raw p FDR-adjusted p Statistic (df) Raw p FDR-adjusted p
Dynamics
Interdaily stability 11.01 (df 8) 0.201 0.228 0.70 (df 1) 0.401 0.401 0.14 (df 1) 0.712 0.903 10.87 (df 7) 0.144 0.175
Intradaily variability 7.51 (df 8) 0.483 0.483 1.04 (df 1) 0.308 0.327 3.02 (df 1) 0.082 0.139 4.48 (df 7) 0.723 0.723
Level
Mean melEDI 30.60 (df 8) <0.001 <0.001 26.75 (df 1) <0.001 <0.001 12.41 (df 1) <0.001 0.002 18.19 (df 7) 0.011 0.032
Brightest 10 h mean 21.98 (df 8) 0.005 0.011 21.24 (df 1) <0.001 <0.001 13.43 (df 1) <0.001 0.001 8.54 (df 7) 0.287 0.325
Darkest 10 h mean 40.71 (df 8) <0.001 <0.001 10.90 (df 1) <0.001 0.002 1.28 (df 1) 0.259 0.400 39.43 (df 7) <0.001 <0.001
Duration
Time above 1,000 lx melEDI 15.51 (df 8) 0.050 0.071 27.41 (df 1) <0.001 <0.001 0.37 (df 1) 0.543 0.769 15.14 (df 7) 0.034 0.053
Time above 250 lx melEDI during wake 23.25 (df 8) 0.003 0.007 17.73 (df 1) <0.001 <0.001 7.96 (df 1) 0.005 0.013 15.30 (df 7) 0.032 0.053
Time below 10 lx melEDI before sleep 17.14 (df 8) 0.029 0.049 15.41 (df 1) <0.001 <0.001 0.00 (df 1) 0.962 0.962 17.14 (df 7) 0.017 0.040
Time below 1 lx melEDI during sleep 16.38 (df 8) 0.037 0.058 1.85 (df 1) 0.174 0.227 0.04 (df 1) 0.850 0.903 16.34 (df 7) 0.022 0.043
Longest continuous period above 250 lx melEDI 8.63 (df 8) 0.375 0.398 18.14 (df 1) <0.001 <0.001 3.04 (df 1) 0.081 0.139 5.59 (df 7) 0.589 0.626
Timing
Midpoint of the brightest 10 hours 30.01 (df 8) <0.001 <0.001 1.22 (df 1) 0.269 0.305 8.33 (df 1) 0.004 0.013 21.68 (df 7) 0.003 0.010
Midpoint of the darkest 10 hours 19.96 (df 8) 0.010 0.020 9.21 (df 1) 0.002 0.004 3.72 (df 1) 0.054 0.114 16.24 (df 7) 0.023 0.043
Mean timing of exposure above 250 lx melEDI 62.56 (df 8) <0.001 <0.001 6.86 (df 1) 0.009 0.013 26.07 (df 1) <0.001 <0.001 36.49 (df 7) <0.001 <0.001
First light timing above 250 lx melEDI 13.86 (df 8) 0.086 0.112 1.74 (df 1) 0.187 0.227 0.04 (df 1) 0.834 0.903 13.81 (df 7) 0.055 0.077
Last light timing above 250 lx melEDI 51.99 (df 8) <0.001 <0.001 21.37 (df 1) <0.001 <0.001 23.01 (df 1) <0.001 <0.001 28.98 (df 7) <0.001 <0.001
Exposure history
melEDI dose 11.56 (df 8) 0.172 0.209 29.24 (df 1) <0.001 <0.001 0.08 (df 1) 0.771 0.903 11.48 (df 7) 0.119 0.156
Spectrum
Melanopic daylight efficacy ratio 29.86 (df 8) <0.001 <0.001 39.50 (df 1) <0.001 <0.001 7.82 (df 1) 0.005 0.013 22.03 (df 7) 0.003 0.010
Bold FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 decision rule in their separate 17-test family. Raw p-values have no separate decision rule here and are not bold.

Photoperiod and latitude effects

effect_gt(effect_display(primary_run))
Table 4: Primary near-eye photoperiod and latitude effects, 95% CIs, raw and FDR-adjusted p-values, and exact fitted samples.
Photoperiod
Latitude
Exact fitted sample
Effect (95% CI) Raw p FDR-adjusted p Effect per 10° (95% CI) Raw p FDR-adjusted p Participants Participant-days Observations Sites
Dynamics
Interdaily stability ×0.98 [0.94–1.02] 0.418 0.401 ×1.01 [0.96–1.06] 0.715 0.903 141 816 141 9
Intradaily variability -0.02 [-0.06–0.02] 0.325 0.327 -0.04 [-0.08–0.01] 0.084 0.139 141 816 141 9
Level
Mean melEDI ×1.18 [1.11–1.26] <0.001 <0.001 ×1.15 [1.07–1.25] <0.001 0.002 141 816 816 9
Brightest 10 h mean ×1.24 [1.13–1.36] <0.001 <0.001 ×1.24 [1.11–1.39] <0.001 0.001 141 816 816 9
Darkest 10 h mean ×1.09 [1.03–1.14] 0.001 0.002 ×1.04 [0.97–1.11] 0.263 0.400 141 816 816 9
Duration
Time above 1,000 lx melEDI ×1.21 [1.13–1.29] <0.001 <0.001 ×1.03 [0.94–1.12] 0.542 0.769 141 816 816 9
Time above 250 lx melEDI during wake ×1.14 [1.07–1.20] <0.001 <0.001 ×1.12 [1.04–1.20] 0.004 0.013 141 737 737 9
Time below 10 lx melEDI before sleep -0.13 [-0.20–-0.07] h <0.001 <0.001 0.00 [-0.09–0.09] h 0.963 0.962 139 655 655 9
Time below 1 lx melEDI during sleep ×0.99 [0.97–1.01] 0.173 0.227 ×1.00 [0.97–1.02] 0.850 0.903 141 778 778 9
Longest continuous period above 250 lx melEDI ×1.12 [1.07–1.19] <0.001 <0.001 ×1.06 [0.99–1.13] 0.083 0.139 141 816 816 9
Timing
Midpoint of the brightest 10 hours 0.05 [-0.05–0.15] h 0.286 0.305 0.19 [0.06–0.32] h 0.004 0.013 141 816 816 9
Midpoint of the darkest 10 hours -0.15 [-0.25–-0.05] h 0.003 0.004 0.12 [-0.00–0.24] h 0.055 0.114 141 816 816 9
Mean timing of exposure above 250 lx melEDI 0.12 [0.03–0.21] h 0.010 0.013 0.33 [0.21–0.46] h <0.001 <0.001 141 742 742 9
First light timing above 250 lx melEDI -0.10 [-0.25–0.05] h 0.206 0.227 0.02 [-0.17–0.21] h 0.835 0.903 140 727 727 9
Last light timing above 250 lx melEDI 0.32 [0.19–0.46] h <0.001 <0.001 0.46 [0.27–0.64] h <0.001 <0.001 141 687 687 9
Exposure history
melEDI dose ×1.28 [1.17–1.39] <0.001 <0.001 ×1.02 [0.91–1.13] 0.773 0.903 141 761 761 9
Spectrum
Melanopic daylight efficacy ratio 0.02 [0.02–0.03] <0.001 <0.001 -0.01 [-0.02–-0.00] 0.005 0.013 137 702 702 9
Effects are differences for identity-scale outcomes, ratios for log-link or log-transformed outcomes, and odds ratios for logit-transformed outcomes; timing differences are hours. Bold FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 decision rule in the applicable 17-test family. Raw p-values have no separate decision rule here and are not bold.

Hierarchical site contrasts

Two-panel near-eye forest plot. Panel A groups four ratio metrics and panel B groups six difference metrics. Within every metric facet, the dashed null line is horizontally centred: one for ratios and zero for differences. Each facet displays the nine study sites in registered north-to-south order, with the exact metric-and-site fitted observation count appended to each site label as n. Coloured filled circles with thicker horizontal 95% CIs indicate within-metric FDR-adjusted p below 0.050; coloured open circles with thinner intervals indicate FDR-adjusted p at least 0.050.
Figure 2: Primary near-eye site differences or ratios relative to the site-average estimate, which gives each site equal weight. Only metrics with a supported overall site test are shown. Panel A contains ratios and panel B contains differences. Every metric facet uses a symmetric scale around its null, placing 1 for ratios and 0 for differences at the same horizontal position. Filled circles with thicker 95% CIs denote within-metric FDR-adjusted p below 0.050; open circles with thinner intervals denote FDR-adjusted p at least 0.050. Site labels give exact fitted observations as n.

Source data for Figure 2

site_deviation_matrix_gt(site_deviation_matrix_display(primary_run))
Table 5: Primary near-eye site-specific deviations for metrics with a supported overall site test.
Deviation from the site-average estimate (95% CI)
Borås (SE) Delft (NL) Dortmund (DE) Tübingen (DE) Munich (DE) Madrid (ES) Izmir (TR) San José (CR) Kumasi (GH)
Mean melEDI ×1.03 [0.72–1.47] ×0.96 [0.68–1.34] ×0.92 [0.66–1.28] ×1.46 [1.12–1.92] ×1.00 [0.66–1.51] ×1.00 [0.72–1.39] ×1.41 [1.04–1.92] ×1.10 [0.68–1.77] ×0.49 [0.35–0.68]
Brightest 10 h mean ×1.46 [0.87–2.48] ×1.08 [0.65–1.78] ×0.97 [0.59–1.57] ×1.40 [0.94–2.10] ×0.70 [0.38–1.29] ×1.39 [0.86–2.26] ×1.19 [0.75–1.86] ×0.99 [0.49–2.03] ×0.41 [0.25–0.66]
Darkest 10 h mean ×0.75 [0.56–1.00] ×0.88 [0.67–1.15] ×0.84 [0.65–1.10] ×1.36 [1.09–1.69] ×1.35 [0.96–1.89] ×0.76 [0.58–0.99] ×1.49 [1.16–1.91] ×1.30 [0.88–1.93] ×0.67 [0.51–0.88]
Time above 250 lx melEDI during wake ×1.24 [0.90–1.70] ×1.20 [0.88–1.64] ×0.98 [0.73–1.32] ×1.12 [0.87–1.43] ×0.73 [0.50–1.06] ×1.68 [1.24–2.26] ×0.86 [0.65–1.14] ×0.75 [0.48–1.18] ×0.78 [0.57–1.06]
Time below 10 lx melEDI before sleep 0.45 [0.08–0.83] h 0.30 [-0.09–0.69] h 0.13 [-0.22–0.49] h -0.23 [-0.51–0.06] h 0.27 [-0.17–0.71] h -0.47 [-0.82–-0.12] h -0.45 [-0.78–-0.12] h -0.10 [-0.62–0.42] h 0.09 [-0.29–0.46] h
Midpoint of the brightest 10 hours -0.58 [-1.15–-0.02] h 0.38 [-0.16–0.91] h 0.04 [-0.48–0.57] h 0.13 [-0.30–0.56] h 0.48 [-0.17–1.14] h 0.51 [-0.01–1.03] h 0.66 [0.17–1.14] h -0.84 [-1.61–-0.07] h -0.78 [-1.31–-0.24] h
Midpoint of the darkest 10 hours -0.55 [-1.11–0.00] h 0.31 [-0.21–0.84] h 0.12 [-0.40–0.63] h 0.10 [-0.33–0.52] h 0.79 [0.15–1.44] h 0.12 [-0.39–0.63] h 0.35 [-0.12–0.83] h -0.80 [-1.55–-0.04] h -0.45 [-0.97–0.07] h
Mean timing of exposure above 250 lx melEDI -0.29 [-0.81–0.23] h 0.47 [-0.02–0.96] h 0.21 [-0.28–0.70] h 0.25 [-0.15–0.65] h 0.52 [-0.08–1.13] h 0.79 [0.31–1.27] h 0.90 [0.45–1.34] h -1.84 [-2.54–-1.14] h -1.00 [-1.50–-0.50] h
Last light timing above 250 lx melEDI 0.18 [-0.58–0.94] h 0.38 [-0.37–1.13] h 0.23 [-0.49–0.96] h -0.14 [-0.75–0.46] h 0.71 [-0.18–1.61] h 1.32 [0.60–2.04] h 0.89 [0.23–1.55] h -1.91 [-2.96–-0.86] h -1.67 [-2.41–-0.93] h
Melanopic daylight efficacy ratio 0.02 [-0.02–0.06] -0.01 [-0.05–0.03] -0.02 [-0.06–0.01] -0.02 [-0.05–0.01] -0.08 [-0.13–-0.04] 0.02 [-0.02–0.05] 0.03 [-0.01–0.06] -0.01 [-0.06–0.05] 0.08 [0.04–0.13]
Rows are restricted to metrics with an overall-site FDR-adjusted p < 0.050. Cells show the site deviation and 95% CI: an ordinary difference for identity-scale responses and a ratio (×) for transformed or log-link responses. Bold cells meet the within-metric FDR-adjusted p < 0.050 rule. Sites retain the registered north-to-south order.
site_contrast_gt(site_contrast_display(primary_run))
Table 6: Primary near-eye site contrasts after a supported overall site test.
Site Difference or ratio (95% CI) Raw p Within-metric FDR-adjusted p Supported
Mean melEDI
Borås (SE) ×1.03 [0.72–1.47] 0.859 0.986 No
Delft (NL) ×0.96 [0.68–1.34] 0.805 0.986 No
Dortmund (DE) ×0.92 [0.66–1.28] 0.607 0.986 No
Tübingen (DE) ×1.46 [1.12–1.92] 0.006 0.027 Yes
Munich (DE) ×1.00 [0.66–1.51] 0.986 0.986 No
Madrid (ES) ×1.00 [0.72–1.39] 0.985 0.986 No
Izmir (TR) ×1.41 [1.04–1.92] 0.027 0.081 No
San José (CR) ×1.10 [0.68–1.77] 0.709 0.986 No
Kumasi (GH) ×0.49 [0.35–0.68] <0.001 <0.001 Yes
Brightest 10 h mean
Borås (SE) ×1.46 [0.87–2.48] 0.155 0.402 No
Delft (NL) ×1.08 [0.65–1.78] 0.767 0.985 No
Dortmund (DE) ×0.97 [0.59–1.57] 0.894 0.985 No
Tübingen (DE) ×1.40 [0.94–2.10] 0.098 0.402 No
Munich (DE) ×0.70 [0.38–1.29] 0.255 0.460 No
Madrid (ES) ×1.39 [0.86–2.26] 0.179 0.402 No
Izmir (TR) ×1.19 [0.75–1.86] 0.459 0.689 No
San José (CR) ×0.99 [0.49–2.03] 0.985 0.985 No
Kumasi (GH) ×0.41 [0.25–0.66] <0.001 0.003 Yes
Darkest 10 h mean
Borås (SE) ×0.75 [0.56–1.00] 0.052 0.093 No
Delft (NL) ×0.88 [0.67–1.15] 0.348 0.348 No
Dortmund (DE) ×0.84 [0.65–1.10] 0.218 0.245 No
Tübingen (DE) ×1.36 [1.09–1.69] 0.007 0.020 Yes
Munich (DE) ×1.35 [0.96–1.89] 0.083 0.125 No
Madrid (ES) ×0.76 [0.58–0.99] 0.043 0.093 No
Izmir (TR) ×1.49 [1.16–1.91] 0.002 0.016 Yes
San José (CR) ×1.30 [0.88–1.93] 0.186 0.239 No
Kumasi (GH) ×0.67 [0.51–0.88] 0.003 0.016 Yes
Time above 250 lx melEDI during wake
Borås (SE) ×1.24 [0.90–1.70] 0.182 0.358 No
Delft (NL) ×1.20 [0.88–1.64] 0.238 0.358 No
Dortmund (DE) ×0.98 [0.73–1.32] 0.896 0.896 No
Tübingen (DE) ×1.12 [0.87–1.43] 0.373 0.420 No
Munich (DE) ×0.73 [0.50–1.06] 0.095 0.335 No
Madrid (ES) ×1.68 [1.24–2.26] <0.001 0.006 Yes
Izmir (TR) ×0.86 [0.65–1.14] 0.289 0.372 No
San José (CR) ×0.75 [0.48–1.18] 0.212 0.358 No
Kumasi (GH) ×0.78 [0.57–1.06] 0.112 0.335 No
Time below 10 lx melEDI before sleep
Borås (SE) 0.45 [0.08–0.83] h 0.019 0.056 No
Delft (NL) 0.30 [-0.09–0.69] h 0.132 0.237 No
Dortmund (DE) 0.13 [-0.22–0.49] h 0.457 0.587 No
Tübingen (DE) -0.23 [-0.51–0.06] h 0.127 0.237 No
Munich (DE) 0.27 [-0.17–0.71] h 0.229 0.344 No
Madrid (ES) -0.47 [-0.82–-0.12] h 0.009 0.041 Yes
Izmir (TR) -0.45 [-0.78–-0.12] h 0.008 0.041 Yes
San José (CR) -0.10 [-0.62–0.42] h 0.707 0.707 No
Kumasi (GH) 0.09 [-0.29–0.46] h 0.653 0.707 No
Midpoint of the brightest 10 hours
Borås (SE) -0.58 [-1.15–-0.02] h 0.043 0.098 No
Delft (NL) 0.38 [-0.16–0.91] h 0.168 0.216 No
Dortmund (DE) 0.04 [-0.48–0.57] h 0.875 0.875 No
Tübingen (DE) 0.13 [-0.30–0.56] h 0.557 0.627 No
Munich (DE) 0.48 [-0.17–1.14] h 0.149 0.216 No
Madrid (ES) 0.51 [-0.01–1.03] h 0.056 0.101 No
Izmir (TR) 0.66 [0.17–1.14] h 0.008 0.037 Yes
San José (CR) -0.84 [-1.61–-0.07] h 0.033 0.098 No
Kumasi (GH) -0.78 [-1.31–-0.24] h 0.004 0.037 Yes
Midpoint of the darkest 10 hours
Borås (SE) -0.55 [-1.11–0.00] h 0.051 0.154 No
Delft (NL) 0.31 [-0.21–0.84] h 0.244 0.366 No
Dortmund (DE) 0.12 [-0.40–0.63] h 0.655 0.659 No
Tübingen (DE) 0.10 [-0.33–0.52] h 0.659 0.659 No
Munich (DE) 0.79 [0.15–1.44] h 0.016 0.145 No
Madrid (ES) 0.12 [-0.39–0.63] h 0.638 0.659 No
Izmir (TR) 0.35 [-0.12–0.83] h 0.147 0.265 No
San José (CR) -0.80 [-1.55–-0.04] h 0.040 0.154 No
Kumasi (GH) -0.45 [-0.97–0.07] h 0.093 0.208 No
Mean timing of exposure above 250 lx melEDI
Borås (SE) -0.29 [-0.81–0.23] h 0.274 0.308 No
Delft (NL) 0.47 [-0.02–0.96] h 0.061 0.110 No
Dortmund (DE) 0.21 [-0.28–0.70] h 0.408 0.408 No
Tübingen (DE) 0.25 [-0.15–0.65] h 0.224 0.288 No
Munich (DE) 0.52 [-0.08–1.13] h 0.092 0.138 No
Madrid (ES) 0.79 [0.31–1.27] h 0.001 0.003 Yes
Izmir (TR) 0.90 [0.45–1.34] h <0.001 <0.001 Yes
San José (CR) -1.84 [-2.54–-1.14] h <0.001 <0.001 Yes
Kumasi (GH) -1.00 [-1.50–-0.50] h <0.001 <0.001 Yes
Last light timing above 250 lx melEDI
Borås (SE) 0.18 [-0.58–0.94] h 0.639 0.645 No
Delft (NL) 0.38 [-0.37–1.13] h 0.317 0.475 No
Dortmund (DE) 0.23 [-0.49–0.96] h 0.526 0.645 No
Tübingen (DE) -0.14 [-0.75–0.46] h 0.645 0.645 No
Munich (DE) 0.71 [-0.18–1.61] h 0.119 0.214 No
Madrid (ES) 1.32 [0.60–2.04] h <0.001 0.001 Yes
Izmir (TR) 0.89 [0.23–1.55] h 0.008 0.018 Yes
San José (CR) -1.91 [-2.96–-0.86] h <0.001 0.001 Yes
Kumasi (GH) -1.67 [-2.41–-0.93] h <0.001 <0.001 Yes
Melanopic daylight efficacy ratio
Borås (SE) 0.02 [-0.02–0.06] 0.370 0.539 No
Delft (NL) -0.01 [-0.05–0.03] 0.649 0.730 No
Dortmund (DE) -0.02 [-0.06–0.01] 0.211 0.379 No
Tübingen (DE) -0.02 [-0.05–0.01] 0.155 0.348 No
Munich (DE) -0.08 [-0.13–-0.04] <0.001 0.002 Yes
Madrid (ES) 0.02 [-0.02–0.05] 0.419 0.539 No
Izmir (TR) 0.03 [-0.01–0.06] 0.103 0.310 No
San José (CR) -0.01 [-0.06–0.05] 0.778 0.778 No
Kumasi (GH) 0.08 [0.04–0.13] <0.001 0.001 Yes
Bold within-metric FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 decision rule. Raw p-values have no separate decision rule here and are not bold.

Variation represented

Marginal R² is the share of variation represented by the complete set of fixed predictors. Conditional R² adds the participant random effect, and the participant-associated share is conditional minus marginal R² for participant-day models. A term-specific part-R² records the reduction in R² when that term is removed from its model. Site and photoperiod part-R² values can contain overlapping information and must not be summed; latitude part-R² belongs to the separate latitude model.

r2_gt(r2_display(primary_run))
Table 7: Primary near-eye R² and part-R² summaries with 95% joint-bootstrap intervals.
Complete model
Variance partition
Non-additive term summaries
Model R² Fixed effects R² Participant-associated Not represented Site part R² Photoperiod part R² Latitude part R²
Grand average
Grand average 37.8% 14.7% 26.1% 62.2% 8.7% (10/7) 5.9% (12/5) 3.8% (7/10)
Dynamics
Interdaily stability 15.4% [10.4–30.8] 15.4% [10.4–30.8] Not estimable 84.6% [69.2–89.6] 6.9% [4.1–20.4] 0.4% [0.0–4.6] 0.1% [0.0–3.5]
Intradaily variability 5.9% [4.6–21.2] 5.9% [4.6–21.2] Not estimable 94.1% [78.8–95.4] 5.1% [3.8–18.7] 0.7% [0.0–5.7] 2.1% [0.0–8.9]
Level
Mean melEDI 57.7% [51.9–65.1] 25.4% [18.6–34.4] 32.3% [24.5–39.4] 42.3% [34.9–48.1] 8.4% [4.8–16.6] 7.7% [2.9–13.9] 3.6% [0.8–8.2]
Brightest 10 h mean 46.0% [39.6–54.3] 17.6% [12.1–26.0] 28.4% [20.9–35.4] 54.0% [45.7–60.4] 5.7% [3.3–12.7] 5.8% [1.9–11.1] 3.6% [0.8–8.1]
Darkest 10 h mean 61.1% [55.3–68.1] 22.0% [15.8–31.8] 39.1% [30.4–46.2] 38.9% [31.9–44.7] 13.7% [9.2–23.9] 3.4% [0.5–8.3] 0.5% [0.0–3.2]
Duration
Time above 1,000 lx melEDI 50.1% [40.2–57.6] 21.5% [16.3–32.3] 28.6% [17.7–34.2] 49.9% [42.4–59.8] 4.7% [2.6–12.1] 9.0% [3.7–15.7] 0.0% [-0.0–2.0]
Time above 250 lx melEDI during wake 50.1% [41.6–58.0] 18.0% [12.9–29.1] 32.1% [20.7–36.7] 49.9% [42.0–58.4] 8.6% [5.2–18.7] 6.4% [2.0–12.7] 3.1% [0.3–8.3]
Time below 10 lx melEDI before sleep 38.3% [31.8–48.5] 7.6% [4.6–17.2] 30.7% [21.7–38.1] 61.7% [51.5–68.2] 5.4% [3.2–13.4] 4.9% [1.2–10.5] 0.0% [0.0–1.7]
Time below 1 lx melEDI during sleep 40.9% [32.8–48.5] 8.0% [5.1–18.2] 32.9% [22.0–37.8] 59.1% [51.5–67.2] 5.4% [3.2–14.2] 0.6% [0.0–3.8] 0.0% [0.0–1.6]
Longest continuous period above 250 lx melEDI 36.3% [29.9–45.3] 10.0% [6.1–17.9] 26.3% [18.7–33.0] 63.7% [54.7–70.1] 2.2% [1.4–8.1] 4.9% [1.3–9.6] 0.8% [0.0–3.4]
Timing
Midpoint of the brightest 10 hours 22.9% [16.9–31.6] 6.7% [4.6–13.0] 16.2% [9.6–22.4] 77.1% [68.4–83.1] 6.5% [4.4–12.6] 0.2% [0.0–1.8] 1.9% [0.2–5.0]
Midpoint of the darkest 10 hours 28.2% [22.1–36.7] 8.6% [5.9–15.8] 19.6% [12.6–25.8] 71.8% [63.3–77.9] 4.6% [3.0–10.5] 2.1% [0.3–5.6] 0.9% [0.0–3.4]
Mean timing of exposure above 250 lx melEDI 25.7% [19.8–34.2] 14.7% [10.7–21.9] 11.0% [5.3–17.2] 74.3% [65.8–80.2] 12.7% [9.4–19.2] 1.2% [0.1–3.6] 6.0% [2.7–9.7]
First light timing above 250 lx melEDI 31.0% [24.6–40.5] 6.8% [4.4–14.4] 24.2% [16.2–31.5] 69.0% [59.5–75.4] 3.8% [2.3–10.2] 0.5% [0.0–2.8] 0.0% [-0.0–1.3]
Last light timing above 250 lx melEDI 37.9% [31.6–46.3] 22.2% [17.0–30.2] 15.8% [9.7–22.3] 62.1% [53.7–68.4] 11.6% [8.3–18.6] 4.5% [1.5–8.5] 5.7% [2.2–9.9]
Exposure history
melEDI dose 33.8% [26.7–42.8] 11.8% [7.7–19.7] 22.0% [14.5–28.7] 66.2% [57.2–73.3] 2.8% [1.7–8.6] 7.5% [3.1–12.9] 0.0% [-0.0–1.2]
Spectrum
Melanopic daylight efficacy ratio 60.5% [54.6–68.2] 27.8% [21.3–38.1] 32.7% [24.1–40.0] 39.5% [31.8–45.4] 9.6% [6.0–17.8] 13.0% [6.7–20.6] 2.7% [0.3–6.9]
Metric rows are percentages with 95% percentile intervals from 1,000 successful joint bootstrap refits per target. Grey term cells correspond to model-level FDR-adjusted p ≥ 0.050. Grand-average term values are descriptive means over supported metrics only; parentheses give supported/unsupported counts. Term summaries are not additive.

Complementary chest results

The chest analysis repeats the same 17-response package and model implementation. It is complementary evidence about light measured at the chest and is not interpreted as ocular exposure.

Model-level tests

model_test_gt(model_test_display(chest_run))
Table 8: Complementary chest model-level tests with raw and FDR-adjusted p-values.
Overall site
Photoperiod
Latitude
Site versus linear latitude
Statistic (df) Raw p FDR-adjusted p Statistic (df) Raw p FDR-adjusted p Statistic (df) Raw p FDR-adjusted p Statistic (df) Raw p FDR-adjusted p
Dynamics
Interdaily stability 2.59 (df 7) 0.920 0.920 0.44 (df 1) 0.509 0.509 0.23 (df 1) 0.630 0.669 2.36 (df 6) 0.884 0.884
Intradaily variability 10.50 (df 7) 0.162 0.179 3.45 (df 1) 0.063 0.083 1.34 (df 1) 0.247 0.349 9.15 (df 6) 0.165 0.187
Level
Mean melEDI 25.30 (df 7) <0.001 0.002 17.25 (df 1) <0.001 <0.001 0.67 (df 1) 0.415 0.503 24.63 (df 6) <0.001 0.001
Brightest 10 h mean 21.94 (df 7) 0.003 0.004 12.99 (df 1) <0.001 <0.001 0.98 (df 1) 0.322 0.421 20.96 (df 6) 0.002 0.004
Darkest 10 h mean 36.84 (df 7) <0.001 <0.001 7.69 (df 1) 0.006 0.012 11.19 (df 1) <0.001 0.002 25.65 (df 6) <0.001 <0.001
Duration
Time above 1,000 lx melEDI 22.68 (df 7) 0.002 0.003 21.79 (df 1) <0.001 <0.001 5.80 (df 1) 0.016 0.030 16.88 (df 6) 0.010 0.018
Time above 250 lx melEDI during wake 24.52 (df 7) <0.001 0.002 16.48 (df 1) <0.001 <0.001 0.01 (df 1) 0.910 0.910 24.51 (df 6) <0.001 0.001
Time below 10 lx melEDI before sleep 10.37 (df 7) 0.169 0.179 6.62 (df 1) 0.010 0.017 0.29 (df 1) 0.591 0.669 10.08 (df 6) 0.121 0.159
Time below 1 lx melEDI during sleep 25.01 (df 7) <0.001 0.002 2.54 (df 1) 0.111 0.135 10.16 (df 1) 0.001 0.003 14.85 (df 6) 0.021 0.033
Longest continuous period above 250 lx melEDI 11.28 (df 7) 0.127 0.154 13.79 (df 1) <0.001 <0.001 2.10 (df 1) 0.147 0.251 9.18 (df 6) 0.164 0.187
Timing
Midpoint of the brightest 10 hours 63.80 (df 7) <0.001 <0.001 1.03 (df 1) 0.311 0.331 34.78 (df 1) <0.001 <0.001 29.03 (df 6) <0.001 <0.001
Midpoint of the darkest 10 hours 49.33 (df 7) <0.001 <0.001 6.90 (df 1) 0.009 0.016 22.42 (df 1) <0.001 <0.001 26.90 (df 6) <0.001 <0.001
Mean timing of exposure above 250 lx melEDI 108.96 (df 7) <0.001 <0.001 5.73 (df 1) 0.017 0.026 66.70 (df 1) <0.001 <0.001 42.26 (df 6) <0.001 <0.001
First light timing above 250 lx melEDI 60.21 (df 7) <0.001 <0.001 1.77 (df 1) 0.183 0.208 19.83 (df 1) <0.001 <0.001 40.38 (df 6) <0.001 <0.001
Last light timing above 250 lx melEDI 41.73 (df 7) <0.001 <0.001 20.27 (df 1) <0.001 <0.001 30.62 (df 1) <0.001 <0.001 11.11 (df 6) 0.085 0.120
Exposure history
melEDI dose 17.12 (df 7) 0.017 0.022 24.48 (df 1) <0.001 <0.001 1.50 (df 1) 0.220 0.340 15.62 (df 6) 0.016 0.027
Spectrum
Melanopic daylight efficacy ratio 17.45 (df 7) 0.015 0.021 4.30 (df 1) 0.038 0.054 9.19 (df 1) 0.002 0.005 8.25 (df 6) 0.220 0.234
Bold FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 decision rule in their separate 17-test family. Raw p-values have no separate decision rule here and are not bold.

Photoperiod and latitude effects

effect_gt(effect_display(chest_run))
Table 9: Complementary chest photoperiod and latitude effects, 95% CIs, raw and FDR-adjusted p-values, and exact fitted samples.
Photoperiod
Latitude
Exact fitted sample
Effect (95% CI) Raw p FDR-adjusted p Effect per 10° (95% CI) Raw p FDR-adjusted p Participants Participant-days Observations Sites
Dynamics
Interdaily stability ×0.99 [0.95–1.03] 0.521 0.509 ×0.99 [0.95–1.03] 0.633 0.669 153 900 153 8
Intradaily variability -0.03 [-0.07–0.00] 0.070 0.083 0.02 [-0.01–0.06] 0.250 0.349 153 900 153 8
Level
Mean melEDI ×1.14 [1.07–1.21] <0.001 <0.001 ×0.98 [0.92–1.04] 0.419 0.503 154 902 902 8
Brightest 10 h mean ×1.18 [1.08–1.29] <0.001 <0.001 ×1.04 [0.96–1.14] 0.326 0.421 154 902 902 8
Darkest 10 h mean ×1.07 [1.02–1.12] 0.006 0.012 ×0.92 [0.88–0.97] <0.001 0.002 154 902 902 8
Duration
Time above 1,000 lx melEDI ×1.17 [1.10–1.24] <0.001 <0.001 ×0.93 [0.87–0.99] 0.015 0.030 154 902 902 8
Time above 250 lx melEDI during wake ×1.11 [1.06–1.17] <0.001 <0.001 ×1.00 [0.95–1.06] 0.910 0.910 154 818 818 8
Time below 10 lx melEDI before sleep -0.08 [-0.14–-0.02] h 0.012 0.017 0.02 [-0.04–0.07] h 0.595 0.669 153 743 743 8
Time below 1 lx melEDI during sleep ×0.99 [0.97–1.00] 0.110 0.135 ×1.03 [1.01–1.04] 0.001 0.003 154 861 861 8
Longest continuous period above 250 lx melEDI ×1.09 [1.04–1.14] <0.001 <0.001 ×0.97 [0.93–1.01] 0.150 0.251 154 902 902 8
Timing
Midpoint of the brightest 10 hours 0.05 [-0.05–0.14] h 0.326 0.331 0.30 [0.21–0.40] h <0.001 <0.001 154 902 902 8
Midpoint of the darkest 10 hours -0.12 [-0.22–-0.03] h 0.010 0.016 0.23 [0.14–0.32] h <0.001 <0.001 154 902 902 8
Mean timing of exposure above 250 lx melEDI 0.11 [0.02–0.19] h 0.019 0.026 0.43 [0.34–0.52] h <0.001 <0.001 154 831 831 8
First light timing above 250 lx melEDI -0.09 [-0.23–0.05] h 0.198 0.208 0.34 [0.19–0.48] h <0.001 <0.001 154 802 802 8
Last light timing above 250 lx melEDI 0.29 [0.17–0.42] h <0.001 <0.001 0.35 [0.23–0.47] h <0.001 <0.001 154 787 787 8
Exposure history
melEDI dose ×1.23 [1.13–1.33] <0.001 <0.001 ×0.95 [0.88–1.03] 0.224 0.340 154 851 851 8
Spectrum
Melanopic daylight efficacy ratio 0.02 [0.00–0.04] 0.045 0.054 -0.03 [-0.05–-0.01] 0.002 0.005 152 732 732 8
Effects are differences for identity-scale outcomes, ratios for log-link or log-transformed outcomes, and odds ratios for logit-transformed outcomes; timing differences are hours. Bold FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 decision rule in the applicable 17-test family. Raw p-values have no separate decision rule here and are not bold.

Hierarchical site contrasts

Two-panel complementary chest forest plot. Panel A groups seven ratio metrics and panel B groups six difference metrics. Within every metric facet, the dashed null line is horizontally centred: one for ratios and zero for differences. Each facet displays the available study sites in registered north-to-south order, with the exact metric-and-site fitted observation count appended to each site label as n. Coloured filled circles with thicker horizontal 95% CIs indicate within-metric FDR-adjusted p below 0.050; coloured open circles with thinner intervals indicate FDR-adjusted p at least 0.050.
Figure 3: Complementary chest site differences or ratios relative to the site-average estimate, which gives each site equal weight. Only metrics with a supported overall site test are shown. Panel A contains ratios and panel B contains differences. Every metric facet uses a symmetric scale around its null, placing 1 for ratios and 0 for differences at the same horizontal position. Filled circles with thicker 95% CIs denote within-metric FDR-adjusted p below 0.050; open circles with thinner intervals denote FDR-adjusted p at least 0.050. Site labels give exact fitted observations as n.

Source data for Figure 3

site_contrast_gt(site_contrast_display(chest_run))
Table 10: Complementary chest site contrasts after a supported overall site test.
Site Difference or ratio (95% CI) Raw p Within-metric FDR-adjusted p Supported
Mean melEDI
Borås (SE) ×1.10 [0.82–1.49] 0.526 0.682 No
Delft (NL) ×1.15 [0.85–1.55] 0.375 0.599 No
Dortmund (DE) ×0.75 [0.56–1.01] 0.060 0.119 No
Munich (DE) ×0.92 [0.62–1.37] 0.682 0.682 No
Madrid (ES) ×0.92 [0.67–1.27] 0.614 0.682 No
Izmir (TR) ×1.39 [1.03–1.86] 0.029 0.078 No
San José (CR) ×1.39 [1.12–1.72] 0.002 0.020 Yes
Kumasi (GH) ×0.65 [0.47–0.89] 0.007 0.029 Yes
Brightest 10 h mean
Borås (SE) ×1.66 [1.07–2.58] 0.025 0.099 No
Delft (NL) ×1.40 [0.90–2.17] 0.138 0.275 No
Dortmund (DE) ×0.81 [0.52–1.25] 0.337 0.449 No
Munich (DE) ×0.68 [0.38–1.23] 0.202 0.323 No
Madrid (ES) ×1.12 [0.70–1.79] 0.629 0.711 No
Izmir (TR) ×1.08 [0.71–1.67] 0.711 0.711 No
San José (CR) ×1.35 [0.99–1.85] 0.057 0.151 No
Kumasi (GH) ×0.48 [0.30–0.76] 0.002 0.015 Yes
Darkest 10 h mean
Borås (SE) ×0.77 [0.60–0.97] 0.030 0.059 No
Delft (NL) ×0.94 [0.74–1.19] 0.606 0.692 No
Dortmund (DE) ×0.72 [0.57–0.92] 0.007 0.020 Yes
Munich (DE) ×1.13 [0.82–1.56] 0.447 0.596 No
Madrid (ES) ×0.83 [0.64–1.07] 0.144 0.231 No
Izmir (TR) ×1.57 [1.24–1.98] <0.001 0.001 Yes
San José (CR) ×1.32 [1.12–1.57] 0.001 0.005 Yes
Kumasi (GH) ×0.99 [0.77–1.27] 0.930 0.930 No
Time above 1,000 lx melEDI
Borås (SE) ×1.17 [0.86–1.59] 0.309 0.412 No
Delft (NL) ×1.25 [0.93–1.70] 0.141 0.226 No
Dortmund (DE) ×1.01 [0.75–1.37] 0.934 0.934 No
Munich (DE) ×0.59 [0.39–0.89] 0.012 0.048 Yes
Madrid (ES) ×0.86 [0.61–1.20] 0.367 0.419 No
Izmir (TR) ×0.73 [0.54–1.00] 0.050 0.133 No
San José (CR) ×1.37 [1.11–1.71] 0.004 0.034 Yes
Kumasi (GH) ×1.32 [0.95–1.84] 0.092 0.185 No
Time above 250 lx melEDI during wake
Borås (SE) ×1.09 [0.85–1.40] 0.485 0.555 No
Delft (NL) ×1.28 [1.00–1.64] 0.048 0.097 No
Dortmund (DE) ×0.97 [0.76–1.24] 0.821 0.821 No
Munich (DE) ×0.74 [0.54–1.03] 0.075 0.099 No
Madrid (ES) ×1.36 [1.04–1.76] 0.024 0.063 No
Izmir (TR) ×0.75 [0.58–0.96] 0.021 0.063 No
San José (CR) ×1.25 [1.06–1.49] 0.010 0.063 No
Kumasi (GH) ×0.78 [0.60–1.02] 0.066 0.099 No
Time below 1 lx melEDI during sleep
Borås (SE) ×1.07 [0.99–1.15] 0.109 0.174 No
Delft (NL) ×1.02 [0.94–1.11] 0.600 0.600 No
Dortmund (DE) ×1.08 [1.00–1.17] 0.049 0.097 No
Munich (DE) ×0.93 [0.83–1.03] 0.157 0.209 No
Madrid (ES) ×1.10 [1.01–1.20] 0.025 0.077 No
Izmir (TR) ×0.92 [0.85–0.99] 0.029 0.077 No
San José (CR) ×0.94 [0.88–0.99] 0.020 0.077 No
Kumasi (GH) ×0.97 [0.89–1.06] 0.476 0.544 No
Midpoint of the brightest 10 hours
Borås (SE) -0.52 [-0.99–-0.05] h 0.031 0.041 Yes
Delft (NL) 0.54 [0.08–1.01] h 0.023 0.036 Yes
Dortmund (DE) 0.19 [-0.27–0.66] h 0.419 0.419 No
Munich (DE) 0.29 [-0.33–0.91] h 0.364 0.416 No
Madrid (ES) 0.67 [0.17–1.17] h 0.009 0.017 Yes
Izmir (TR) 0.67 [0.21–1.12] h 0.004 0.011 Yes
San José (CR) -1.00 [-1.33–-0.67] h <0.001 <0.001 Yes
Kumasi (GH) -0.85 [-1.35–-0.35] h <0.001 0.004 Yes
Midpoint of the darkest 10 hours
Borås (SE) -0.59 [-1.05–-0.14] h 0.011 0.023 Yes
Delft (NL) 0.29 [-0.17–0.75] h 0.213 0.233 No
Dortmund (DE) 0.28 [-0.18–0.73] h 0.233 0.233 No
Munich (DE) 0.78 [0.18–1.39] h 0.012 0.023 Yes
Madrid (ES) 0.41 [-0.08–0.89] h 0.099 0.132 No
Izmir (TR) 0.44 [-0.01–0.89] h 0.053 0.085 No
San José (CR) -0.77 [-1.10–-0.45] h <0.001 <0.001 Yes
Kumasi (GH) -0.83 [-1.32–-0.34] h <0.001 0.003 Yes
Mean timing of exposure above 250 lx melEDI
Borås (SE) -0.31 [-0.74–0.12] h 0.158 0.180 No
Delft (NL) 0.42 [-0.01–0.86] h 0.056 0.089 No
Dortmund (DE) 0.40 [-0.03–0.83] h 0.071 0.095 No
Munich (DE) 0.29 [-0.30–0.87] h 0.333 0.333 No
Madrid (ES) 0.87 [0.41–1.34] h <0.001 <0.001 Yes
Izmir (TR) 0.78 [0.36–1.20] h <0.001 <0.001 Yes
San José (CR) -1.47 [-1.78–-1.17] h <0.001 <0.001 Yes
Kumasi (GH) -0.99 [-1.45–-0.52] h <0.001 <0.001 Yes
First light timing above 250 lx melEDI
Borås (SE) -0.93 [-1.60–-0.27] h 0.006 0.016 Yes
Delft (NL) 0.09 [-0.59–0.77] h 0.791 0.791 No
Dortmund (DE) 0.27 [-0.40–0.94] h 0.430 0.492 No
Munich (DE) 0.97 [0.08–1.86] h 0.033 0.066 No
Madrid (ES) 0.44 [-0.27–1.15] h 0.227 0.363 No
Izmir (TR) 1.11 [0.46–1.76] h <0.001 0.003 Yes
San José (CR) -1.62 [-2.10–-1.14] h <0.001 <0.001 Yes
Kumasi (GH) -0.33 [-1.06–0.41] h 0.387 0.492 No
Last light timing above 250 lx melEDI
Borås (SE) 0.14 [-0.48–0.75] h 0.661 0.756 No
Delft (NL) 0.38 [-0.26–1.01] h 0.243 0.389 No
Dortmund (DE) 0.23 [-0.38–0.85] h 0.462 0.616 No
Munich (DE) 0.04 [-0.80–0.87] h 0.931 0.931 No
Madrid (ES) 0.96 [0.30–1.63] h 0.005 0.012 Yes
Izmir (TR) 0.45 [-0.15–1.06] h 0.141 0.282 No
San José (CR) -0.81 [-1.25–-0.37] h <0.001 0.001 Yes
Kumasi (GH) -1.39 [-2.06–-0.72] h <0.001 <0.001 Yes
melEDI dose
Borås (SE) ×1.33 [0.89–1.98] 0.161 0.269 No
Delft (NL) ×1.31 [0.88–1.95] 0.179 0.269 No
Dortmund (DE) ×0.68 [0.46–1.01] 0.056 0.186 No
Munich (DE) ×0.46 [0.27–0.78] 0.004 0.030 Yes
Madrid (ES) ×1.07 [0.70–1.63] 0.755 0.862 No
Izmir (TR) ×0.98 [0.66–1.44] 0.899 0.899 No
San José (CR) ×1.20 [0.91–1.59] 0.202 0.269 No
Kumasi (GH) ×1.48 [0.97–2.26] 0.070 0.186 No
Melanopic daylight efficacy ratio
Borås (SE) -0.03 [-0.13–0.07] 0.562 0.750 No
Delft (NL) -0.06 [-0.16–0.05] 0.289 0.613 No
Dortmund (DE) -0.05 [-0.15–0.06] 0.384 0.614 No
Munich (DE) -0.10 [-0.24–0.04] 0.151 0.604 No
Madrid (ES) -0.01 [-0.12–0.10] 0.841 0.841 No
Izmir (TR) -0.02 [-0.12–0.08] 0.682 0.779 No
San José (CR) 0.04 [-0.04–0.11] 0.307 0.613 No
Kumasi (GH) 0.23 [0.12–0.34] <0.001 <0.001 Yes
Bold within-metric FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 decision rule. Raw p-values have no separate decision rule here and are not bold.

Variation represented

r2_gt(r2_display(chest_run))
Table 11: Complementary chest R² and part-R² summaries with 95% joint-bootstrap intervals.
Complete model
Variance partition
Non-additive term summaries
Model R² Fixed effects R² Participant-associated Not represented Site part R² Photoperiod part R² Latitude part R²
Grand average
Grand average 35.4% 12.1% 26.3% 64.6% 9.7% (13/4) 3.3% (11/6) 6.1% (9/8)
Dynamics
Interdaily stability 3.4% [2.5–16.6] 3.4% [2.5–16.6] Not estimable 96.6% [83.4–97.5] 1.6% [1.6–13.5] 0.3% [0.0–4.5] 0.1% [0.0–3.8]
Intradaily variability 11.4% [7.3–25.8] 11.4% [7.3–25.8] Not estimable 88.6% [74.2–92.7] 6.3% [3.5–19.3] 2.0% [0.0–8.3] 0.8% [0.0–5.9]
Level
Mean melEDI 43.7% [37.0–51.2] 14.2% [9.2–22.5] 29.5% [21.8–36.2] 56.3% [48.8–63.0] 6.5% [3.8–13.3] 4.4% [1.2–9.1] 0.2% [0.0–1.8]
Brightest 10 h mean 33.5% [27.2–41.3] 10.5% [6.6–17.9] 23.0% [15.6–29.5] 66.5% [58.7–72.8] 5.0% [2.8–11.0] 2.9% [0.5–6.5] 0.2% [0.0–2.1]
Darkest 10 h mean 51.7% [45.7–58.9] 13.8% [9.5–23.1] 37.9% [29.2–44.7] 48.3% [41.1–54.3] 11.7% [7.2–20.2] 2.3% [0.2–6.4] 3.9% [0.7–8.1]
Duration
Time above 1,000 lx melEDI 47.7% [39.3–54.4] 19.2% [14.0–29.4] 28.6% [18.4–33.5] 52.3% [45.6–60.7] 7.2% [4.4–15.0] 6.2% [2.3–11.9] 2.2% [0.1–6.1]
Time above 250 lx melEDI during wake 42.3% [33.5–49.7] 14.7% [10.0–24.2] 27.6% [17.6–32.5] 57.7% [50.3–66.5] 8.0% [4.7–15.7] 4.8% [1.4–10.1] 0.0% [-0.0–1.5]
Time below 10 lx melEDI before sleep 29.3% [23.1–38.5] 3.1% [1.8–9.6] 26.2% [18.6–32.4] 70.7% [61.5–76.9] 2.7% [1.4–8.7] 1.7% [0.1–5.4] 0.1% [0.0–1.9]
Time below 1 lx melEDI during sleep 31.9% [24.1–39.2] 7.5% [4.9–14.7] 24.4% [15.3–29.1] 68.1% [60.8–75.9] 6.4% [3.9–13.3] 0.6% [0.0–2.8] 2.7% [0.5–6.8]
Longest continuous period above 250 lx melEDI 26.8% [20.6–34.7] 7.1% [4.1–13.8] 19.7% [12.7–26.1] 73.2% [65.3–79.4] 2.4% [1.3–7.1] 2.9% [0.6–6.4] 0.5% [0.0–2.1]
Timing
Midpoint of the brightest 10 hours 25.0% [19.3–32.9] 12.7% [8.9–19.1] 12.4% [6.7–17.7] 75.0% [67.1–80.7] 12.3% [8.4–18.6] 0.1% [0.0–1.3] 7.4% [4.0–11.8]
Midpoint of the darkest 10 hours 32.4% [26.2–40.8] 12.7% [8.9–20.1] 19.7% [13.0–25.8] 67.6% [59.2–73.8] 11.2% [7.4–18.1] 1.4% [0.1–4.2] 5.6% [2.4–10.2]
Mean timing of exposure above 250 lx melEDI 33.3% [27.5–40.6] 23.8% [19.1–30.6] 9.4% [4.5–14.1] 66.7% [59.4–72.5] 21.7% [16.8–28.0] 0.8% [0.0–2.4] 15.2% [10.2–20.4]
First light timing above 250 lx melEDI 37.8% [31.2–46.0] 16.1% [11.4–24.4] 21.7% [15.1–27.8] 62.2% [54.0–68.8] 15.8% [10.9–23.4] 0.4% [0.0–2.4] 5.8% [2.3–11.0]
Last light timing above 250 lx melEDI 35.4% [28.6–43.7] 17.8% [13.2–25.5] 17.6% [10.9–24.0] 64.6% [56.3–71.4] 9.0% [5.5–15.1] 4.1% [1.2–8.3] 7.1% [3.4–12.1]
Exposure history
melEDI dose 26.7% [21.0–34.7] 9.4% [5.8–15.9] 17.3% [11.1–23.2] 73.3% [65.3–79.0] 3.5% [1.9–8.6] 5.1% [1.7–9.4] 0.3% [0.0–2.1]
Spectrum
Melanopic daylight efficacy ratio 88.6% [86.2–91.1] 8.4% [5.6–20.5] 80.1% [68.6–83.6] 11.4% [8.9–13.8] 7.7% [4.6–18.8] 2.2% [0.0–8.2] 5.2% [0.5–12.4]
Metric rows are percentages with 95% percentile intervals from 1,000 successful joint bootstrap refits per target. Grey term cells correspond to model-level FDR-adjusted p ≥ 0.050. Grand-average term values are descriptive means over supported metrics only; parentheses give supported/unsupported counts. Term summaries are not additive.
Two-panel forest plot for near-eye and chest models. Rows are the 17 metrics; coloured symbols and horizontal intervals show marginal R squared, conditional R squared, participant-associated share, and unrepresented share. Intervals are based on 1,000 successful joint bootstrap refits per target.
Figure 4: Marginal R², conditional R², participant-associated share, and unrepresented share for near-eye and chest models. Points are estimates and horizontal lines are 95% intervals from 1,000 successful joint bootstrap refits.

Source data for Figure 4

Matched near-eye and chest evidence

This comparison uses the same participants and participant-days at both sensor positions for each metric. Across the 30 comparable photoperiod and latitude estimands, 26 point estimates lay on the same side of the null and 5 FDR-adjusted support decisions differed between positions. The matched samples contained 107–112 participants, 489–643 participant-days and observations, and 8 sites, depending on metric. Proximity to the identity line describes agreement of point estimates; it is not an equivalence test and does not establish a causal sensor-position effect.

Two-panel matched-sample sensor-position scatterplot. The same participants and participant-days contribute at near-eye and chest positions for each metric. The difference panel has null lines at zero and the ratio panel has null lines at one. Near-eye estimates are on the horizontal axes and chest estimates on the vertical axes, with equal axis geometry within each panel. A grey dashed 45-degree line shows identity. Green circles mark photoperiod estimands and purple triangles mark latitude-per-10-degree estimands. Each point is labelled by metric abbreviation and predictor. Component 95% CIs and exact matched samples are reported in the adjacent table.
Figure 5: Matched-sample near-eye and chest photoperiod and latitude estimands. Near-eye estimates are on the horizontal axis and chest estimates are on the vertical axis. Dashed diagonal lines show identity; dotted horizontal and vertical lines show the null.

Source data for Figure 5

The study-wide photoperiod and latitude distributions are shown once in the descriptive results. The corresponding data are available with the descriptive figure outputs.

Model checks

All 34 primary and complementary point fits converged with positive-definite Hessians. A model is acceptable when no reviewed diagnostic is flagged, acceptable with limitations when optimization is valid but a residual, prediction-bound, construct, or clock-scale signal requires disclosure, and not acceptable when a required fit or hard diagnostic fails. None of the 34 models is classified as not acceptable. A review signal is therefore not a convergence failure and does not by itself justify changing a response family.

The model-check matrix (model diagnostics) evaluates:

  • convergence and Hessian validity, which indicate whether the numerical optimum was obtained and locally identified;
  • random-effect singularity, which checks whether an estimated variance component collapsed to its boundary;
  • residual distribution, which detects residual-shape, dispersion, zero-mass, or outlier discrepancies;
  • prediction bounds, which check verified physical lower and upper limits;
  • the calendar-day cumulative construct check for pre-sleep time; and
  • the strict-after-16:00 linearization check for clock outcomes.
Two-panel model-check matrix for near-eye and chest models. Rows are the 17 metrics and columns are convergence and Hessian, random-effect singularity, residual distribution, prediction bounds, construct check, and clock-time cut. Blue P cells pass, yellow R cells require review, and grey n/a cells mark non-applicable checks. Convergence and Hessian checks pass throughout; review signals are concentrated in residual distribution and a small number of prediction-bound checks.
Figure 6: Model-check assessment for each primary near-eye and complementary chest metric. P denotes pass, R denotes review, and n/a denotes a non-applicable check.

Source data for Figure 6

model_results |>
  arrange(.data$run_order, .data$metric_order) |>
  transmute(
    Placement = .data$placement_label,
    Metric = .data$manuscript_name,
    Assessment = .data$assessment,
    `Residual check` = .data$residual_assessment,
    `Prediction-bound check` = .data$bound_assessment
  ) |>
  h01_gt(groupname_col = "Placement", rowname_col = "Metric") |>
  cols_width(
    Metric ~ px(300),
    Assessment ~ px(190),
    `Residual check` ~ px(300),
    `Prediction-bound check` ~ px(250)
  ) |>
  tab_style(
    style = list(
      cell_fill(color = "#FFF2CC"),
      cell_text(color = "#6B5700")
    ),
    locations = cells_body(rows = Assessment == "Acceptable with limitations")
  ) |>
  tab_source_note(
    md(
      "Yellow rows require interpretation with the stated limitation but passed the required optimization and inferential-fit checks."
    )
  )
Table 12: Overall model-check assessment for each primary near-eye and complementary chest model.
Assessment Residual check Prediction-bound check
Near eye
Interdaily stability Acceptable No flagged residual issue Prediction bounds passed
Intradaily variability Acceptable No flagged residual issue No verified upper bound available
Mean melEDI Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Brightest 10 h mean Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Darkest 10 h mean Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Time above 1,000 lx melEDI Acceptable No flagged residual issue Prediction bounds passed
Time above 250 lx melEDI during wake Acceptable with limitations Tweedie simulation-diagnostic warning Prediction bounds passed
Time below 10 lx melEDI before sleep Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Time below 1 lx melEDI during sleep Acceptable with limitations Strong Tweedie simulation-diagnostic warning Predicted values crossed a physical bound
Longest continuous period above 250 lx melEDI Acceptable No flagged residual issue Prediction bounds passed
Midpoint of the brightest 10 hours Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Midpoint of the darkest 10 hours Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Mean timing of exposure above 250 lx melEDI Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
First light timing above 250 lx melEDI Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Last light timing above 250 lx melEDI Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
melEDI dose Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Melanopic daylight efficacy ratio Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Chest
Interdaily stability Acceptable No flagged residual issue Prediction bounds passed
Intradaily variability Acceptable No flagged residual issue No verified upper bound available
Mean melEDI Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Brightest 10 h mean Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Darkest 10 h mean Acceptable with limitations Strong Gaussian residual-shape warning No verified upper bound available
Time above 1,000 lx melEDI Acceptable No flagged residual issue Prediction bounds passed
Time above 250 lx melEDI during wake Acceptable No flagged residual issue Prediction bounds passed
Time below 10 lx melEDI before sleep Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Time below 1 lx melEDI during sleep Acceptable with limitations Strong Tweedie simulation-diagnostic warning Predicted values crossed a physical bound
Longest continuous period above 250 lx melEDI Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Midpoint of the brightest 10 hours Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Midpoint of the darkest 10 hours Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Mean timing of exposure above 250 lx melEDI Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
First light timing above 250 lx melEDI Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
Last light timing above 250 lx melEDI Acceptable with limitations Gaussian residual-shape warning Prediction bounds passed
melEDI dose Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Melanopic daylight efficacy ratio Acceptable with limitations Gaussian residual-shape warning No verified upper bound available
Yellow rows require interpretation with the stated limitation but passed the required optimization and inferential-fit checks.
Show representative model-check details

Representative review examples

The overview table identifies every model requiring review; the four examples below make the main warning types inspectable at ordinary report size. The Gaussian panels show residual structure and tail behaviour directly. For the Tweedie models, the plots are descriptive views of Pearson residuals; the formal review signal comes from the stored simulation-based values in the table, not from expecting Tweedie residuals to be normally distributed.

diagnostic_example_gt(diagnostic_example_display())
Table 13: Stored model-check values for four representative primary near-eye review signals.
Family Review signal Stored model-check values Prediction-bound check
Mean melEDI Gaussian Gaussian residual-shape warning Shapiro–Wilk p <0.001; residual-variance ratio 2.20; |standardized residual| >3: 1.3% No verified upper bound available
Time below 10 lx melEDI before sleep Gaussian Gaussian residual-shape warning Shapiro–Wilk p <0.001; residual-variance ratio 1.66; |standardized residual| >3: 0.8% Passed
Time above 250 lx melEDI during wake Tweedie, log link Tweedie simulation-diagnostic warning DHARMa uniformity p 0.005; dispersion p 0.064; zero-inflation p 0.456; outlier p 0.301 Passed
Time below 1 lx melEDI during sleep Tweedie, log link Strong Tweedie simulation-diagnostic warning DHARMa uniformity p <0.001; dispersion p 0.280; zero-inflation p <0.001; outlier p 0.002 187 stored predictions above the physical upper bound
Model-check p-values are descriptive quantities, not the four H01 inferential families. They are shown without bold emphasis. The plots below are preserved point-model checks; for Tweedie models the simulation-based DHARMa values in this table, rather than normality of the Q–Q panel, determine the formal review signal.
Two diagnostic panels for the primary near-eye mean melEDI Gaussian model. The residual-versus-fitted panel shows a curved smooth and increasing residual spread at higher fitted values. The normal Q–Q panel shows departures in both tails, especially the upper tail.
Figure 7: Residual-versus-fitted and normal Q–Q diagnostics for the primary near-eye mean melEDI Gaussian model.

Source data for Figure 7

Two diagnostic panels for the primary near-eye Gaussian model of calendar-day cumulative time below 10 lx melEDI before sleep. The residual-versus-fitted panel shows mild curvature and changing spread. The normal Q–Q panel shows modest departures in the tails.
Figure 8: Residual-versus-fitted and normal Q–Q diagnostics for the primary near-eye calendar-day cumulative time below 10 lx melEDI before sleep Gaussian model.

Source data for Figure 8

Two descriptive Pearson-residual panels for the primary near-eye Tweedie model of time above 250 lx melEDI during wake. The residual-versus-fitted panel shows residual structure across the fitted range. The Q–Q panel shows tail departures; the formal review decision uses the stored simulation diagnostics in the adjacent table.
Figure 9: Residual-versus-fitted and Q–Q diagnostics for the primary near-eye time above 250 lx melEDI during wake Tweedie model.

Source data for Figure 9

Two descriptive Pearson-residual panels for the primary near-eye Tweedie model of time below 1 lx melEDI during sleep. Residual structure and upper-tail departures are visible. The stored simulation diagnostics show the strong review signal, and some fitted predictions crossed the verified physical upper bound.
Figure 10: Residual-versus-fitted and Q–Q diagnostics for the primary near-eye time below 1 lx melEDI during sleep Tweedie model.

Source data for Figure 10

Sensitivity analyses

At the individual metric-by-family support level, the battery is qualitatively sensitive, although the broad geographic pattern remains. Relative to the primary near-eye analysis, support switches occur in both the gap-timing-unaware dataset and matched-sample analyses and are most frequent for the complementary chest placement. Photoperiod support is comparatively stable; site and latitude support vary more with placement and sample definition.

sensitivity_classification |>
  transmute(
    Scenario = display_run(.data$run_id),
    `Metric-family cells` = as.integer(.data$evaluated_cells),
    `Non-estimable cells` = as.integer(.data$nonestimable_cells),
    `Support switches` = as.integer(.data$support_switches),
    Classification = .data$classification
  ) |>
  h01_gt() |>
  cols_align("center", columns = -Scenario) |>
  tab_source_note(
    md(
      "A support switch means that FDR-adjusted p < 0.050 in exactly one of the two compared analyses. Non-estimable cells are reported separately."
    )
  )
Table 14: Support-level sensitivity classification relative to the primary near-eye analysis.
Scenario Metric-family cells Non-estimable cells Support switches Classification
Chest: all available 68 0 22 Qualitatively sensitive
Near eye: matched sample 68 8 11 Qualitatively sensitive
Chest: matched sample 68 8 15 Qualitatively sensitive
Gap-timing-unaware dataset: near eye, all available 68 0 7 Qualitatively sensitive
Gap-timing-unaware dataset: chest, all available 68 0 23 Qualitatively sensitive
Gap-timing-unaware dataset: near eye, matched sample 68 8 11 Qualitatively sensitive
Gap-timing-unaware dataset: chest, matched sample 68 8 14 Qualitatively sensitive
A support switch means that FDR-adjusted p < 0.050 in exactly one of the two compared analyses. Non-estimable cells are reported separately.

Alternative dataset and matched-sample results

sensitivity_support |>
  arrange(.data$run_order, .data$question_order) |>
  transmute(
    Scenario = display_run(.data$run_id),
    Question = .data$question_label,
    `Planned tests` = as.integer(.data$planned_tests),
    `Supported metrics` = as.integer(.data$supported_metrics),
    `Not estimable` = as.integer(.data$nonestimable_metrics),
    Status = str_to_sentence(str_to_lower(.data$family_status))
  ) |>
  h01_gt(groupname_col = "Scenario") |>
  cols_align("center", columns = -Question) |>
  cols_width(Question ~ px(260), everything() ~ px(140))
Table 15: Supported metrics in each complete 17-test family across sensor position, dataset, and matched-sample analyses.
Question Planned tests Supported metrics Not estimable Status
Near eye: all available
Overall site 17 10 0 Complete
Photoperiod 17 12 0 Complete
Latitude 17 7 0 Complete
Site versus linear latitude 17 9 0 Complete
Chest: all available
Overall site 17 13 0 Complete
Photoperiod 17 11 0 Complete
Latitude 17 9 0 Complete
Site versus linear latitude 17 11 0 Complete
Near eye: matched sample
Overall site 17 11 2 Complete
Photoperiod 17 11 2 Complete
Latitude 17 7 2 Complete
Site versus linear latitude 17 8 2 Complete
Chest: matched sample
Overall site 17 10 2 Complete
Photoperiod 17 12 2 Complete
Latitude 17 7 2 Complete
Site versus linear latitude 17 10 2 Complete
Gap-timing-unaware dataset: near eye, all available
Overall site 17 10 0 Complete
Photoperiod 17 12 0 Complete
Latitude 17 8 0 Complete
Site versus linear latitude 17 9 0 Complete
Gap-timing-unaware dataset: chest, all available
Overall site 17 13 0 Complete
Photoperiod 17 11 0 Complete
Latitude 17 9 0 Complete
Site versus linear latitude 17 10 0 Complete
Gap-timing-unaware dataset: near eye, matched sample
Overall site 17 10 2 Complete
Photoperiod 17 12 2 Complete
Latitude 17 8 2 Complete
Site versus linear latitude 17 9 2 Complete
Gap-timing-unaware dataset: chest, matched sample
Overall site 17 8 2 Complete
Photoperiod 17 12 2 Complete
Latitude 17 8 2 Complete
Site versus linear latitude 17 10 2 Complete

Matched near-eye and chest estimands

The matched visual and its interpretation are shown in Figure 5 in the results overview. The table below retains the component 95% CIs and exact matched sample for every plotted estimand. The two participant-level dynamics metrics are not estimable in the paired sample and are omitted.

paired_placement |>
  arrange(.data$effect_scale, .data$metric_order, .data$predictor_order) |>
  transmute(
    Scale = .data$effect_scale,
    Metric = .data$manuscript_name,
    Predictor = .data$predictor,
    `Near-eye estimate (95% CI)` = format_effect_ci(
      .data$near_estimate_practical,
      .data$near_conf_low_practical,
      .data$near_conf_high_practical,
      .data$near_effect_type,
      .data$display_unit
    ),
    `Chest estimate (95% CI)` = format_effect_ci(
      .data$chest_estimate_practical,
      .data$chest_conf_low_practical,
      .data$chest_conf_high_practical,
      .data$chest_effect_type,
      .data$display_unit
    ),
    Participants = as.integer(.data$near_participants),
    `Participant-days` = as.integer(.data$near_participant_days),
    Observations = as.integer(.data$near_observations),
    Sites = as.integer(.data$near_sites)
  ) |>
  h01_gt(groupname_col = "Scale") |>
  cols_align("center", columns = -c(Metric, Predictor)) |>
  cols_width(
    Metric ~ px(280),
    Predictor ~ px(150),
    `Near-eye estimate (95% CI)` ~ px(210),
    `Chest estimate (95% CI)` ~ px(210),
    everything() ~ px(110)
  ) |>
  tab_source_note(
    md(
      "The figure omits intervals for legibility; this table reports both sensor-position-specific 95% CIs. The display compares separately fitted matched estimands and does not pool sensor positions."
    )
  )
Table 16: Matched-sample near-eye and chest estimands with component 95% CIs and exact samples.
Metric Predictor Near-eye estimate (95% CI) Chest estimate (95% CI) Participants Participant-days Observations Sites
Difference
Time below 10 lx melEDI before sleep Photoperiod -0.11 [-0.19–-0.04] h -0.10 [-0.17–-0.03] h 110 505 505 8
Time below 10 lx melEDI before sleep Latitude per 10° -0.01 [-0.11–0.08] h 0.00 [-0.09–0.09] h 110 505 505 8
Midpoint of the brightest 10 hours Photoperiod 0.03 [-0.07–0.14] h 0.04 [-0.07–0.15] h 112 643 643 8
Midpoint of the brightest 10 hours Latitude per 10° 0.22 [0.08–0.35] h 0.24 [0.10–0.38] h 112 643 643 8
Midpoint of the darkest 10 hours Photoperiod -0.14 [-0.25–-0.04] h -0.13 [-0.23–-0.02] h 112 643 643 8
Midpoint of the darkest 10 hours Latitude per 10° 0.13 [0.00–0.26] h 0.22 [0.09–0.35] h 112 643 643 8
Mean timing of exposure above 250 lx melEDI Photoperiod 0.10 [-0.00–0.20] h 0.13 [0.03–0.23] h 112 573 573 8
Mean timing of exposure above 250 lx melEDI Latitude per 10° 0.38 [0.25–0.52] h 0.39 [0.25–0.52] h 112 573 573 8
First light timing above 250 lx melEDI Photoperiod -0.06 [-0.21–0.09] h -0.07 [-0.23–0.09] h 112 563 563 8
First light timing above 250 lx melEDI Latitude per 10° 0.01 [-0.19–0.20] h 0.07 [-0.13–0.28] h 112 563 563 8
Last light timing above 250 lx melEDI Photoperiod 0.29 [0.14–0.44] h 0.32 [0.20–0.45] h 112 524 524 8
Last light timing above 250 lx melEDI Latitude per 10° 0.59 [0.40–0.77] h 0.45 [0.29–0.60] h 112 524 524 8
Melanopic daylight efficacy ratio Photoperiod 0.02 [0.01–0.03] 0.03 [0.02–0.04] 107 489 489 8
Melanopic daylight efficacy ratio Latitude per 10° -0.01 [-0.02–0.00] -0.01 [-0.02–-0.00] 107 489 489 8
Ratio
Mean melEDI Photoperiod ×1.16 [1.08–1.24] ×1.16 [1.08–1.25] 112 643 643 8
Mean melEDI Latitude per 10° ×1.14 [1.04–1.24] ×1.07 [0.97–1.17] 112 643 643 8
Brightest 10 h mean Photoperiod ×1.19 [1.08–1.32] ×1.21 [1.09–1.35] 112 643 643 8
Brightest 10 h mean Latitude per 10° ×1.26 [1.11–1.42] ×1.20 [1.05–1.36] 112 643 643 8
Darkest 10 h mean Photoperiod ×1.10 [1.04–1.16] ×1.08 [1.02–1.15] 112 643 643 8
Darkest 10 h mean Latitude per 10° ×1.00 [0.93–1.08] ×0.93 [0.87–1.00] 112 643 643 8
Time above 1,000 lx melEDI Photoperiod ×1.16 [1.09–1.24] ×1.17 [1.09–1.26] 112 643 643 8
Time above 1,000 lx melEDI Latitude per 10° ×1.04 [0.95–1.13] ×0.99 [0.91–1.09] 112 643 643 8
Time above 250 lx melEDI during wake Photoperiod ×1.10 [1.04–1.16] ×1.12 [1.06–1.18] 112 578 578 8
Time above 250 lx melEDI during wake Latitude per 10° ×1.15 [1.07–1.24] ×1.12 [1.04–1.20] 112 578 578 8
Time below 1 lx melEDI during sleep Photoperiod ×0.98 [0.96–1.01] ×0.99 [0.97–1.01] 112 608 608 8
Time below 1 lx melEDI during sleep Latitude per 10° ×1.01 [0.98–1.04] ×1.02 [1.00–1.05] 112 608 608 8
Longest continuous period above 250 lx melEDI Photoperiod ×1.09 [1.03–1.15] ×1.09 [1.04–1.15] 112 643 643 8
Longest continuous period above 250 lx melEDI Latitude per 10° ×1.09 [1.02–1.16] ×1.03 [0.97–1.09] 112 643 643 8
melEDI dose Photoperiod ×1.24 [1.13–1.35] ×1.25 [1.14–1.38] 112 598 598 8
melEDI dose Latitude per 10° ×1.05 [0.94–1.17] ×0.98 [0.87–1.10] 112 598 598 8
The figure omits intervals for legibility; this table reports both sensor-position-specific 95% CIs. The display compares separately fitted matched estimands and does not pool sensor positions.

Darkest-10-hour midpoint clock cut

The primary midpoint conversion subtracts 24 hours only for clock values strictly after 16:00. The registered same-row sensitivity uses a noon cut. Both use the same Gaussian linear model implementation and identical fitted rows.

l10_noon |>
  arrange(.data$run_order, .data$variant, .data$question_order) |>
  transmute(
    Scenario = display_run(.data$run_id),
    Conversion = recode(
      .data$variant,
      `Primary strict-after-16:00 conversion` = "Strictly after 16:00",
      `Noon-cut sensitivity` = "Strictly after 12:00"
    ),
    Question = .data$question_label,
    `Raw p` = format_p(.data$p_raw),
    `FDR-adjusted p` = format_p(.data$adjusted_p),
    adjusted_bold = !is.na(.data$adjusted_p) & .data$adjusted_p < 0.05,
    Multiplicity = dplyr::recode(
      .data$multiplicity,
      `Primary 17-test BH family` = "Primary 17-test FDR family"
    )
  ) |>
  h01_gt(groupname_col = "Scenario") |>
  cols_hide(columns = "adjusted_bold") |>
  cols_width(
    Conversion ~ px(180),
    Question ~ px(240),
    everything() ~ px(150)
  ) |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_body(columns = `FDR-adjusted p`, rows = adjusted_bold)
  ) |>
  tab_source_note(
    md(
      "Bold FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 rule. Raw p-values have no separate decision rule here and are not bold."
    )
  )
Table 17: Primary strict-after-16:00 and noon-cut sensitivity for the darkest-10-hour midpoint.
Conversion Question Raw p FDR-adjusted p Multiplicity
Near eye: all available
Strictly after 12:00 Overall site <0.001 — Outside primary multiplicity families
Strictly after 12:00 Photoperiod 0.003 — Outside primary multiplicity families
Strictly after 12:00 Latitude 0.034 — Outside primary multiplicity families
Strictly after 12:00 Site versus linear latitude 0.002 — Outside primary multiplicity families
Strictly after 16:00 Overall site 0.010 0.020 Primary 17-test FDR family
Strictly after 16:00 Photoperiod 0.002 0.004 Primary 17-test FDR family
Strictly after 16:00 Latitude 0.054 0.114 Primary 17-test FDR family
Strictly after 16:00 Site versus linear latitude 0.023 0.043 Primary 17-test FDR family
Chest: all available
Strictly after 12:00 Overall site <0.001 — Outside primary multiplicity families
Strictly after 12:00 Photoperiod 0.033 — Outside primary multiplicity families
Strictly after 12:00 Latitude <0.001 — Outside primary multiplicity families
Strictly after 12:00 Site versus linear latitude <0.001 — Outside primary multiplicity families
Strictly after 16:00 Overall site <0.001 <0.001 Primary 17-test FDR family
Strictly after 16:00 Photoperiod 0.009 0.016 Primary 17-test FDR family
Strictly after 16:00 Latitude <0.001 <0.001 Primary 17-test FDR family
Strictly after 16:00 Site versus linear latitude <0.001 <0.001 Primary 17-test FDR family
Bold FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 rule. Raw p-values have no separate decision rule here and are not bold.
l10_noon_effects |>
  left_join(
    metric_registry |>
      select("metric_id", "display_unit"),
    by = "metric_id",
    relationship = "many-to-one"
  ) |>
  arrange(.data$run_id, .data$term) |>
  transmute(
    Scenario = display_run(.data$run_id),
    Conversion = "Strictly after 12:00",
    Term = recode(
      .data$term,
      photoperiod_centered_hours = "Photoperiod",
      absolute_latitude_10deg_centered = "Latitude per 10°"
    ),
    `Effect (95% CI)` = format_effect_ci(
      .data$estimate_practical,
      .data$conf_low_practical,
      .data$conf_high_practical,
      .data$effect_type,
      .data$display_unit
    ),
    `Raw p` = format_p(.data$p_raw)
  ) |>
  h01_gt(groupname_col = "Scenario") |>
  cols_width(Conversion ~ px(180), Term ~ px(160), everything() ~ px(200)) |>
  tab_source_note(
    md("Raw p-values are reported descriptively and have no separate bolding rule in this table.")
  )
Table 18: Photoperiod and latitude effects under the darkest-10-hour midpoint clock-cut sensitivity.
Conversion Term Effect (95% CI) Raw p
Chest: all available
Strictly after 12:00 Latitude per 10° 0.21 [0.12–0.30] h <0.001
Strictly after 12:00 Photoperiod -0.10 [-0.19–-0.01] h 0.037
Near eye: all available
Strictly after 12:00 Latitude per 10° 0.14 [0.01–0.27] h 0.035
Strictly after 12:00 Photoperiod -0.15 [-0.25–-0.05] h 0.004
Raw p-values are reported descriptively and have no separate bolding rule in this table.

Exactly identified continuous periods

The continuous-period sensitivity excludes censored periods and retains only exactly identified periods. It uses the same response-family implementation as the corresponding main model.

period_sensitivity |>
  arrange(.data$run_order) |>
  transmute(
    Scenario = display_run(.data$run_id),
    `Excluded censored rows` = as.integer(.data$excluded_censored_rows),
    Participants = as.integer(.data$participants),
    `Participant-days` = as.integer(.data$participant_days),
    Observations = as.integer(.data$observations),
    Sites = as.integer(.data$sites),
    `Overall site raw p` = format_p(.data$p_raw),
    `Fit status` = str_to_sentence(str_replace_all(.data$comparison_status, "_", " ")),
    `Model-check assessment` = case_when(
      .data$diagnostic_status == "PASS" ~ "Acceptable",
      .data$diagnostic_status == "WARN_REVIEW" ~
        "Acceptable with limitations",
      TRUE ~ "Not acceptable"
    )
  ) |>
  h01_gt() |>
  cols_align("center", columns = -Scenario) |>
  cols_width(Scenario ~ px(260), everything() ~ px(145)) |>
  tab_source_note(
    md("The raw p-value is reported descriptively and has no separate bolding rule in this sensitivity table.")
  )
Table 19: Exactly identified continuous-period sensitivity for the longest period above 250 lx melEDI.
Scenario Excluded censored rows Participants Participant-days Observations Sites Overall site raw p Fit status Model-check assessment
Near eye: all available 316 132 500 500 9 0.663 Pass Acceptable with limitations
Near eye: all available 316 132 500 500 9 <0.001 Pass Acceptable with limitations
Near eye: all available 316 132 500 500 9 0.305 Pass Acceptable with limitations
Near eye: all available 316 132 500 500 9 0.684 Pass Acceptable with limitations
Chest: all available 338 150 564 564 8 0.094 Pass Acceptable with limitations
Chest: all available 338 150 564 564 8 <0.001 Pass Acceptable with limitations
Chest: all available 338 150 564 564 8 0.010 Pass Acceptable with limitations
Chest: all available 338 150 564 564 8 0.479 Pass Acceptable with limitations
The raw p-value is reported descriptively and has no separate bolding rule in this sensitivity table.

Preregistered photoperiod scope

The main implementation adjusts all 17 metrics for photoperiod. The preregistered-scope sensitivity restricts photoperiod adjustment to the duration metrics declared for that purpose.

scope_sensitivity |>
  arrange(.data$run_order, .data$metric_order, .data$question_order) |>
  transmute(
    Scenario = display_run(.data$run_id),
    Metric = .data$manuscript_name,
    Question = .data$question_label,
    `Raw p` = format_p(.data$p_raw),
    `FDR-adjusted p` = format_p(.data$p_adjusted),
    adjusted_bold = !is.na(.data$p_adjusted) & .data$p_adjusted < 0.05,
    Status = str_to_sentence(str_replace_all(.data$comparison_status, "_", " "))
  ) |>
  h01_gt(groupname_col = "Scenario") |>
  cols_hide(columns = "adjusted_bold") |>
  cols_width(
    Metric ~ px(280),
    Question ~ px(230),
    everything() ~ px(145)
  ) |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_body(columns = `FDR-adjusted p`, rows = adjusted_bold)
  ) |>
  tab_source_note(
    md(
      "Bold FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 rule. Raw p-values have no separate decision rule here and are not bold."
    )
  )
Table 20: Preregistered-scope sensitivity with raw and FDR-adjusted p-values.
Metric Question Raw p FDR-adjusted p Status
Near eye: all available
Interdaily stability NA 0.004 0.006 Pass
Interdaily stability NA 0.376 0.533 Pass
Interdaily stability NA 0.003 0.007 Pass
Intradaily variability NA 0.479 0.479 Pass
Intradaily variability NA 0.046 0.088 Pass
Intradaily variability NA 0.827 0.827 Pass
Mean melEDI NA <0.001 <0.001 Pass
Mean melEDI NA <0.001 <0.001 Pass
Mean melEDI NA 0.003 0.008 Pass
Brightest 10 h mean NA <0.001 <0.001 Pass
Brightest 10 h mean NA <0.001 <0.001 Pass
Brightest 10 h mean NA 0.209 0.237 Pass
Darkest 10 h mean NA <0.001 <0.001 Pass
Darkest 10 h mean NA 0.012 0.030 Pass
Darkest 10 h mean NA <0.001 <0.001 Pass
Time above 1,000 lx melEDI NA 0.050 0.061 Pass
Time above 1,000 lx melEDI NA <0.001 <0.001 Pass
Time above 1,000 lx melEDI NA 0.543 0.709 Pass
Time above 1,000 lx melEDI NA 0.034 0.045 Pass
Time above 250 lx melEDI during wake NA 0.003 0.006 Pass
Time above 250 lx melEDI during wake NA <0.001 <0.001 Pass
Time above 250 lx melEDI during wake NA 0.005 0.014 Pass
Time above 250 lx melEDI during wake NA 0.032 0.045 Pass
Time below 10 lx melEDI before sleep NA 0.029 0.041 Pass
Time below 10 lx melEDI before sleep NA <0.001 <0.001 Pass
Time below 10 lx melEDI before sleep NA 0.962 0.962 Pass
Time below 10 lx melEDI before sleep NA 0.017 0.028 Pass
Time below 1 lx melEDI during sleep NA 0.037 0.049 Pass
Time below 1 lx melEDI during sleep NA 0.174 0.174 Pass
Time below 1 lx melEDI during sleep NA 0.850 0.962 Pass
Time below 1 lx melEDI during sleep NA 0.022 0.034 Pass
Longest continuous period above 250 lx melEDI NA 0.375 0.398 Pass
Longest continuous period above 250 lx melEDI NA <0.001 <0.001 Pass
Longest continuous period above 250 lx melEDI NA 0.081 0.138 Pass
Longest continuous period above 250 lx melEDI NA 0.589 0.626 Pass
Midpoint of the brightest 10 hours NA <0.001 <0.001 Pass
Midpoint of the brightest 10 hours NA 0.002 0.008 Pass
Midpoint of the brightest 10 hours NA 0.005 0.009 Pass
Midpoint of the darkest 10 hours NA <0.001 0.002 Pass
Midpoint of the darkest 10 hours NA 0.721 0.876 Pass
Midpoint of the darkest 10 hours NA <0.001 0.002 Pass
Mean timing of exposure above 250 lx melEDI NA <0.001 <0.001 Pass
Mean timing of exposure above 250 lx melEDI NA <0.001 <0.001 Pass
Mean timing of exposure above 250 lx melEDI NA <0.001 <0.001 Pass
First light timing above 250 lx melEDI NA 0.005 0.008 Pass
First light timing above 250 lx melEDI NA 0.360 0.533 Pass
First light timing above 250 lx melEDI NA 0.004 0.008 Pass
Last light timing above 250 lx melEDI NA <0.001 <0.001 Pass
Last light timing above 250 lx melEDI NA <0.001 <0.001 Pass
Last light timing above 250 lx melEDI NA <0.001 0.002 Pass
melEDI dose NA 0.067 0.076 Pass
melEDI dose NA 0.031 0.066 Pass
melEDI dose NA 0.190 0.231 Pass
Melanopic daylight efficacy ratio NA <0.001 <0.001 Pass
Melanopic daylight efficacy ratio NA 0.912 0.962 Pass
Melanopic daylight efficacy ratio NA <0.001 <0.001 Pass
Bold FDR-adjusted p-values meet the declared FDR-adjusted p < 0.050 rule. Raw p-values have no separate decision rule here and are not bold.

Marginalization, participant influence, and latitude leverage

Site-average marginalization gives every site equal weight; observed-sample marginalization weights sites by their fitted analytical sample. Participant deletion assesses whether one participant dominates a coefficient, while leave-one-site-out latitude refits assess the structural leverage of individual sites on the latitude estimate.

marginalization |>
  arrange(.data$run_order, .data$metric_order) |>
  transmute(
    Placement = .data$placement_label,
    Metric = .data$manuscript_name,
    `Site-average estimate` = format_number(.data$equal_site_estimate_practical),
    `Observed-sample estimate` = format_number(
      .data$observed_sample_estimate_practical
    ),
    Difference = format_number(.data$practical_difference)
  ) |>
  h01_gt(groupname_col = "Placement", rowname_col = "Metric") |>
  cols_align("center", columns = -Metric) |>
  cols_width(Metric ~ px(300), everything() ~ px(190))
Table 21: Site-average and observed-sample marginalization for primary and complementary models.
Site-average estimate Observed-sample estimate Difference
Near eye
Interdaily stability 0.31 0.31 0.00
Intradaily variability 1.25 1.23 -0.02
Mean melEDI 4.65 4.80 0.16
Brightest 10 h mean 82.67 87.72 5.05
Darkest 10 h mean 0.13 0.13 -0.00
Time above 1,000 lx melEDI 0.84 0.89 0.06
Time above 250 lx melEDI during wake 2.41 2.54 0.13
Time below 10 lx melEDI before sleep 1.92 1.85 -0.07
Time below 1 lx melEDI during sleep 6.90 6.95 0.04
Longest continuous period above 250 lx melEDI 0.57 0.60 0.03
Midpoint of the brightest 10 hours 13.83 13.92 0.10
Midpoint of the darkest 10 hours 2.94 2.99 0.06
Mean timing of exposure above 250 lx melEDI 13.32 13.51 0.19
First light timing above 250 lx melEDI 9.36 9.45 0.09
Last light timing above 250 lx melEDI 17.71 17.91 0.19
melEDI dose 4090.35 4412.94 322.59
Melanopic daylight efficacy ratio 0.73 0.72 -0.00
Chest
Interdaily stability 0.30 0.30 0.00
Intradaily variability 1.30 1.28 -0.03
Mean melEDI 3.87 4.08 0.21
Brightest 10 h mean 69.69 74.66 4.97
Darkest 10 h mean 0.10 0.11 0.01
Time above 1,000 lx melEDI 0.97 1.03 0.06
Time above 250 lx melEDI during wake 2.43 2.56 0.13
Time below 10 lx melEDI before sleep 1.99 1.94 -0.05
Time below 1 lx melEDI during sleep 7.13 7.09 -0.03
Longest continuous period above 250 lx melEDI 0.54 0.56 0.02
Midpoint of the brightest 10 hours 13.82 13.70 -0.12
Midpoint of the darkest 10 hours 2.86 2.75 -0.12
Mean timing of exposure above 250 lx melEDI 13.48 13.29 -0.19
First light timing above 250 lx melEDI 9.50 9.27 -0.23
Last light timing above 250 lx melEDI 17.97 17.89 -0.08
melEDI dose 5160.20 5421.32 261.12
Melanopic daylight efficacy ratio 0.76 0.76 -0.00
influence_summary |>
  arrange(.data$run_order, .data$metric_order) |>
  transmute(
    Placement = .data$placement_label,
    Metric = .data$manuscript_name,
    Refits = as.integer(.data$refits),
    `Successful refits` = as.integer(.data$successful_refits),
    `Maximum |DFBETA|` = format_number(.data$maximum_absolute_dfbeta, 3),
    `Most influential participant` = .data$most_influential_participant,
    Term = .data$maximum_dfbeta_term
  ) |>
  h01_gt(groupname_col = "Placement", rowname_col = "Metric") |>
  cols_align("center", columns = -Metric) |>
  cols_width(Metric ~ px(300), everything() ~ px(175))
Table 22: Participant-deletion influence summary for primary and complementary models.
Refits Successful refits Maximum |DFBETA| Most influential participant Term
Near eye
Interdaily stability 3 3 0.634 MPI::MPI_S201 site5
Intradaily variability 3 3 0.568 TUM::TUM_S008 site8
Mean melEDI 6 6 0.742 TUM::TUM_S005 site8
Brightest 10 h mean 6 6 0.842 KNUST::KNUST_S005 site4
Darkest 10 h mean 6 6 0.757 IZTECH::IZTECH_S013 site3
Time above 1,000 lx melEDI 6 6 0.486 BAUA::BAUA_S011 site1
Time above 250 lx melEDI during wake 6 6 0.592 MPI::MPI_S206 site5
Time below 10 lx melEDI before sleep 6 6 0.661 KNUST::KNUST_S012 site4
Time below 1 lx melEDI during sleep 6 6 0.932 KNUST::KNUST_S008 site4
Longest continuous period above 250 lx melEDI 6 6 0.596 MPI::MPI_S206 site5
Midpoint of the brightest 10 hours 6 6 0.870 TUM::TUM_S007 site8
Midpoint of the darkest 10 hours 6 6 1.116 TUM::TUM_S007 site8
Mean timing of exposure above 250 lx melEDI 5 5 0.986 TUM::TUM_S005 site8
First light timing above 250 lx melEDI 6 6 0.717 IZTECH::IZTECH_S015 site3
Last light timing above 250 lx melEDI 5 5 0.594 BAUA::BAUA_S024 site1
melEDI dose 5 5 0.878 THUAS::THUAS_S001 site7
Melanopic daylight efficacy ratio 5 5 1.054 RISE::RISE_S001 site6
Chest
Interdaily stability 3 3 0.779 TUM::TUM_S003 site7
Intradaily variability 3 3 0.469 IZTECH::IZTECH_S001 site3
Mean melEDI 6 6 0.920 TUM::TUM_S005 site7
Brightest 10 h mean 6 6 0.746 KNUST::KNUST_S005 site4
Darkest 10 h mean 6 6 0.820 IZTECH::IZTECH_S013 site3
Time above 1,000 lx melEDI 6 6 0.786 TUM::TUM_S009 site7
Time above 250 lx melEDI during wake 6 6 0.511 BAUA::BAUA_S008 site1
Time below 10 lx melEDI before sleep 6 6 0.435 IZTECH::IZTECH_S001 site3
Time below 1 lx melEDI during sleep 6 6 0.784 TUM::TUM_S005 site7
Longest continuous period above 250 lx melEDI 6 6 0.745 TUM::TUM_S009 site7
Midpoint of the brightest 10 hours 6 6 0.968 TUM::TUM_S007 site7
Midpoint of the darkest 10 hours 5 5 1.163 TUM::TUM_S007 site7
Mean timing of exposure above 250 lx melEDI 6 6 1.014 TUM::TUM_S007 site7
First light timing above 250 lx melEDI 6 6 1.057 TUM::TUM_S007 site7
Last light timing above 250 lx melEDI 6 6 0.778 TUM::TUM_S003 site7
melEDI dose 5 5 0.603 IZTECH::IZTECH_S001 site3
Melanopic daylight efficacy ratio 6 6 2.622 KNUST::KNUST_S007 site4
latitude_loo_summary |>
  arrange(.data$run_order, .data$metric_order) |>
  transmute(
    Placement = .data$placement_label,
    Metric = .data$manuscript_name,
    `Site omissions` = as.integer(.data$omitted_site_refits),
    `Successful refits` = as.integer(.data$successful_refits),
    `Minimum effect` = format_number(.data$minimum_estimate),
    `Maximum effect` = format_number(.data$maximum_estimate),
    `Range crosses null` = if_else(.data$range_crosses_null, "Yes", "No")
  ) |>
  h01_gt(groupname_col = "Placement", rowname_col = "Metric") |>
  cols_align("center", columns = -Metric) |>
  cols_width(Metric ~ px(300), everything() ~ px(170))
Table 23: Leave-one-site-out latitude sensitivity for primary and complementary models.
Site omissions Successful refits Minimum effect Maximum effect Range crosses null
Near eye
Interdaily stability 9 9 0.95 1.03 No
Intradaily variability 9 9 -0.05 -0.02 No
Mean melEDI 9 9 0.99 1.20 Yes
Brightest 10 h mean 9 9 1.07 1.30 No
Darkest 10 h mean 9 9 0.91 1.08 Yes
Time above 1,000 lx melEDI 9 9 0.99 1.20 Yes
Time above 250 lx melEDI during wake 9 9 1.10 1.15 No
Time below 10 lx melEDI before sleep 9 9 -0.03 0.12 Yes
Time below 1 lx melEDI during sleep 9 9 0.99 1.02 Yes
Longest continuous period above 250 lx melEDI 9 9 1.05 1.08 No
Midpoint of the brightest 10 hours 9 9 0.05 0.26 No
Midpoint of the darkest 10 hours 9 9 0.07 0.18 No
Mean timing of exposure above 250 lx melEDI 9 9 0.25 0.40 No
First light timing above 250 lx melEDI 9 9 -0.06 0.10 Yes
Last light timing above 250 lx melEDI 9 9 0.27 0.59 No
melEDI dose 9 9 0.98 1.09 Yes
Melanopic daylight efficacy ratio 9 9 -0.02 -0.00 No
Chest
Interdaily stability 8 8 0.98 1.00 No
Intradaily variability 8 8 -0.03 0.04 Yes
Mean melEDI 8 8 0.92 1.10 Yes
Brightest 10 h mean 8 8 0.97 1.25 Yes
Darkest 10 h mean 8 8 0.90 0.95 No
Time above 1,000 lx melEDI 8 8 0.91 0.96 No
Time above 250 lx melEDI during wake 8 8 0.97 1.10 Yes
Time below 10 lx melEDI before sleep 8 8 -0.02 0.04 Yes
Time below 1 lx melEDI during sleep 8 8 1.02 1.03 No
Longest continuous period above 250 lx melEDI 8 8 0.96 1.01 Yes
Midpoint of the brightest 10 hours 8 8 0.20 0.41 No
Midpoint of the darkest 10 hours 8 8 0.16 0.33 No
Mean timing of exposure above 250 lx melEDI 8 8 0.25 0.52 No
First light timing above 250 lx melEDI 8 8 -0.00 0.46 Yes
Last light timing above 250 lx melEDI 8 8 0.30 0.40 No
melEDI dose 8 8 0.93 0.97 No
Melanopic daylight efficacy ratio 8 8 -0.06 -0.02 No

Interpretation

The primary results support the hypothesis that personal light exposure differs geographically, but they do not reduce that pattern to one universal site ordering or one latitude effect. After FDR adjustment in the four separate complete 17-test families, overall site was supported for 10 of 17 metrics, photoperiod for 12, latitude for 7, and site-versus-linear-latitude adequacy for 9. The two dynamics metrics retained none of these associations. In contrast, the three level metrics retained site, photoperiod, and latitude support for 3, 3, 2 metrics, respectively; the five duration metrics retained 2, 4, 1; and the five timing metrics retained 4, 3, 3. Thus geography appeared most consistently in light level and timing, whereas duration responses were more consistently related to photoperiod.

MDER, the spectrum metric, retained support for all four model-level questions. Its positive photoperiod association and negative linear-latitude association summarize broad gradients, while the supported site-versus-linear- latitude comparison shows that a single latitude slope does not capture all represented site structure.

Among supported ratio-scale photoperiod effects, an additional hour of day length corresponded to approximately 9–28% higher responses, depending on metric. Examples include mean melEDI at ×1.18 [1.11–1.26], calendar-day cumulative pre-sleep time below 10 lx melEDI at -0.13 [-0.20–-0.07] h, and last light above 250 lx melEDI at 0.32 [0.19–0.46] h. Hierarchical site contrasts then identify which sites differ from the site-average estimate, but only for metrics with a supported overall site test; their varying directions rule out a single north-to-south site ranking.

The R² summaries distinguish complete-model variation, participant-associated variation, and term-level part R². They should not be interpreted as an additive decomposition: site and photoperiod can contain overlapping information, and latitude belongs to a separate same-frame model. Across the 17 primary metrics, fixed effects represented 5.9–27.8% and complete models represented 5.9–61.1%. For participant-day outcomes, the participant-associated share ranged from 11.0 to 39.1%. When averaged only over metrics whose corresponding model-level test was supported, term part R² was 8.7% for site (10 metrics), 5.9% for photoperiod (12 metrics), and 3.8% for latitude (7 metrics).

The complementary chest analysis reproduced the broad geographic signal, but the placement-specific all-available heatmap is not a test of placement. In the matched samples containing the same participants and participant-days at both sensor positions, 26 of 30 comparable photoperiod and latitude point estimates were on the same side of the null and 5 FDR-adjusted support decisions differed. This indicates material metric-level placement sensitivity without implying equivalence, a formal placement effect, or that one placement can substitute for the other. The gap-timing-unaware near-eye sensitivity changed 7 of 68 support decisions, so individual metric claims remain conditional on the timing-aware metric preparation even though the broad geographic conclusion persists.

Limitations

  • The design is observational; associations with site, photoperiod, and latitude are not causal effects.
  • Site and latitude are structurally linked because every site has one latitude. Separate same-frame models address estimability but cannot remove all geographic confounding.
  • Many models are acceptable with limitations because residual-shape or prediction-bound checks require review. The representative plots and stored model-check values make the main warning types inspectable and should temper tail-sensitive interpretation.
  • Near-eye and chest sensors measure related but different exposure environments. Chest measurements are complementary and must not be relabelled as ocular exposure.
  • Rows from the gap-timing-unaware dataset do not retain measurement-support hours, so those hours remain unavailable.
  • The calendar-day cumulative pre-sleep outcome can include more than one diary-defined pre-sleep interval. Values strictly above six hours are audited but are not capped.

Detailed analysis record

Exact fitted samples

The tabs report exact participants, contributing participant-days, observations, measurement-support hours, and sites for all 136 run-metric combinations without forcing them into one 136-row table. Measurement-support hours were not retained in the analytical rows for the gap-timing-unaware dataset and therefore remain explicitly unavailable rather than being reconstructed.

exact_sample_gt(exact_sample_display("main__glasses__all_available"))
Table 24: Exact fitted samples: primary dataset, near eye, all available.
Unit Participants Days Observations Support hours Sites Status
Interdaily stability Participant 141 816 141 18851.0 9 Fitted
Intradaily variability Participant 141 816 141 18851.0 9 Fitted
Mean melEDI Participant-day 141 816 816 18851.0 9 Fitted
Brightest 10 h mean Participant-day 141 816 816 Unavailable 9 Fitted
Darkest 10 h mean Participant-day 141 816 816 Unavailable 9 Fitted
Time above 1,000 lx melEDI Participant-day 141 816 816 18851.0 9 Fitted
Time above 250 lx melEDI during wake Participant-day 141 737 737 9174.3 9 Fitted
Time below 10 lx melEDI before sleep Participant-day 139 655 655 1925.5 9 Fitted
Time below 1 lx melEDI during sleep Participant-day 141 778 778 6282.5 9 Fitted
Longest continuous period above 250 lx melEDI Participant-day 141 816 816 18851.0 9 Fitted
Midpoint of the brightest 10 hours Participant-day 141 816 816 Unavailable 9 Fitted
Midpoint of the darkest 10 hours Participant-day 141 816 816 Unavailable 9 Fitted
Mean timing of exposure above 250 lx melEDI Participant-day 141 742 742 17209.6 9 Fitted
First light timing above 250 lx melEDI Participant-day 140 727 727 16832.5 9 Fitted
Last light timing above 250 lx melEDI Participant-day 141 687 687 15995.0 9 Fitted
melEDI dose Participant-day 141 761 761 17678.8 9 Fitted
Melanopic daylight efficacy ratio Participant-day 137 702 702 10949.9 9 Fitted
Participant-level dynamics models have one fitted observation per participant; Days records the contributing repeated-day support. Unavailable support hours were not retained in the analytical rows and were not reconstructed.
exact_sample_gt(exact_sample_display("main__chest__all_available"))
Table 25: Exact fitted samples: primary dataset, chest, all available.
Unit Participants Days Observations Support hours Sites Status
Interdaily stability Participant 153 900 153 20845.4 8 Fitted
Intradaily variability Participant 153 900 153 20845.4 8 Fitted
Mean melEDI Participant-day 154 902 902 20891.8 8 Fitted
Brightest 10 h mean Participant-day 154 902 902 Unavailable 8 Fitted
Darkest 10 h mean Participant-day 154 902 902 Unavailable 8 Fitted
Time above 1,000 lx melEDI Participant-day 154 902 902 20891.8 8 Fitted
Time above 250 lx melEDI during wake Participant-day 154 818 818 10297.7 8 Fitted
Time below 10 lx melEDI before sleep Participant-day 153 743 743 2180.1 8 Fitted
Time below 1 lx melEDI during sleep Participant-day 154 861 861 6871.4 8 Fitted
Longest continuous period above 250 lx melEDI Participant-day 154 902 902 20891.8 8 Fitted
Midpoint of the brightest 10 hours Participant-day 154 902 902 Unavailable 8 Fitted
Midpoint of the darkest 10 hours Participant-day 154 902 902 Unavailable 8 Fitted
Mean timing of exposure above 250 lx melEDI Participant-day 154 831 831 19336.1 8 Fitted
First light timing above 250 lx melEDI Participant-day 154 802 802 18634.7 8 Fitted
Last light timing above 250 lx melEDI Participant-day 154 787 787 18358.1 8 Fitted
melEDI dose Participant-day 154 851 851 19814.9 8 Fitted
Melanopic daylight efficacy ratio Participant-day 152 732 732 11145.3 8 Fitted
Participant-level dynamics models have one fitted observation per participant; Days records the contributing repeated-day support. Unavailable support hours were not retained in the analytical rows and were not reconstructed.
exact_sample_gt(exact_sample_display("main__glasses__paired_common_sample"))
Table 26: Exact fitted samples: primary dataset, near eye, matched sample.
Unit Participants Days Observations Support hours Sites Status
Interdaily stability Participant 0 0 0 Unavailable 0 Non estimable
Intradaily variability Participant 0 0 0 Unavailable 0 Non estimable
Mean melEDI Participant-day 112 643 643 14874.1 8 Fitted
Brightest 10 h mean Participant-day 112 643 643 Unavailable 8 Fitted
Darkest 10 h mean Participant-day 112 643 643 Unavailable 8 Fitted
Time above 1,000 lx melEDI Participant-day 112 643 643 14874.1 8 Fitted
Time above 250 lx melEDI during wake Participant-day 112 578 578 7172.9 8 Fitted
Time below 10 lx melEDI before sleep Participant-day 110 505 505 1479.3 8 Fitted
Time below 1 lx melEDI during sleep Participant-day 112 608 608 4966.2 8 Fitted
Longest continuous period above 250 lx melEDI Participant-day 112 643 643 14874.1 8 Fitted
Midpoint of the brightest 10 hours Participant-day 112 643 643 Unavailable 8 Fitted
Midpoint of the darkest 10 hours Participant-day 112 643 643 Unavailable 8 Fitted
Mean timing of exposure above 250 lx melEDI Participant-day 112 573 573 13310.4 8 Fitted
First light timing above 250 lx melEDI Participant-day 112 563 563 13059.7 8 Fitted
Last light timing above 250 lx melEDI Participant-day 112 524 524 12226.1 8 Fitted
melEDI dose Participant-day 112 598 598 13912.9 8 Fitted
Melanopic daylight efficacy ratio Participant-day 107 489 489 7665.2 8 Fitted
Participant-level dynamics models have one fitted observation per participant; Days records the contributing repeated-day support. Unavailable support hours were not retained in the analytical rows and were not reconstructed.
exact_sample_gt(exact_sample_display("main__chest__paired_common_sample"))
Table 27: Exact fitted samples: primary dataset, chest, matched sample.
Unit Participants Days Observations Support hours Sites Status
Interdaily stability Participant 0 0 0 Unavailable 0 Non estimable
Intradaily variability Participant 0 0 0 Unavailable 0 Non estimable
Mean melEDI Participant-day 112 643 643 14873.7 8 Fitted
Brightest 10 h mean Participant-day 112 643 643 Unavailable 8 Fitted
Darkest 10 h mean Participant-day 112 643 643 Unavailable 8 Fitted
Time above 1,000 lx melEDI Participant-day 112 643 643 14873.7 8 Fitted
Time above 250 lx melEDI during wake Participant-day 112 578 578 7170.2 8 Fitted
Time below 10 lx melEDI before sleep Participant-day 110 505 505 1479.2 8 Fitted
Time below 1 lx melEDI during sleep Participant-day 112 608 608 4966.8 8 Fitted
Longest continuous period above 250 lx melEDI Participant-day 112 643 643 14873.7 8 Fitted
Midpoint of the brightest 10 hours Participant-day 112 643 643 Unavailable 8 Fitted
Midpoint of the darkest 10 hours Participant-day 112 643 643 Unavailable 8 Fitted
Mean timing of exposure above 250 lx melEDI Participant-day 112 573 573 13312.7 8 Fitted
First light timing above 250 lx melEDI Participant-day 112 563 563 13055.3 8 Fitted
Last light timing above 250 lx melEDI Participant-day 112 524 524 12226.6 8 Fitted
melEDI dose Participant-day 112 598 598 13915.2 8 Fitted
Melanopic daylight efficacy ratio Participant-day 107 489 489 7435.6 8 Fitted
Participant-level dynamics models have one fitted observation per participant; Days records the contributing repeated-day support. Unavailable support hours were not retained in the analytical rows and were not reconstructed.
exact_sample_gt(exact_sample_display(
  "alternative_preprocessing__glasses__all_available"
))
Table 28: Exact fitted samples: gap-timing-unaware dataset, near eye, all available.
Unit Participants Days Observations Support hours Sites Status
Interdaily stability Participant 141 811 141 Unavailable 9 Fitted
Intradaily variability Participant 141 811 141 Unavailable 9 Fitted
Mean melEDI Participant-day 141 811 811 Unavailable 9 Fitted
Brightest 10 h mean Participant-day 141 811 811 Unavailable 9 Fitted
Darkest 10 h mean Participant-day 141 811 811 Unavailable 9 Fitted
Time above 1,000 lx melEDI Participant-day 141 811 811 Unavailable 9 Fitted
Time above 250 lx melEDI during wake Participant-day 140 755 755 Unavailable 9 Fitted
Time below 10 lx melEDI before sleep Participant-day 141 780 780 Unavailable 9 Fitted
Time below 1 lx melEDI during sleep Participant-day 141 790 790 Unavailable 9 Fitted
Longest continuous period above 250 lx melEDI Participant-day 141 811 811 Unavailable 9 Fitted
Midpoint of the brightest 10 hours Participant-day 141 811 811 Unavailable 9 Fitted
Midpoint of the darkest 10 hours Participant-day 141 811 811 Unavailable 9 Fitted
Mean timing of exposure above 250 lx melEDI Participant-day 141 778 778 Unavailable 9 Fitted
First light timing above 250 lx melEDI Participant-day 141 778 778 Unavailable 9 Fitted
Last light timing above 250 lx melEDI Participant-day 141 778 778 Unavailable 9 Fitted
melEDI dose Participant-day 141 811 811 Unavailable 9 Fitted
Melanopic daylight efficacy ratio Participant-day 137 687 687 Unavailable 9 Fitted
Participant-level dynamics models have one fitted observation per participant; Days records the contributing repeated-day support. Unavailable support hours were not retained in the analytical rows and were not reconstructed.
exact_sample_gt(exact_sample_display(
  "alternative_preprocessing__chest__all_available"
))
Table 29: Exact fitted samples: gap-timing-unaware dataset, chest, all available.
Unit Participants Days Observations Support hours Sites Status
Interdaily stability Participant 154 897 154 Unavailable 8 Fitted
Intradaily variability Participant 154 897 154 Unavailable 8 Fitted
Mean melEDI Participant-day 154 897 897 Unavailable 8 Fitted
Brightest 10 h mean Participant-day 154 897 897 Unavailable 8 Fitted
Darkest 10 h mean Participant-day 154 897 897 Unavailable 8 Fitted
Time above 1,000 lx melEDI Participant-day 154 897 897 Unavailable 8 Fitted
Time above 250 lx melEDI during wake Participant-day 153 839 839 Unavailable 8 Fitted
Time below 10 lx melEDI before sleep Participant-day 154 867 867 Unavailable 8 Fitted
Time below 1 lx melEDI during sleep Participant-day 154 878 878 Unavailable 8 Fitted
Longest continuous period above 250 lx melEDI Participant-day 154 897 897 Unavailable 8 Fitted
Midpoint of the brightest 10 hours Participant-day 154 897 897 Unavailable 8 Fitted
Midpoint of the darkest 10 hours Participant-day 154 897 897 Unavailable 8 Fitted
Mean timing of exposure above 250 lx melEDI Participant-day 154 867 867 Unavailable 8 Fitted
First light timing above 250 lx melEDI Participant-day 154 867 867 Unavailable 8 Fitted
Last light timing above 250 lx melEDI Participant-day 154 867 867 Unavailable 8 Fitted
melEDI dose Participant-day 154 897 897 Unavailable 8 Fitted
Melanopic daylight efficacy ratio Participant-day 152 723 723 Unavailable 8 Fitted
Participant-level dynamics models have one fitted observation per participant; Days records the contributing repeated-day support. Unavailable support hours were not retained in the analytical rows and were not reconstructed.
exact_sample_gt(exact_sample_display(
  "alternative_preprocessing__glasses__paired_common_sample"
))
Table 30: Exact fitted samples: gap-timing-unaware dataset, near eye, matched sample.
Unit Participants Days Observations Support hours Sites Status
Interdaily stability Participant 0 0 0 Unavailable 0 Non estimable
Intradaily variability Participant 0 0 0 Unavailable 0 Non estimable
Mean melEDI Participant-day 112 640 640 Unavailable 8 Fitted
Brightest 10 h mean Participant-day 112 640 640 Unavailable 8 Fitted
Darkest 10 h mean Participant-day 112 640 640 Unavailable 8 Fitted
Time above 1,000 lx melEDI Participant-day 112 640 640 Unavailable 8 Fitted
Time above 250 lx melEDI during wake Participant-day 111 592 592 Unavailable 8 Fitted
Time below 10 lx melEDI before sleep Participant-day 112 615 615 Unavailable 8 Fitted
Time below 1 lx melEDI during sleep Participant-day 112 621 621 Unavailable 8 Fitted
Longest continuous period above 250 lx melEDI Participant-day 112 640 640 Unavailable 8 Fitted
Midpoint of the brightest 10 hours Participant-day 112 640 640 Unavailable 8 Fitted
Midpoint of the darkest 10 hours Participant-day 112 640 640 Unavailable 8 Fitted
Mean timing of exposure above 250 lx melEDI Participant-day 112 603 603 Unavailable 8 Fitted
First light timing above 250 lx melEDI Participant-day 112 603 603 Unavailable 8 Fitted
Last light timing above 250 lx melEDI Participant-day 112 603 603 Unavailable 8 Fitted
melEDI dose Participant-day 112 640 640 Unavailable 8 Fitted
Melanopic daylight efficacy ratio Participant-day 107 478 478 Unavailable 8 Fitted
Participant-level dynamics models have one fitted observation per participant; Days records the contributing repeated-day support. Unavailable support hours were not retained in the analytical rows and were not reconstructed.
exact_sample_gt(exact_sample_display(
  "alternative_preprocessing__chest__paired_common_sample"
))
Table 31: Exact fitted samples: gap-timing-unaware dataset, chest, matched sample.
Unit Participants Days Observations Support hours Sites Status
Interdaily stability Participant 0 0 0 Unavailable 0 Non estimable
Intradaily variability Participant 0 0 0 Unavailable 0 Non estimable
Mean melEDI Participant-day 112 640 640 Unavailable 8 Fitted
Brightest 10 h mean Participant-day 112 640 640 Unavailable 8 Fitted
Darkest 10 h mean Participant-day 112 640 640 Unavailable 8 Fitted
Time above 1,000 lx melEDI Participant-day 112 640 640 Unavailable 8 Fitted
Time above 250 lx melEDI during wake Participant-day 111 592 592 Unavailable 8 Fitted
Time below 10 lx melEDI before sleep Participant-day 112 615 615 Unavailable 8 Fitted
Time below 1 lx melEDI during sleep Participant-day 112 621 621 Unavailable 8 Fitted
Longest continuous period above 250 lx melEDI Participant-day 112 640 640 Unavailable 8 Fitted
Midpoint of the brightest 10 hours Participant-day 112 640 640 Unavailable 8 Fitted
Midpoint of the darkest 10 hours Participant-day 112 640 640 Unavailable 8 Fitted
Mean timing of exposure above 250 lx melEDI Participant-day 112 603 603 Unavailable 8 Fitted
First light timing above 250 lx melEDI Participant-day 112 603 603 Unavailable 8 Fitted
Last light timing above 250 lx melEDI Participant-day 112 603 603 Unavailable 8 Fitted
melEDI dose Participant-day 112 640 640 Unavailable 8 Fitted
Melanopic daylight efficacy ratio Participant-day 107 478 478 Unavailable 8 Fitted
Participant-level dynamics models have one fitted observation per participant; Days records the contributing repeated-day support. Unavailable support hours were not retained in the analytical rows and were not reconstructed.

Preregistration deviations

deviations |>
  rename(
    Topic = topic,
    `Preregistered or expected` = registered_or_expected,
    `Analysis used` = analysis_used
  ) |>
  h01_gt() |>
  cols_width(
    Topic ~ px(200),
    `Preregistered or expected` ~ px(430),
    `Analysis used` ~ px(540)
  ) |>
  tab_source_note(
    md(
      "The table distinguishes the preregistered or expected analysis from the method used here."
    )
  )
Table 32: H01 changes relative to the preregistered or otherwise expected analysis.
Topic Preregistered or expected Analysis used
Placement Chest measurements were primary and near-eye measurements a robustness repeat. Near-eye measurements are primary; chest measurements are complementary and placements are not pooled.
Inclusion and support Protocol eligibility and fixed daily/hourly coverage exclusions defined the sample. Verified participant-day coverage is followed by metric-specific support; every fitted model reports its exact rows and support hours.
Sleep and non-wear Logged non-wear and sleep exclusions were applied without a fully specified state hierarchy. Diary sleep has precedence, invalid non-wear is masked consistently, and sleep measurements are described as the bedside environment rather than ocular exposure.
Upper light boundary Values above 120,000 lx were to be removed. The verified analytical melEDI signal retains values strictly below 100,000 lx.
Darkest-window level The level metric used the five darkest hours. The specified metric is the mean melEDI during the darkest 10 hours.
Threshold timing The timing outcome was the midpoint of the longest period above 250 lx. The registered midpoint of the longest qualifying period is retained. Mean timing above 250 lx melEDI is a distinct circular duration-weighted metric and is labelled as an adapted sensitivity; period construction follows verified continuity and support rules.
Site and latitude One model included both site and latitude. Because each site has one latitude, fixed-site and linear-latitude models are fitted separately on identical rows and compared for adequacy.
Photoperiod scope Photoperiod adjustment was specified for duration metrics. Photoperiod is included in the common model implementation for all 17 metrics.
Response models Linear mixed models were specified generically. Each metric uses its specified Gaussian transformation or Tweedie log-link response model.
Multiplicity False-discovery-rate control was required within H1 but the exact vectors were not specified. Four separate complete 17-test Benjamini–Hochberg families are used for site, photoperiod, latitude, and site-versus-latitude adequacy.
Site follow-ups Site coefficients were reported without a fixed hierarchical follow-up rule. Only after a supported overall site test, each site is compared with the equally weighted overall site mean and the site contrasts are adjusted within metric.
Variation, uncertainty, and exact samples Conditional R² and significance-dependent component summaries were used without joint interval estimation or exact model-specific sample reporting. Marginal and conditional R², participant-associated share, and non-overlapping term part-R² summaries use 1,000 successful joint bootstrap refits in the full run and 95% intervals; exact model-specific samples are reported.
Model comparison Fixed-effect structures had been compared using REML-derived criteria. Gaussian fixed-effect comparisons use maximum likelihood; final Gaussian estimation uses REML where applicable.
Full-day construct The intended relation between worn exposure and sleep-period environmental measurement was implicit. The 24-hour record retains both constructs but keeps their interpretations distinct.
Melanopic daylight efficacy ratio MDER summarizes momentary melEDI-to-photopic-illuminance ratios; the preregistration did not specify a ratio-of-integrals definition. MDER is the arithmetic mean of viable one-minute melEDI-to-photopic-illuminance ratios. Both channels must be finite and strictly positive, and at least 720 viable minutes are required on the complete 1,440-minute local wall-clock grid.
Interdaily stability and intradaily variability Incomplete repeated-day support could enter the dynamics metrics. Dynamics metrics use verified temporal support and report participant-level model rows plus contributing participant-days.
Windows, periods, and timing Missing intervals could be bridged or incomplete windows summarized. Windows and continuous periods use support, continuity, gap, wrapping, and tie rules fixed before modelling.
melEDI dose Dose could be a partial sum without a defined support denominator. Dose is time-sensitive and retained only with the specified interval support.
Time axes and source epochs A single local time axis and one assumed source epoch were used. Absolute time governs ordering and duration, local wall time governs clock metrics, repeated fall-back bins are handled explicitly, and each source epoch is respected.
Participant-day plausibility The preregistration uses coverage and signal-validity rules but does not specify exclusion of an otherwise eligible complete exact-zero melEDI day. An otherwise eligible participant-day is excluded only when every finite one-minute melEDI value is exactly 0 lx; individual zeros remain valid and retaining these days is a sensitivity analysis.
Pre-sleep duration The label implied a single three-hour window before sleep. The outcome is calendar-day cumulative time below 10 lx melEDI across every diary-defined pre-sleep interval; values strictly above six hours trigger a diagnostic warning but are not capped.
Darkest-10-hour midpoint The clock response was linearized around noon. The primary conversion subtracts 24 hours only for values strictly after 16:00; the noon cut is a registered same-row sensitivity.
The table distinguishes the preregistered or expected analysis from the method used here.

Preregistration deviations explains the scientific changes across analyses.

Response and model-family registry

metric_registry |>
  arrange(.data$metric_order) |>
  transmute(
    Category = recode(.data$manuscript_category, !!!category_labels),
    Metric = .data$manuscript_name,
    Unit = .data$display_unit,
    `Model unit` = .data$analysis_unit_label,
    `Response family` = .data$family_label
  ) |>
  h01_gt(groupname_col = "Category", rowname_col = "Metric") |>
  cols_width(Metric ~ px(300), everything() ~ px(190))
Table 33: H01 response package and fitted response families.
Unit Model unit Response family
Dynamics
Interdaily stability dimensionless Participant Gaussian after logit transformation
Intradaily variability dimensionless Participant Gaussian on the identity scale
Level
Mean melEDI lx Participant-day Gaussian after log10(value + 0.1)
Brightest 10 h mean lx Participant-day Gaussian after log10(value + 0.1)
Darkest 10 h mean lx Participant-day Gaussian after log10(value + 0.1)
Duration
Time above 1,000 lx melEDI h Participant-day Tweedie with log link
Time above 250 lx melEDI during wake h Participant-day Tweedie with log link
Time below 10 lx melEDI before sleep h Participant-day Gaussian on the identity scale
Time below 1 lx melEDI during sleep h Participant-day Tweedie with log link
Longest continuous period above 250 lx melEDI h Participant-day Gaussian after log10(value + 0.1)
Timing
Midpoint of the brightest 10 hours clock time Participant-day Gaussian on the linear clock scale
Midpoint of the darkest 10 hours clock time Participant-day Gaussian on the linear clock scale (strict after-16:00 cut)
Mean timing of exposure above 250 lx melEDI clock time Participant-day Gaussian on the linear clock scale
First light timing above 250 lx melEDI clock time Participant-day Gaussian on the linear clock scale
Last light timing above 250 lx melEDI clock time Participant-day Gaussian on the linear clock scale
Exposure history
melEDI dose lx·h Participant-day Gaussian after log10(value + 0.1)
Spectrum
Melanopic daylight efficacy ratio dimensionless Participant-day Gaussian on the identity scale

Exact formulas and model engines

Show exact formulas and model engines

The displayed FDR adjustment uses the Benjamini-Hochberg method separately across each complete 17-test family. The evaluated objects below retain the exact Wilkinson formulas, engines, and estimation methods supplied by the H01 implementation; they are placed here so implementation detail does not interrupt the principal result flow.

Participant-level formula objects

participant_formulas <- h01_formula_set("participant")
participant_formula_objects <- participant_formulas[c(
  "site_full",
  "no_site",
  "no_photoperiod",
  "latitude_full",
  "random_site"
)]
formula_gt(formula_display(participant_formula_objects))
Table 34: Exact participant-level Wilkinson formulas used by the H01 implementation.
Model Formula
Site full response_value ~ site + photoperiod_centered_hours
No site response_value ~ photoperiod_centered_hours
No photoperiod response_value ~ site
Latitude full response_value ~ absolute_latitude_10deg_centered + photoperiod_centered_hours
Random site response_value ~ photoperiod_centered_hours + (1 | site)
These strings are produced directly from the evaluated R formula objects; the transient R environments are intentionally not printed.

Participant-day formula objects

participant_day_formulas <- h01_formula_set("participant_day")
participant_day_formula_objects <- participant_day_formulas[c(
  "site_full",
  "no_site",
  "no_photoperiod",
  "latitude_full",
  "random_site"
)]
formula_gt(formula_display(participant_day_formula_objects))
Table 35: Exact participant-day Wilkinson formulas used by the H01 implementation.
Model Formula
Site full response_value ~ site + photoperiod_centered_hours + (1 | participant_key)
No site response_value ~ photoperiod_centered_hours + (1 | participant_key)
No photoperiod response_value ~ site + (1 | participant_key)
Latitude full response_value ~ absolute_latitude_10deg_centered + photoperiod_centered_hours + (1 | participant_key)
Random site response_value ~ photoperiod_centered_hours + (1 | site) + (1 | participant_key)
These strings are produced directly from the evaluated R formula objects; the transient R environments are intentionally not printed.

Preregistered-scope sensitivity formula objects

participant_scope_formulas <- h01_formula_set(
  "participant",
  preregistered_scope = TRUE
)
participant_day_scope_formulas <- h01_formula_set(
  "participant_day",
  preregistered_scope = TRUE
)
participant_scope_formula_objects <- participant_scope_formulas[c(
    "site_full", "no_site", "no_photoperiod", "latitude_full", "random_site"
  )]
participant_day_scope_formula_objects <- participant_day_scope_formulas[c(
    "site_full", "no_site", "no_photoperiod", "latitude_full", "random_site"
  )]
bind_rows(
  formula_display(participant_scope_formula_objects, "Participant"),
  formula_display(participant_day_scope_formula_objects, "Participant-day")
) |>
  formula_gt()
Table 36: Exact Wilkinson formulas used in the preregistered-scope sensitivity.
Model unit Model Formula
Participant Site full response_value ~ site
Participant No site response_value ~ 1
Participant No photoperiod response_value ~ site
Participant Latitude full response_value ~ absolute_latitude_10deg_centered
Participant Random site response_value ~ 1 + (1 | site)
Participant-day Site full response_value ~ site + (1 | participant_key)
Participant-day No site response_value ~ 1 + (1 | participant_key)
Participant-day No photoperiod response_value ~ site + (1 | participant_key)
Participant-day Latitude full response_value ~ absolute_latitude_10deg_centered + (1 | participant_key)
Participant-day Random site response_value ~ 1 + (1 | site) + (1 | participant_key)
These strings are produced directly from the evaluated R formula objects; the transient R environments are intentionally not printed.
formula_specification |>
  mutate(
    `Model unit` = recode(
      .data$analysis_unit,
      participant = "Participant",
      participant_day = "Participant-day"
    ),
    Model = str_replace_all(.data$model_name, "_", " "),
    Role = str_to_sentence(str_replace_all(.data$estimation_stage, "_", " ")),
    Method = str_to_sentence(str_replace_all(.data$estimation_method, "_", " ")),
    Family = paste(.data$family, .data$link, sep = " / ")
  ) |>
  select(
    `Model unit`, Model, Role, Formula = formula, Engine = engine,
    Method, Family
  ) |>
  h01_gt(groupname_col = "Model unit") |>
  cols_width(
    Model ~ px(150),
    Role ~ px(100),
    Formula ~ px(380),
    Engine ~ px(90),
    Method ~ px(190),
    Family ~ px(150)
  )
Table 37: Formula, engine, and estimation method recorded for every fitted and comparison model.
Model Role Formula Engine Method Family
Participant
latitude full Comparison response_value ~ absolute_latitude_10deg_centered + photoperiod_centered_hours lm Maximum likelihood Gaussian / identity_after_declared_transformation
latitude full Final response_value ~ absolute_latitude_10deg_centered + photoperiod_centered_hours lm Maximum likelihood Gaussian / identity_after_declared_transformation
no photoperiod Comparison response_value ~ site lm Maximum likelihood Gaussian / identity_after_declared_transformation
no photoperiod Final response_value ~ site lm Maximum likelihood Gaussian / identity_after_declared_transformation
no site Comparison response_value ~ photoperiod_centered_hours lm Maximum likelihood Gaussian / identity_after_declared_transformation
no site Final response_value ~ photoperiod_centered_hours lm Maximum likelihood Gaussian / identity_after_declared_transformation
random site Comparison response_value ~ photoperiod_centered_hours + (1 | site) lm Maximum likelihood Gaussian / identity_after_declared_transformation
site full Comparison response_value ~ site + photoperiod_centered_hours lm Maximum likelihood Gaussian / identity_after_declared_transformation
site full Final response_value ~ site + photoperiod_centered_hours lm Maximum likelihood Gaussian / identity_after_declared_transformation
Participant-day
latitude full Comparison response_value ~ absolute_latitude_10deg_centered + photoperiod_centered_hours + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
latitude full Comparison response_value ~ absolute_latitude_10deg_centered + photoperiod_centered_hours + (1 | participant_key) lmer Maximum likelihood Gaussian / identity_after_declared_transformation
latitude full Final response_value ~ absolute_latitude_10deg_centered + photoperiod_centered_hours + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
latitude full Final response_value ~ absolute_latitude_10deg_centered + photoperiod_centered_hours + (1 | participant_key) lmer Restricted maximum likelihood Gaussian / identity_after_declared_transformation
no photoperiod Comparison response_value ~ site + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
no photoperiod Comparison response_value ~ site + (1 | participant_key) lmer Maximum likelihood Gaussian / identity_after_declared_transformation
no photoperiod Final response_value ~ site + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
no photoperiod Final response_value ~ site + (1 | participant_key) lmer Restricted maximum likelihood Gaussian / identity_after_declared_transformation
no site Comparison response_value ~ photoperiod_centered_hours + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
no site Comparison response_value ~ photoperiod_centered_hours + (1 | participant_key) lmer Maximum likelihood Gaussian / identity_after_declared_transformation
no site Final response_value ~ photoperiod_centered_hours + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
no site Final response_value ~ photoperiod_centered_hours + (1 | participant_key) lmer Restricted maximum likelihood Gaussian / identity_after_declared_transformation
random site Comparison response_value ~ photoperiod_centered_hours + (1 | site) + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
random site Comparison response_value ~ photoperiod_centered_hours + (1 | site) + (1 | participant_key) lmer Maximum likelihood Gaussian / identity_after_declared_transformation
site full Comparison response_value ~ site + photoperiod_centered_hours + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
site full Comparison response_value ~ site + photoperiod_centered_hours + (1 | participant_key) lmer Maximum likelihood Gaussian / identity_after_declared_transformation
site full Final response_value ~ site + photoperiod_centered_hours + (1 | participant_key) glmmTMB Maximum likelihood Tweedie / log
site full Final response_value ~ site + photoperiod_centered_hours + (1 | participant_key) lmer Restricted maximum likelihood Gaussian / identity_after_declared_transformation

Analysis record and source data

Each plotted figure has a paired CSV, and the publication tables are read from the corresponding H01 result CSVs without refitting or resampling.