H03: Self-reported light source and measured personal light exposure

This analysis relates hourly reported light-source context to measured melanopic light exposure. It estimates population-average associations, checks sensor placement and alternative preprocessing, and explores clock-time patterns. The near-eye analysis is primary; chest measurements are complementary.

Data and model guide

The questionnaire and diary preparation and hourly analysis datasets link each retained light-source report to an eligible participant-hour. The response is the zero-aware geometric mean melanopic EDI in lux. An hour requires at least 30 valid minutes; exact zeros remain valid. Each retained hour has one analysis category, and indoor electric light is the reference category.

Population-mean models use a log link with a fixed-power quasi-Tweedie working variance. Participant-cluster-robust covariance supplies uncertainty for repeated hours. The additive category comparison and the category-by-site interaction answer separate questions. Site-average estimates weight represented sites equally. A pooled category requires at least 200 hours, 20 participants and three sites; a site-category contrast additionally requires 20 hours, five participants and five participants shared with the reference category. Sparse cells do not receive unsupported estimates.

The following sections report the omnibus restrictions, declared multiplicity families, exact fitted samples and category contrasts. Residual zero mass and temporal dependence remain relevant qualifications. Alternative preprocessing, matched sensor hours, category composition, participant and site influence are evaluated separately. The participant-intercept decomposition and nonlinear clock-time models are exploratory extensions, with their own estimands and diagnostics.

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

Setup

Shared helpers define category coding, covariance estimation, prediction contrasts and plotting. The computations below start from the hourly model-ready data generated by the preparation notebooks.

source("scripts/project.R")
analysis_setup()
source("scripts/pipeline/paths_io.R")
source("scripts/pipeline/p_value_display.R")
source("scripts/hypotheses/H03/h03_contract.R")
source("scripts/hypotheses/H03/h03_data.R")
source("scripts/hypotheses/H03/h03_modeling.R")
source("scripts/hypotheses/H03/h03_temporal.R")
source("scripts/hypotheses/H03/h03_reporting.R")
root <- normalizePath(Sys.getenv("QUARTO_PROJECT_DIR", unset = getwd()))
producer <- "analyses/H03-light-source.qmd"
options(stringsAsFactors = FALSE, scipen = 999, dplyr.summarise.inform = FALSE)
roots <- list(model_data = file.path(root,"results/intermediate/model_data/H03"), models = file.path(root,"results/models/H03"), diagnostics = file.path(root,"results/csv/diagnostics/H03"), tables = file.path(root,"results/tables/H03"), figures = file.path(root,"results/images/H03"), source_data = file.path(root,"results/csv/source_data/H03"))
invisible(lapply(roots, dir.create, recursive=TRUE, showWarnings=FALSE))
h03_write_csv <- function(data, path, id) {
  write_csv_artifact(data, path, producer)
  invisible(path)
}

h03_write_rds <- function(object, path, id) {
  write_rds_artifact(object, path, producer)
  invisible(path)
}

Prepare hourly analysis samples

Join each eligible diary hour to the matching light measurement. Require at least 30 valid minutes, preserve zero measurements, and construct primary, paired-sensor, alternative-preprocessing and boundary-exclusion samples.

inputs <- h03_load_inputs(root)

spec <- h03_specification()

formulas <- h03_formula_set()

multiplicity <- h03_multiplicity_registry()

diary <- h03_prepare_diary(
  inputs$diary,
  inputs$categories,
  inputs$sites
)

near_prepared <- h03_prepare_primary_frame(
  inputs$near_eye,
  diary,
  "Near-eye",
  inputs$categories,
  inputs$sites
)

chest_prepared <- h03_prepare_primary_frame(
  inputs$chest,
  diary,
  "Chest",
  inputs$categories,
  inputs$sites
)

main_frames <- list(
  near_eye = near_prepared$frame,
  chest = chest_prepared$frame
)

paired_frames <- h03_prepare_paired_frames(
  main_frames$near_eye,
  main_frames$chest
)

paired_frames <- lapply(paired_frames, h03_add_ar_sequences)

gap_frames_raw <- h03_prepare_gap_frames(
  inputs$gap_timing_unaware,
  diary,
  inputs$categories,
  inputs$sites
)

gap_frames <- list(
  near_eye = gap_frames_raw$glasses,
  chest = gap_frames_raw$chest
)

boundary_frames <- lapply(
  main_frames,
  function(frame) h03_add_ar_sequences(h03_exclude_boundary_hours(frame))
)

supported_cell_frames <- lapply(
  main_frames,
  function(frame) {
    h03_add_ar_sequences(h03_exclude_unsupported_cells(
      frame,
      inputs$categories,
      inputs$sites,
      spec
    ))
  }
)
dplyr::bind_rows(lapply(main_frames, function(x) tibble::tibble(hours=nrow(x), participants=dplyr::n_distinct(x$participant))))
# A tibble: 2 × 2
  hours participants
  <int>        <int>
1 17935          140
2 19512          151

Fit population-average and site-specific associations

The primary quasi-Tweedie log-link model estimates the mean hourly response with participant-clustered covariance. Site interaction representations are evaluated using design rank, covariance conditioning, leverage and support criteria before interpreting their estimates. These are statistical estimability checks.

interaction_check <- list(
 near_eye=h03_run_interaction_check(main_frames$near_eye,"Near-eye",inputs$categories,inputs$sites,spec),
 chest=h03_run_interaction_check(main_frames$chest,"Chest",inputs$categories,inputs$sites,spec))
h03_write_rds(interaction_check,file.path(roots$models,"H03_interaction_check_models.rds"),"interaction_models")
h03_write_csv(dplyr::bind_rows(interaction_check$near_eye$estimability_check,interaction_check$chest$estimability_check),file.path(roots$diagnostics,"H03_interaction_architecture_check.csv"),"interaction_diagnostics")
main_near <- h03_fit_additive_run(
  main_frames$near_eye,
  "main__near_eye",
  "Near-eye",
  "primary_dataset",
  inputs$categories,
  spec,
  family_prefix = "H03-F"
)

main_near$estimands$family_id <- "H03-F2-context-contrasts"

main_near$omnibus$family_id <- "H03-F1-omnibus"

main_near$observed_weighted$family_id <-
  "H03-S5-near-eye-observed-sample-weighting"

main_chest <- h03_fit_additive_run(
  main_frames$chest,
  "main__chest",
  "Chest",
  "primary_dataset",
  inputs$categories,
  spec,
  family_prefix = "H03-C"
)

main_chest$estimands$family_id <- "H03-C2-context-contrasts"

main_chest$omnibus$family_id <- "H03-C1-omnibus"

main_chest$observed_weighted$family_id <-
  "H03-S5-chest-observed-sample-weighting"

message("Extracting selected full-interaction effects after check selection")

near_interaction <- interaction_check$near_eye

chest_interaction <- interaction_check$chest

near_site_estimands <- h03_interaction_estimands(
  near_interaction$selected_bundle,
  near_interaction$selected_architecture,
  inputs$categories,
  near_interaction$cell_support,
  "H03-F4-site-context-contrasts"
) |>
  dplyr::mutate(placement = "Near-eye", .before = 1)

chest_site_estimands <- h03_interaction_estimands(
  chest_interaction$selected_bundle,
  chest_interaction$selected_architecture,
  inputs$categories,
  chest_interaction$cell_support,
  "H03-C4-site-context-contrasts"
) |>
  dplyr::mutate(placement = "Chest", .before = 1)

near_heterogeneity <- h03_interaction_omnibus(
  near_interaction$selected_bundle,
  near_interaction$selected_restriction,
  "H03-F3-site-heterogeneity",
  near_interaction$selected_architecture
) |>
  dplyr::mutate(placement = "Near-eye", .before = 1)

chest_heterogeneity <- h03_interaction_omnibus(
  chest_interaction$selected_bundle,
  chest_interaction$selected_restriction,
  "H03-C3-site-heterogeneity",
  chest_interaction$selected_architecture
) |>
  dplyr::mutate(placement = "Chest", .before = 1)
dplyr::bind_rows(main_near$estimands,main_chest$estimands)
# A tibble: 14 × 27
   run_id       placement scenario_id working_power category_order category_code
   <chr>        <chr>     <chr>               <dbl>          <int> <chr>        
 1 main__near_… Near-eye  primary_da…          1.54              1 electric_ind…
 2 main__near_… Near-eye  primary_da…          1.54              2 electric_out…
 3 main__near_… Near-eye  primary_da…          1.54              3 daylight_ind…
 4 main__near_… Near-eye  primary_da…          1.54              4 daylight_out…
 5 main__near_… Near-eye  primary_da…          1.54              5 display      
 6 main__near_… Near-eye  primary_da…          1.54              6 sleep_darkne…
 7 main__near_… Near-eye  primary_da…          1.54              7 sleep_extern…
 8 main__chest  Chest     primary_da…          1.54              1 electric_ind…
 9 main__chest  Chest     primary_da…          1.54              2 electric_out…
10 main__chest  Chest     primary_da…          1.54              3 daylight_ind…
11 main__chest  Chest     primary_da…          1.54              4 daylight_out…
12 main__chest  Chest     primary_da…          1.54              5 display      
13 main__chest  Chest     primary_da…          1.54              6 sleep_darkne…
14 main__chest  Chest     primary_da…          1.54              7 sleep_extern…
# ℹ 21 more variables: light_source <chr>, short_label <chr>,
#   distribution <chr>, expected_mel_edi_lx <dbl>, expected_conf_low_lx <dbl>,
#   expected_conf_high_lx <dbl>, model_ratio_to_indoor <dbl>,
#   ratio_to_indoor <dbl>, ratio_conf_low <dbl>, ratio_conf_high <dbl>,
#   statistic <dbl>, df <int>, p_raw <dbl>, estimability_status <chr>,
#   hours <int>, participants <int>, participant_days <int>, sites <int>,
#   family_id <chr>, family_n <int>, p_adjusted <dbl>

Sensitivity analyses

Repeat the additive model using the same observed hour for both sensors, the alternative preprocessing baseline, boundary exclusions, support restrictions, alternative working powers and within/between participant light-source proportions.

run_registry <- tibble::tribble(
  ~run_id, ~scenario_id, ~placement, ~frame_id, ~working_power, ~formula_id,
  "paired__near_eye", "paired_common_sample", "Near-eye", "paired_near", spec$working_tweedie_power, "primary_population_mean",
  "paired__chest", "paired_common_sample", "Chest", "paired_chest", spec$working_tweedie_power, "primary_population_mean",
  "gap__near_eye", "gap_timing_unaware_dataset", "Near-eye", "gap_near", spec$working_tweedie_power, "primary_population_mean",
  "gap__chest", "gap_timing_unaware_dataset", "Chest", "gap_chest", spec$working_tweedie_power, "primary_population_mean",
  "boundary_excluded__near_eye", "boundary_hours_excluded", "Near-eye", "boundary_near", spec$working_tweedie_power, "primary_population_mean",
  "boundary_excluded__chest", "boundary_hours_excluded", "Chest", "boundary_chest", spec$working_tweedie_power, "primary_population_mean",
  "supported_cells__near_eye", "unsupported_cells_excluded", "Near-eye", "supported_near", spec$working_tweedie_power, "primary_population_mean",
  "supported_cells__chest", "unsupported_cells_excluded", "Chest", "supported_chest", spec$working_tweedie_power, "primary_population_mean",
  "power_1_30__near_eye", "working_power_1_30", "Near-eye", "main_near", 1.30, "primary_population_mean",
  "power_1_30__chest", "working_power_1_30", "Chest", "main_chest", 1.30, "primary_population_mean",
  "power_1_80__near_eye", "working_power_1_80", "Near-eye", "main_near", 1.80, "primary_population_mean",
  "power_1_80__chest", "working_power_1_80", "Chest", "main_chest", 1.80, "primary_population_mean",
  "mundlak__near_eye", "mundlak_within_between", "Near-eye", "mundlak_near", spec$working_tweedie_power, "secondary_mundlak_audit",
  "mundlak__chest", "mundlak_within_between", "Chest", "mundlak_chest", spec$working_tweedie_power, "secondary_mundlak_audit"
)

scenario_frames <- list(
  paired_near = paired_frames$near_eye,
  paired_chest = paired_frames$chest,
  gap_near = gap_frames$near_eye,
  gap_chest = gap_frames$chest,
  boundary_near = boundary_frames$near_eye,
  boundary_chest = boundary_frames$chest,
  supported_near = supported_cell_frames$near_eye,
  supported_chest = supported_cell_frames$chest,
  main_near = main_frames$near_eye,
  main_chest = main_frames$chest,
  mundlak_near = h03_add_mundlak_proportions(main_frames$near_eye, spec),
  mundlak_chest = h03_add_mundlak_proportions(main_frames$chest, spec)
)

message("Running bounded additive sensitivity fits")

sensitivity_runs <- vector("list", nrow(run_registry))

for (index in seq_len(nrow(run_registry))) {
  run <- run_registry[index, , drop = FALSE]
  message("  ", run$run_id)
  formula <- formulas[[run$formula_id]]
  sensitivity_runs[[index]] <- h03_fit_additive_run(
    scenario_frames[[run$frame_id]],
    run$run_id,
    run$placement,
    run$scenario_id,
    inputs$categories,
    spec,
    working_power = run$working_power,
    formula = formula,
    family_prefix = paste0("H03-S-", run$run_id)
  )
}

names(sensitivity_runs) <- run_registry$run_id

sensitivity_estimands <- dplyr::bind_rows(lapply(
  sensitivity_runs,
  `[[`,
  "estimands"
))

sensitivity_omnibus <- dplyr::bind_rows(lapply(
  sensitivity_runs,
  `[[`,
  "omnibus"
))

sensitivity_diagnostics <- dplyr::bind_rows(lapply(
  sensitivity_runs,
  `[[`,
  "diagnostics"
))

sensitivity_samples <- dplyr::bind_rows(lapply(
  sensitivity_runs,
  `[[`,
  "sample"
))

main_estimands <- dplyr::bind_rows(
  main_near$estimands,
  main_chest$estimands
)

main_omnibus <- dplyr::bind_rows(main_near$omnibus, main_chest$omnibus)

main_diagnostics <- dplyr::bind_rows(
  main_near$diagnostics,
  main_chest$diagnostics
)

main_samples <- dplyr::bind_rows(main_near$sample, main_chest$sample)

weighting_sensitivity <- dplyr::bind_rows(
  main_near$observed_weighted,
  main_chest$observed_weighted
)

comparison_reference <- main_estimands |>
  dplyr::select(
    .data$placement,
    .data$category_code,
    primary_ratio = .data$ratio_to_indoor,
    primary_conf_low = .data$ratio_conf_low,
    primary_conf_high = .data$ratio_conf_high,
    primary_p_adjusted = .data$p_adjusted
  )

sensitivity_comparison <- sensitivity_estimands |>
  dplyr::left_join(
    comparison_reference,
    by = c("placement", "category_code"),
    relationship = "many-to-one"
  ) |>
  dplyr::mutate(
    ratio_relative_change_percent = 100 *
      (.data$ratio_to_indoor / .data$primary_ratio - 1),
    sign_relative_to_null_concordant = sign(log(.data$ratio_to_indoor)) ==
      sign(log(.data$primary_ratio)),
    primary_ratio_inside_sensitivity_interval =
      .data$primary_ratio >= .data$ratio_conf_low &
      .data$primary_ratio <= .data$ratio_conf_high,
    stability = dplyr::case_when(
      .data$estimability_status == "SUPPORT_NON_ESTIMABLE" ~
        "support_non_estimable",
      !is.finite(.data$ratio_to_indoor) ~ "non_estimable",
      .data$sign_relative_to_null_concordant &
        abs(.data$ratio_relative_change_percent) <= 25 ~ "stable",
      .data$sign_relative_to_null_concordant ~ "magnitude_shift",
      TRUE ~ "direction_shift"
    )
  )

paired_comparison <- sensitivity_estimands |>
  dplyr::filter(.data$scenario_id == "paired_common_sample") |>
  dplyr::mutate(
    placement_id = dplyr::recode(
      .data$placement,
      `Near-eye` = "near_eye",
      Chest = "chest"
    )
  ) |>
  dplyr::select(
    .data$placement_id,
    .data$category_order,
    .data$category_code,
    .data$estimability_status,
    .data$ratio_to_indoor,
    .data$ratio_conf_low,
    .data$ratio_conf_high,
    .data$hours,
    .data$participants,
    .data$participant_days,
    .data$sites
  ) |>
  tidyr::pivot_wider(
    names_from = "placement_id",
    values_from = c(
      "estimability_status", "ratio_to_indoor", "ratio_conf_low",
      "ratio_conf_high", "hours", "participants", "participant_days",
      "sites"
    ),
    names_glue = "{.value}_{placement_id}"
  ) |>
  dplyr::transmute(
    .data$category_order,
    .data$category_code,
    near_estimability_status = .data$estimability_status_near_eye,
    chest_estimability_status = .data$estimability_status_chest,
    near_ratio = .data$ratio_to_indoor_near_eye,
    near_conf_low = .data$ratio_conf_low_near_eye,
    near_conf_high = .data$ratio_conf_high_near_eye,
    chest_ratio = .data$ratio_to_indoor_chest,
    chest_conf_low = .data$ratio_conf_low_chest,
    chest_conf_high = .data$ratio_conf_high_chest,
    paired_hours_near = .data$hours_near_eye,
    paired_hours_chest = .data$hours_chest,
    paired_participants_near = .data$participants_near_eye,
    paired_participants_chest = .data$participants_chest,
    paired_days_near = .data$participant_days_near_eye,
    paired_days_chest = .data$participant_days_chest,
    paired_sites_near = .data$sites_near_eye,
    paired_sites_chest = .data$sites_chest
  )
sensitivity_comparison
# A tibble: 98 × 35
   run_id       placement scenario_id working_power category_order category_code
   <chr>        <chr>     <chr>               <dbl>          <int> <chr>        
 1 paired__nea… Near-eye  paired_com…          1.54              1 electric_ind…
 2 paired__nea… Near-eye  paired_com…          1.54              2 electric_out…
 3 paired__nea… Near-eye  paired_com…          1.54              3 daylight_ind…
 4 paired__nea… Near-eye  paired_com…          1.54              4 daylight_out…
 5 paired__nea… Near-eye  paired_com…          1.54              5 display      
 6 paired__nea… Near-eye  paired_com…          1.54              6 sleep_darkne…
 7 paired__nea… Near-eye  paired_com…          1.54              7 sleep_extern…
 8 paired__che… Chest     paired_com…          1.54              1 electric_ind…
 9 paired__che… Chest     paired_com…          1.54              2 electric_out…
10 paired__che… Chest     paired_com…          1.54              3 daylight_ind…
# ℹ 88 more rows
# ℹ 29 more variables: light_source <chr>, short_label <chr>,
#   distribution <chr>, expected_mel_edi_lx <dbl>, expected_conf_low_lx <dbl>,
#   expected_conf_high_lx <dbl>, model_ratio_to_indoor <dbl>,
#   ratio_to_indoor <dbl>, ratio_conf_low <dbl>, ratio_conf_high <dbl>,
#   statistic <dbl>, df <int>, p_raw <dbl>, estimability_status <chr>,
#   hours <int>, participants <int>, participant_days <int>, sites <int>, …

Influence and residual checks

Delete each site in turn and the five highest-ranked participant clusters for each placement. Compare their estimates with the complete sample and inspect residual dependence and group-level residual structure.

influence_refit <- function(
  frame,
  placement,
  deletion_type,
  deletion_id,
  primary_result,
  fit_interaction = FALSE,
  primary_site_estimands = NULL,
  selected_architecture = NULL
) {
  reduced <- if (deletion_type == "site") {
    dplyr::filter(frame, as.character(.data$site) != deletion_id)
  } else {
    dplyr::filter(frame, as.character(.data$participant) != deletion_id)
  }
  reduced <- h03_add_ar_sequences(droplevels(reduced))
  run_id <- paste("influence", placement, deletion_type, deletion_id, sep = "__")
  additive <- h03_fit_additive_run(
    reduced,
    run_id,
    placement,
    paste0("delete_", deletion_type),
    inputs$categories,
    spec,
    family_prefix = paste0("H03-I-", placement, "-", deletion_type)
  )
  category <- additive$estimands |>
    dplyr::left_join(
      primary_result$estimands |>
        dplyr::select(
          .data$category_code,
          full_ratio = .data$ratio_to_indoor,
          full_conf_low = .data$ratio_conf_low,
          full_conf_high = .data$ratio_conf_high,
          full_p_adjusted = .data$p_adjusted
        ),
      by = "category_code",
      relationship = "one-to-one"
    ) |>
    dplyr::mutate(
      deletion_type = deletion_type,
      deletion_id = deletion_id,
      full_omnibus_p = primary_result$omnibus$p_raw,
      deletion_omnibus_p = additive$omnibus$p_raw,
      omnibus_decision_changed =
        (primary_result$omnibus$p_raw < 0.05) !=
        (additive$omnibus$p_raw < 0.05),
      ratio_relative_change_percent = 100 *
        (.data$ratio_to_indoor / .data$full_ratio - 1),
      full_ratio_inside_deletion_interval =
        .data$full_ratio >= .data$ratio_conf_low &
        .data$full_ratio <= .data$ratio_conf_high,
      .before = 1
    )
  site_result <- NULL
  if (fit_interaction) {
    interaction_frame <- h03_make_interaction_frame(
      reduced,
      selected_architecture,
      inputs$categories,
      spec
    )
    interaction_bundle <- h03_fit_quasi(
      h03_interaction_formula(selected_architecture),
      interaction_frame,
      spec$working_tweedie_power
    )
    restriction <- h03_interaction_restriction(
      interaction_bundle,
      selected_architecture
    )
    support <- h03_cell_support(
      reduced,
      inputs$categories,
      inputs$sites,
      spec
    )
    site_result <- h03_interaction_estimands(
      interaction_bundle,
      selected_architecture,
      inputs$categories,
      support,
      paste0("H03-I-site-context-", deletion_type)
    ) |>
      dplyr::left_join(
        primary_site_estimands |>
          dplyr::select(
            .data$site,
            .data$category_code,
            full_site_deviation = .data$site_deviation_ratio,
            full_site_conf_low = .data$site_deviation_conf_low,
            full_site_conf_high = .data$site_deviation_conf_high
          ),
        by = c("site", "category_code"),
        relationship = "one-to-one"
      ) |>
      dplyr::mutate(
        placement = placement,
        deletion_type = deletion_type,
        deletion_id = deletion_id,
        site_deviation_relative_change_percent = 100 *
          (.data$site_deviation_ratio / .data$full_site_deviation - 1),
        full_deviation_inside_deletion_interval =
          .data$full_site_deviation >= .data$site_deviation_conf_low &
          .data$full_site_deviation <= .data$site_deviation_conf_high,
        .before = 1
      )
  }
  list(
    category = category,
    site = site_result,
    diagnostics = additive$diagnostics,
    sample = additive$sample
  )
}

near_clusters <- h03_cluster_diagnostics(main_near$bundle)

chest_clusters <- h03_cluster_diagnostics(main_chest$bundle)

influence_jobs <- dplyr::bind_rows(
  tidyr::crossing(
    placement = c("Near-eye", "Chest"),
    deletion_type = "site",
    deletion_id = c(
      levels(main_near$bundle$data$site),
      setdiff(
        levels(main_chest$bundle$data$site),
        levels(main_near$bundle$data$site)
      )
    )
  ) |>
    dplyr::filter(
      (.data$placement == "Near-eye" &
        .data$deletion_id %in% levels(main_near$bundle$data$site)) |
        (.data$placement == "Chest" &
          .data$deletion_id %in% levels(main_chest$bundle$data$site))
    ),
  tibble::tibble(
    placement = "Near-eye",
    deletion_type = "participant",
    deletion_id = near_clusters$participant[1:5]
  ),
  tibble::tibble(
    placement = "Chest",
    deletion_type = "participant",
    deletion_id = chest_clusters$participant[1:5]
  )
)

influence_results <- vector("list", nrow(influence_jobs))

for (index in seq_len(nrow(influence_jobs))) {
  job <- influence_jobs[index, , drop = FALSE]
  message(
    "  ", job$placement, " delete ", job$deletion_type, ": ",
    job$deletion_id
  )
  is_near <- job$placement == "Near-eye"
  influence_results[[index]] <- influence_refit(
    frame = if (is_near) main_frames$near_eye else main_frames$chest,
    placement = job$placement,
    deletion_type = job$deletion_type,
    deletion_id = job$deletion_id,
    primary_result = if (is_near) main_near else main_chest,
    fit_interaction = is_near,
    primary_site_estimands = if (is_near) near_site_estimands else NULL,
    selected_architecture = if (is_near) {
      near_interaction$selected_architecture
    } else {
      NULL
    }
  )
}

influence_category <- dplyr::bind_rows(lapply(
  influence_results,
  `[[`,
  "category"
))

influence_site <- dplyr::bind_rows(lapply(
  influence_results,
  `[[`,
  "site"
))

influence_diagnostics <- dplyr::bind_rows(lapply(
  influence_results,
  `[[`,
  "diagnostics"
))

influence_samples <- dplyr::bind_rows(lapply(
  influence_results,
  `[[`,
  "sample"
))
model_diagnostics <- dplyr::bind_rows(
  main_diagnostics,
  sensitivity_diagnostics,
  influence_diagnostics
)

residual_groups <- dplyr::bind_rows(
  h03_residual_group_summary(main_near$bundle, "main__near_eye") |>
    dplyr::mutate(placement = "Near-eye", .after = "run_id"),
  h03_residual_group_summary(main_chest$bundle, "main__chest") |>
    dplyr::mutate(placement = "Chest", .after = "run_id")
)

residual_plot_data <- dplyr::bind_rows(
  h03_residual_plot_data(main_near$bundle, "main__near_eye") |>
    dplyr::mutate(placement = "Near-eye", .after = "run_id"),
  h03_residual_plot_data(main_chest$bundle, "main__chest") |>
    dplyr::mutate(placement = "Chest", .after = "run_id")
)

cluster_diagnostics <- dplyr::bind_rows(
  near_clusters |>
    dplyr::mutate(run_id = "main__near_eye", placement = "Near-eye", .before = 1),
  chest_clusters |>
    dplyr::mutate(run_id = "main__chest", placement = "Chest", .before = 1)
)

main_tests <- dplyr::bind_rows(
  main_omnibus,
  near_heterogeneity,
  chest_heterogeneity
)

all_samples <- dplyr::bind_rows(
  main_samples,
  sensitivity_samples,
  influence_samples
)

all_category_estimands <- dplyr::bind_rows(
  main_estimands,
  sensitivity_estimands,
  weighting_sensitivity
)

all_site_estimands <- dplyr::bind_rows(
  near_site_estimands,
  chest_site_estimands
)

formula_registry <- tibble::tibble(
  formula_id = names(formulas),
  formula = vapply(
    formulas,
    function(value) paste(deparse(value), collapse = " "),
    character(1)
  )
)
influence_category
# A tibble: 189 × 38
   deletion_type deletion_id full_omnibus_p deletion_omnibus_p
   <chr>         <chr>                <dbl>              <dbl>
 1 site          BAUA              6.57e-45           3.92e-37
 2 site          BAUA              6.57e-45           3.92e-37
 3 site          BAUA              6.57e-45           3.92e-37
 4 site          BAUA              6.57e-45           3.92e-37
 5 site          BAUA              6.57e-45           3.92e-37
 6 site          BAUA              6.57e-45           3.92e-37
 7 site          BAUA              6.57e-45           3.92e-37
 8 site          FUSPCEU           6.57e-45           1.63e-40
 9 site          FUSPCEU           6.57e-45           1.63e-40
10 site          FUSPCEU           6.57e-45           1.63e-40
# ℹ 179 more rows
# ℹ 34 more variables: omnibus_decision_changed <lgl>,
#   ratio_relative_change_percent <dbl>,
#   full_ratio_inside_deletion_interval <lgl>, run_id <chr>, placement <chr>,
#   scenario_id <chr>, working_power <dbl>, category_order <int>,
#   category_code <chr>, light_source <chr>, short_label <chr>,
#   distribution <chr>, expected_mel_edi_lx <dbl>, …

Export fitted models and numerical results

Save the exact fitted frames, models, estimates, checks and plot data for subsequent manuscript and supplementary tables.

Export results
h03_write_csv(multiplicity, file.path(roots$model_data, "H03_multiplicity_registry.csv"), "multiplicity")

h03_write_csv(formula_registry, file.path(roots$model_data, "H03_formula_registry.csv"), "formulas")

h03_write_csv(run_registry, file.path(roots$model_data, "H03_sensitivity_run_registry.csv"), "run_registry")

h03_write_csv(all_samples, file.path(roots$model_data, "H03_model_frame_index.csv"), "samples")

h03_write_csv(
  dplyr::bind_rows(
    h03_category_support(main_frames$near_eye, inputs$categories, spec) |>
      dplyr::mutate(placement = "Near-eye", .before = 1),
    h03_category_support(main_frames$chest, inputs$categories, spec) |>
      dplyr::mutate(placement = "Chest", .before = 1)
  ),
  file.path(roots$model_data, "H03_category_support.csv"),
  "category_support"
)

h03_write_csv(
  dplyr::bind_rows(
    h03_cell_support(main_frames$near_eye, inputs$categories, inputs$sites, spec) |>
      dplyr::select(-"participant_ids", -"reference_ids") |>
      dplyr::mutate(placement = "Near-eye", .before = 1),
    h03_cell_support(main_frames$chest, inputs$categories, inputs$sites, spec) |>
      dplyr::select(-"participant_ids", -"reference_ids") |>
      dplyr::mutate(placement = "Chest", .before = 1)
  ),
  file.path(roots$model_data, "H03_site_category_support.csv"),
  "cell_support"
)

h03_write_rds(
  list(
    main = main_frames,
    paired = paired_frames,
    gap_timing_unaware = gap_frames,
    boundary_excluded = boundary_frames,
    supported_cells_only = supported_cell_frames
  ),
  file.path(roots$model_data, "H03_model_frames.rds"),
  "model_frames"
)

h03_write_rds(
  list(
    main_near_eye = main_near$bundle,
    main_chest = main_chest$bundle,
    sensitivities = lapply(sensitivity_runs, `[[`, "bundle")
  ),
  file.path(roots$models, "H03_additive_model_objects.rds"),
  "additive_models"
)

h03_write_csv(main_estimands, file.path(roots$tables, "H03_primary_category_estimands.csv"), "primary_estimands")

h03_write_csv(all_category_estimands, file.path(roots$tables, "H03_all_category_estimands.csv"), "all_estimands")

h03_write_csv(main_tests, file.path(roots$tables, "H03_primary_omnibus_tests.csv"), "primary_tests")

h03_write_csv(all_site_estimands, file.path(roots$tables, "H03_site_context_estimands.csv"), "site_estimands")

h03_write_csv(sensitivity_comparison, file.path(roots$tables, "H03_sensitivity_comparison.csv"), "sensitivity_comparison")

h03_write_csv(sensitivity_omnibus, file.path(roots$tables, "H03_sensitivity_omnibus_tests.csv"), "sensitivity_tests")

h03_write_csv(paired_comparison, file.path(roots$tables, "H03_paired_placement_comparison.csv"), "paired")

h03_write_csv(influence_category, file.path(roots$tables, "H03_influence_category_refits.csv"), "influence_category")

h03_write_csv(influence_site, file.path(roots$tables, "H03_influence_site_context_refits.csv"), "influence_site")

h03_write_csv(model_diagnostics, file.path(roots$diagnostics, "H03_model_diagnostics.csv"), "diagnostics")

h03_write_csv(residual_groups, file.path(roots$diagnostics, "H03_residual_group_summary.csv"), "residual_groups")

h03_write_csv(residual_plot_data, file.path(roots$source_data, "H03_primary_residual_plot_data.csv"), "residual_plot_data")

h03_write_csv(cluster_diagnostics, file.path(roots$diagnostics, "H03_cluster_influence_scores.csv"), "cluster_scores")

h03_write_csv(influence_jobs, file.path(roots$diagnostics, "H03_influence_refit_registry.csv"), "influence_jobs")

primary_figure_source <- main_estimands

site_figure_source <- near_site_estimands

paired_figure_source <- paired_comparison

h03_write_csv(primary_figure_source, file.path(roots$source_data, "H03_primary_category_figure_data.csv"), "primary_figure_source")

h03_write_csv(site_figure_source, file.path(roots$source_data, "H03_near_eye_site_context_figure_data.csv"), "site_figure_source")

h03_write_csv(paired_figure_source, file.path(roots$source_data, "H03_paired_placement_figure_data.csv"), "paired_figure_source")

message("Creating bounded, final-size H03 figures")

primary_plot <- h03_primary_category_figure(
  primary_figure_source,
  inputs$categories
)

site_plot <- h03_site_context_figure(
  site_figure_source,
  inputs$categories,
  inputs$sites
)

paired_plot <- h03_paired_placement_figure(
  paired_figure_source,
  inputs$categories
)

residual_plot <- h03_residual_figure(residual_plot_data)

h03_save_plot(
  primary_plot,
  "H03_primary_category_estimates",
  roots$figures,
  width = 12,
  height = 10,
  producer = producer
)
$png
$png$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_primary_category_estimates.png"

$png$bytes
[1] 206587

$png$producer
[1] "analyses/H03-light-source.qmd"

$png$r_version
[1] "4.6.1"

$png$written_utc
[1] "2026-09-22 12:12:14 UTC"


$pdf
$pdf$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_primary_category_estimates.pdf"

$pdf$bytes
[1] 8081

$pdf$producer
[1] "analyses/H03-light-source.qmd"

$pdf$r_version
[1] "4.6.1"

$pdf$written_utc
[1] "2026-09-22 12:12:14 UTC"


$svg
$svg$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_primary_category_estimates.svg"

$svg$bytes
[1] 27123

$svg$producer
[1] "analyses/H03-light-source.qmd"

$svg$r_version
[1] "4.6.1"

$svg$written_utc
[1] "2026-09-22 12:12:14 UTC"
Export results
h03_save_plot(
  site_plot,
  "H03_near_eye_site_context_estimates",
  roots$figures,
  width = 15,
  height = 10,
  producer = producer
)
$png
$png$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_near_eye_site_context_estimates.png"

$png$bytes
[1] 374529

$png$producer
[1] "analyses/H03-light-source.qmd"

$png$r_version
[1] "4.6.1"

$png$written_utc
[1] "2026-09-22 12:12:14 UTC"


$pdf
$pdf$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_near_eye_site_context_estimates.pdf"

$pdf$bytes
[1] 11556

$pdf$producer
[1] "analyses/H03-light-source.qmd"

$pdf$r_version
[1] "4.6.1"

$pdf$written_utc
[1] "2026-09-22 12:12:15 UTC"


$svg
$svg$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_near_eye_site_context_estimates.svg"

$svg$bytes
[1] 55313

$svg$producer
[1] "analyses/H03-light-source.qmd"

$svg$r_version
[1] "4.6.1"

$svg$written_utc
[1] "2026-09-22 12:12:15 UTC"
Export results
h03_save_plot(
  paired_plot,
  "H03_paired_placement_comparison",
  roots$figures,
  width = 8,
  height = 8,
  producer = producer
)
$png
$png$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_paired_placement_comparison.png"

$png$bytes
[1] 132540

$png$producer
[1] "analyses/H03-light-source.qmd"

$png$r_version
[1] "4.6.1"

$png$written_utc
[1] "2026-09-22 12:12:15 UTC"


$pdf
$pdf$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_paired_placement_comparison.pdf"

$pdf$bytes
[1] 5534

$pdf$producer
[1] "analyses/H03-light-source.qmd"

$pdf$r_version
[1] "4.6.1"

$pdf$written_utc
[1] "2026-09-22 12:12:15 UTC"


$svg
$svg$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_paired_placement_comparison.svg"

$svg$bytes
[1] 9432

$svg$producer
[1] "analyses/H03-light-source.qmd"

$svg$r_version
[1] "4.6.1"

$svg$written_utc
[1] "2026-09-22 12:12:15 UTC"
Export results
h03_save_plot(
  residual_plot,
  "H03_primary_residual_diagnostics",
  roots$figures,
  width = 11,
  height = 6.5,
  producer = producer
)
$png
$png$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_primary_residual_diagnostics.png"

$png$bytes
[1] 260751

$png$producer
[1] "analyses/H03-light-source.qmd"

$png$r_version
[1] "4.6.1"

$png$written_utc
[1] "2026-09-22 12:12:15 UTC"


$pdf
$pdf$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_primary_residual_diagnostics.pdf"

$pdf$bytes
[1] 6460

$pdf$producer
[1] "analyses/H03-light-source.qmd"

$pdf$r_version
[1] "4.6.1"

$pdf$written_utc
[1] "2026-09-22 12:12:15 UTC"


$svg
$svg$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_primary_residual_diagnostics.svg"

$svg$bytes
[1] 13358

$svg$producer
[1] "analyses/H03-light-source.qmd"

$svg$r_version
[1] "4.6.1"

$svg$written_utc
[1] "2026-09-22 12:12:16 UTC"

Observed zeros and model calibration

Quantify observed zero mass and compare it with the working distribution. Check the relationship between response-scale and link-scale site averaging, and inspect residual dependence.

model_path <- file.path(
  root,
  "results/models/H03/H03_additive_model_objects.rds"
)

if (!file.exists(model_path)) {
  h03_abort("Run H03 primary models before post-fit diagnostics")
}

objects <- readRDS(model_path)

models <- list(
  near_eye = objects$main_near_eye,
  chest = objects$main_chest
)

placements <- c(near_eye = "Near-eye", chest = "Chest")

h03_residual_rows <- function(object, placement) {
  tibble::tibble(
    placement = placement,
    observation_index = seq_len(nrow(object$data)),
    fitted_mean = as.numeric(stats::fitted(object$fit)),
    pearson_residual = as.numeric(stats::residuals(
      object$fit,
      type = "pearson"
    )),
    deviance_residual = as.numeric(stats::residuals(
      object$fit,
      type = "deviance"
    )),
    observed_mel_edi_lx = object$data$geo_medi_1h,
    observed_zero = object$data$geo_medi_1h == 0,
    source_model = "geo_medi_1h ~ site + light_source"
  )
}

h03_residual_acf_rows <- function(object, placement, maximum_lag = 6L) {
  residual <- stats::residuals(object$fit, type = "pearson")
  dplyr::bind_rows(lapply(seq_len(maximum_lag), function(lag) {
    estimate <- h03_boundary_lag_correlation(
      residual,
      object$data$AR_start,
      lag = lag
    )
    tibble::tibble(
      placement = placement,
      lag = lag,
      correlation = unname(estimate[["correlation"]]),
      eligible_pairs = as.integer(unname(estimate[["pairs"]])),
      boundary_definition = paste(
        "pairs remain within the same participant-day contiguous",
        "hourly sequence"
      ),
      source_model = "geo_medi_1h ~ site + light_source"
    )
  }))
}

residual_points <- dplyr::bind_rows(lapply(names(models), function(id) {
  h03_residual_rows(models[[id]], placements[[id]])
}))

residual_acf <- dplyr::bind_rows(lapply(names(models), function(id) {
  h03_residual_acf_rows(models[[id]], placements[[id]])
}))

h03_zero_rows <- function(object, placement) {
  mu <- stats::fitted(object$fit)
  power <- object$working_power
  dispersion <- summary(object$fit)$dispersion
  if (!is.finite(dispersion) || dispersion <= 0 || power <= 1 || power >= 2) {
    h03_abort("Invalid working Tweedie parameters for %s", placement)
  }
  # For a compound Poisson Tweedie with 1 < p < 2, P(Y = 0) is exp(-lambda),
  # lambda = mu^(2-p) / (phi * (2-p)). The H03 fit is quasi-likelihood, so
  # these are explicitly working-distribution diagnostics, not fitted
  # likelihood probabilities.
  lambda <- mu^(2 - power) / (dispersion * (2 - power))
  zero_probability <- exp(-lambda)
  observed_zero <- object$data$geo_medi_1h == 0
  tibble::tibble(
    placement = placement,
    light_source = as.character(object$data$light_source),
    fitted_mean_lx = mu,
    observed_zero = observed_zero,
    working_zero_probability = zero_probability,
    working_power = power,
    dispersion = dispersion
  )
}

row_data <- dplyr::bind_rows(lapply(names(models), function(id) {
  h03_zero_rows(models[[id]], placements[[id]])
}))

summarise_zero <- function(data, scope) {
  data |>
    dplyr::summarise(
      observations = dplyr::n(),
      observed_zeros = sum(.data$observed_zero),
      observed_zero_fraction = mean(.data$observed_zero),
      working_expected_zeros = sum(.data$working_zero_probability),
      working_expected_zero_fraction = mean(.data$working_zero_probability),
      observed_minus_working_fraction =
        .data$observed_zero_fraction - .data$working_expected_zero_fraction,
      observed_to_working_ratio = dplyr::if_else(
        .data$working_expected_zero_fraction > 0,
        .data$observed_zero_fraction / .data$working_expected_zero_fraction,
        NA_real_
      ),
      zero_brier_score = mean(
        (as.numeric(.data$observed_zero) - .data$working_zero_probability)^2
      ),
      working_power = dplyr::first(.data$working_power),
      dispersion = dplyr::first(.data$dispersion),
      scope = scope,
      diagnostic_role = paste(
        "working compound-Poisson Tweedie zero-mass check;",
        "quasi-likelihood fit does not estimate a zero-mass likelihood"
      ),
      .groups = "drop"
    )
}

overall <- row_data |>
  dplyr::group_by(.data$placement) |>
  summarise_zero("overall") |>
  dplyr::mutate(light_source = NA_character_, .after = "placement")

by_category <- row_data |>
  dplyr::group_by(.data$placement, .data$light_source) |>
  summarise_zero("light_source")

zero_summary <- dplyr::bind_rows(overall, by_category) |>
  dplyr::arrange(.data$placement, dplyr::desc(.data$scope), .data$light_source)

zero_bins <- row_data |>
  dplyr::group_by(.data$placement) |>
  dplyr::mutate(fitted_mean_decile = dplyr::ntile(.data$fitted_mean_lx, 10L)) |>
  dplyr::group_by(.data$placement, .data$fitted_mean_decile) |>
  dplyr::summarise(
    observations = dplyr::n(),
    fitted_mean_minimum_lx = min(.data$fitted_mean_lx),
    fitted_mean_median_lx = stats::median(.data$fitted_mean_lx),
    fitted_mean_maximum_lx = max(.data$fitted_mean_lx),
    observed_zero_fraction = mean(.data$observed_zero),
    working_expected_zero_fraction = mean(.data$working_zero_probability),
    observed_minus_working_fraction =
      .data$observed_zero_fraction - .data$working_expected_zero_fraction,
    .groups = "drop"
  )

h03_standardization_rows <- function(object, placement) {
  site_levels <- levels(object$data$site)
  category_levels <- levels(object$data$light_source)
  grid <- expand.grid(
    site = site_levels,
    light_source = category_levels,
    KEEP.OUT.ATTRS = FALSE,
    stringsAsFactors = FALSE
  )
  grid$site <- factor(grid$site, levels = site_levels)
  grid$light_source <- factor(
    grid$light_source,
    levels = category_levels
  )
  grid$linear_predictor <- as.numeric(stats::predict(
    object$fit,
    newdata = grid,
    type = "link"
  ))
  grid$expected_mel_edi_lx <- exp(grid$linear_predictor)
  grid |>
    dplyr::mutate(placement = placement, .before = 1) |>
    dplyr::group_by(.data$placement, .data$light_source) |>
    dplyr::summarise(
      sites_standardized = dplyr::n(),
      minimum_site_mean_lx = min(.data$expected_mel_edi_lx),
      maximum_site_mean_lx = max(.data$expected_mel_edi_lx),
      link_scale_equal_site_backtransform_lx = exp(
        mean(.data$linear_predictor)
      ),
      response_scale_equal_site_mean_lx = mean(.data$expected_mel_edi_lx),
      .groups = "drop"
    )
}

standardization_reconciliation <- dplyr::bind_rows(lapply(
  names(models),
  function(id) h03_standardization_rows(models[[id]], placements[[id]])
))

primary_path <- file.path(
  root,
  "results/tables/H03/H03_primary_category_estimands.csv"
)

if (!file.exists(primary_path)) {
  h03_abort("Run H03 primary reporting before reconciliation")
}

primary_estimands <- readr::read_csv(primary_path, show_col_types = FALSE) |>
  dplyr::filter(.data$distribution == "site_standardized") |>
  dplyr::select(
    "placement", "category_order", "category_code", "light_source",
    reported_expected_mel_edi_lx = "expected_mel_edi_lx"
  )

standardization_reconciliation <- standardization_reconciliation |>
  dplyr::left_join(
    primary_estimands,
    by = c("placement", "light_source"),
    relationship = "one-to-one"
  ) |>
  dplyr::mutate(
    response_minus_link_scale_lx =
      .data$response_scale_equal_site_mean_lx -
      .data$link_scale_equal_site_backtransform_lx,
    response_relative_to_link_scale_percent = 100 * (
      .data$response_scale_equal_site_mean_lx /
        .data$link_scale_equal_site_backtransform_lx - 1
    ),
    reported_minus_link_scale_lx =
      .data$reported_expected_mel_edi_lx -
      .data$link_scale_equal_site_backtransform_lx,
    reported_estimand = "exp(equal-site mean linear predictor)",
    alternative_estimand = "equal-site arithmetic mean of response-scale expected melEDI"
  ) |>
  dplyr::arrange(.data$placement, .data$category_order)

if (
  any(!is.finite(
    standardization_reconciliation$reported_minus_link_scale_lx
  )) ||
    max(abs(
      standardization_reconciliation$reported_minus_link_scale_lx
    )) > 1e-8
) {
  h03_abort("site-standardized means failed deterministic reconciliation")
}

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

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

source_root <- file.path(root, "results/csv/source_data/H03")
write_csv_artifact(
    zero_summary,
    file.path(diagnostic_root, "H03_zero_mass_diagnostics.csv"),
    producer
  )

write_csv_artifact(
    zero_bins,
    file.path(diagnostic_root, "H03_zero_mass_calibration_bins.csv"),
    producer
  )

write_csv_artifact(
    residual_acf,
    file.path(diagnostic_root, "H03_primary_residual_acf.csv"),
    producer
  )

write_csv_artifact(
    residual_points,
    file.path(source_root, "H03_primary_residual_points.csv"),
    producer
  )

write_csv_artifact(
    standardization_reconciliation,
    file.path(table_root, "H03_standardization_reconciliation.csv"),
    producer
  )

zero_summary
# A tibble: 16 × 14
   placement light_source     observations observed_zeros observed_zero_fraction
   <chr>     <chr>                   <int>          <int>                  <dbl>
 1 Chest     <NA>                    19512           5409                 0.277 
 2 Chest     Darkness during…         5540           4440                 0.801 
 3 Chest     Daylight indoors         4979            171                 0.0343
 4 Chest     Daylight outdoo…         1686             59                 0.0350
 5 Chest     Electric light …         5468            265                 0.0485
 6 Chest     Electric light …          219             13                 0.0594
 7 Chest     Emissive displa…          861             84                 0.0976
 8 Chest     Light entering …          759            377                 0.497 
 9 Near-eye  <NA>                    17935           4977                 0.278 
10 Near-eye  Darkness during…         5225           4100                 0.785 
11 Near-eye  Daylight indoors         4804            130                 0.0271
12 Near-eye  Daylight outdoo…         1557             57                 0.0366
13 Near-eye  Electric light …         4629            229                 0.0495
14 Near-eye  Electric light …          203             25                 0.123 
15 Near-eye  Emissive displa…          664             73                 0.110 
16 Near-eye  Light entering …          853            363                 0.426 
# ℹ 9 more variables: working_expected_zeros <dbl>,
#   working_expected_zero_fraction <dbl>,
#   observed_minus_working_fraction <dbl>, observed_to_working_ratio <dbl>,
#   zero_brier_score <dbl>, working_power <dbl>, dispersion <dbl>, scope <chr>,
#   diagnostic_role <chr>

Descriptive explained variation

Fit the declared conditional-mean and Gaussian alternatives on the same data to describe explained variation. Participant-level folds keep the same participant out of both training and validation sets.

model_path <- file.path(
  root,
  "results/models/H03/H03_additive_model_objects.rds"
)

if (!file.exists(model_path)) {
  h03_abort("Run H03 primary models before the GLM fit assessment")
}

objects <- readRDS(model_path)

bundles <- list(
  near_eye = objects$main_near_eye,
  chest = objects$main_chest
)

placements <- c(near_eye = "Near-eye", chest = "Chest")

working_power <- h03_specification()$working_tweedie_power

model_registry <- tibble::tribble(
  ~model_id, ~model_label, ~analysis_scale, ~family_label,
  ~exact_formula, ~outcome_target,
  "quasi_tweedie_log", "Quasi-Tweedie, log link", "melEDI (lx)",
  "quasi-Tweedie; p = 1.539919; log link",
  "geo_medi_1h ~ site + light_source",
  "conditional arithmetic mean of hourly melEDI",
  "gaussian_identity_raw", "Gaussian, identity link", "melEDI (lx)",
  "Gaussian; identity link",
  "geo_medi_1h ~ site + light_source",
  "conditional arithmetic mean of hourly melEDI",
  "gaussian_log10_response", "Gaussian on log10(melEDI + 0.1)",
  "log10(melEDI + 0.1 lx)", "Gaussian; identity link after log10 transform",
  "log10(geo_medi_1h + 0.1) ~ site + light_source",
  "conditional mean on log10 scale; direct inverse is geometric-scale target"
)

h03_candidate_response <- function(data, model_id) {
  if (identical(model_id, "gaussian_log10_response")) {
    log10(data$geo_medi_1h + 0.1)
  } else {
    data$geo_medi_1h
  }
}

h03_candidate_family <- function(model_id) {
  if (identical(model_id, "quasi_tweedie_log")) {
    statmod::tweedie(var.power = working_power, link.power = 0)
  } else {
    stats::gaussian(link = "identity")
  }
}

h03_fit_candidate <- function(data, model_id, terms) {
  fit_data <- data
  fit_data$h03_candidate_outcome <- h03_candidate_response(data, model_id)
  formula <- stats::reformulate(terms, response = "h03_candidate_outcome")
  warnings <- character()
  fit <- withCallingHandlers(
    stats::glm(
      formula = formula,
      data = fit_data,
      family = h03_candidate_family(model_id),
      control = stats::glm.control(epsilon = 1e-10, maxit = 100L),
      model = TRUE,
      x = TRUE,
      y = TRUE
    ),
    warning = function(condition) {
      warnings <<- c(warnings, conditionMessage(condition))
      invokeRestart("muffleWarning")
    }
  )
  list(fit = fit, warnings = unique(warnings), formula = formula)
}

h03_predict_candidate <- function(fit, newdata, model_id) {
  prediction_data <- newdata
  for (variable in intersect(names(fit$xlevels), names(prediction_data))) {
    # Prediction must inherit the training-fit contrasts. Reconstructing the
    # factor removes custom frame-level contrast attributes that predict.glm
    # would otherwise discard with a harmless warning.
    prediction_data[[variable]] <- factor(
      as.character(prediction_data[[variable]]),
      levels = fit$xlevels[[variable]]
    )
  }
  prediction <- as.numeric(stats::predict(
    fit,
    newdata = prediction_data,
    type = "response"
  ))
  if (identical(model_id, "gaussian_log10_response")) {
    10^prediction - 0.1
  } else {
    prediction
  }
}

h03_weights <- function(data, weighting) {
  if (identical(weighting, "participant_balanced")) {
    counts <- table(data$participant)
    weight <- 1 / as.numeric(counts[as.character(data$participant)])
  } else {
    weight <- rep(1, nrow(data))
  }
  weight / sum(weight)
}

h03_sse_components <- function(response, prediction, weight) {
  center <- sum(weight * response)
  sse <- sum(weight * (response - prediction)^2)
  sst <- sum(weight * (response - center)^2)
  list(sse = sse, sst = sst, r_squared = 1 - sse / sst)
}

h03_add_observed_cell_factor <- function(data) {
  cell_levels <- data |>
    dplyr::distinct(.data$site, .data$light_source) |>
    dplyr::arrange(.data$site, .data$light_source) |>
    dplyr::transmute(
      cell = paste(
        as.character(.data$site),
        as.character(.data$light_source),
        sep = "__"
      )
    ) |>
    dplyr::pull(.data$cell)
  data$site_source_cell <- factor(
    paste(
      as.character(data$site),
      as.character(data$light_source),
      sep = "__"
    ),
    levels = cell_levels
  )
  data
}

h03_r2_row <- function(
  data,
  model_id,
  placement,
  weighting,
  loss_basis = c("squared_error", "model_deviance")
) {
  loss_basis <- match.arg(loss_basis)
  full_architecture <- if (identical(placement, "Chest")) {
    "full_observed_cell"
  } else {
    "full_literal"
  }
  if (identical(full_architecture, "full_observed_cell")) {
    data <- h03_add_observed_cell_factor(data)
  }
  full_terms <- if (identical(full_architecture, "full_observed_cell")) {
    "0 + site_source_cell"
  } else {
    "site * light_source"
  }
  term_sets <- list(
    intercept = character(),
    site = "site",
    category = "light_source",
    additive = c("site", "light_source"),
    full = full_terms
  )
  fitted <- lapply(term_sets, function(terms) {
    h03_fit_candidate(data, model_id, terms)
  })
  if (loss_basis == "model_deviance") {
    losses <- vapply(fitted, function(item) item$fit$deviance, numeric(1))
    scale_label <- if (identical(model_id, "quasi_tweedie_log")) {
      "working quasi-Tweedie deviance"
    } else {
      "Gaussian deviance on model response scale"
    }
  } else {
    response <- h03_candidate_response(data, model_id)
    weight <- h03_weights(data, weighting)
    losses <- vapply(fitted, function(item) {
      # Stored factor contrasts that are irrelevant to a reduced formula can
      # trigger a harmless "contrasts dropped" warning during prediction.
      prediction <- as.numeric(suppressWarnings(stats::predict(
        item$fit,
        type = "response"
      )))
      sum(weight * (response - prediction)^2)
    }, numeric(1))
    scale_label <- paste(
      if (identical(weighting, "participant_balanced")) {
        "participant-balanced"
      } else {
        "participant-hour-weighted"
      },
      "squared error on model response scale"
    )
  }
  overall <- 1 - losses[["full"]] / losses[["intercept"]]
  site_partial <- 1 - losses[["additive"]] / losses[["category"]]
  category_partial <- 1 - losses[["additive"]] / losses[["site"]]
  interaction_partial <- 1 - losses[["full"]] / losses[["additive"]]
  site_shapley <- 0.5 * (
    (losses[["intercept"]] - losses[["site"]]) +
      (losses[["category"]] - losses[["additive"]])
  ) / losses[["intercept"]]
  category_shapley <- 0.5 * (
    (losses[["intercept"]] - losses[["category"]]) +
      (losses[["site"]] - losses[["additive"]])
  ) / losses[["intercept"]]
  interaction_r_squared <- (
    losses[["additive"]] - losses[["full"]]
  ) / losses[["intercept"]]
  registry <- dplyr::filter(model_registry, .data$model_id == .env$model_id)
  tibble::tibble(
    placement = placement,
    model_id = model_id,
    model_label = registry$model_label,
    heterogeneity_architecture = full_architecture,
    analysis_scale = registry$analysis_scale,
    loss_basis = loss_basis,
    weighting = if (loss_basis == "model_deviance") {
      "model-defined observation weighting"
    } else {
      weighting
    },
    scale_label = scale_label,
    overall_r_squared = overall,
    site_partial_r_squared_conditional_on_category = site_partial,
    category_partial_r_squared_conditional_on_site = category_partial,
    interaction_partial_r_squared_conditional_on_additive =
      interaction_partial,
    site_shapley_r_squared = site_shapley,
    category_shapley_r_squared = category_shapley,
    interaction_r_squared = interaction_r_squared,
    site_shapley_share_percent = if (overall == 0) {
      NA_real_
    } else {
      100 * site_shapley / overall
    },
    category_shapley_share_percent = if (overall == 0) {
      NA_real_
    } else {
      100 * category_shapley / overall
    },
    interaction_share_percent = if (overall == 0) {
      NA_real_
    } else {
      100 * interaction_r_squared / overall
    },
    intercept_loss = losses[["intercept"]],
    site_only_loss = losses[["site"]],
    category_only_loss = losses[["category"]],
    additive_loss = losses[["additive"]],
    full_heterogeneity_loss = losses[["full"]],
    full_loss = losses[["full"]],
    r_squared_formula = if (identical(model_id, "gaussian_log10_response")) {
      paste0(
        "log10(geo_medi_1h + 0.1) ~ ",
        full_terms
      )
    } else {
      paste0("geo_medi_1h ~ ", full_terms)
    },
    allocation_definition = paste(
      "hierarchy-respecting allocation: site and category main effects",
      "are averaged over both entry orders; the interaction enters only",
      "after both main effects"
    ),
    full_converged = fitted$full$fit$converged,
    full_rank = fitted$full$fit$rank,
    full_coefficients = length(stats::coef(fitted$full$fit)),
    warning_count = sum(vapply(fitted, function(x) length(x$warnings), integer(1))),
    inferential_role = paste(
      "descriptive in-sample point estimate; no cluster-bootstrap interval;",
      "not a quasi-likelihood effect test"
    )
  )
}

r_squared_rows <- list()

for (id in names(bundles)) {
  data <- bundles[[id]]$data
  placement <- placements[[id]]
  for (model_id in model_registry$model_id) {
    for (weighting in c("participant_hour_weighted", "participant_balanced")) {
      key <- paste(id, model_id, weighting, sep = "__")
      r_squared_rows[[key]] <- h03_r2_row(
        data,
        model_id,
        placement,
        weighting,
        loss_basis = "squared_error"
      )
    }
    key <- paste(id, model_id, "deviance", sep = "__")
    r_squared_rows[[key]] <- h03_r2_row(
      data,
      model_id,
      placement,
      weighting = "participant_hour_weighted",
      loss_basis = "model_deviance"
    )
  }
}

r_squared <- dplyr::bind_rows(r_squared_rows)

h03_fold_assignments <- function(data, folds = 5L) {
  participants <- data |>
    dplyr::distinct(.data$site, .data$participant) |>
    dplyr::group_by(.data$site) |>
    dplyr::arrange(as.character(.data$participant), .by_group = TRUE) |>
    dplyr::mutate(fold = (dplyr::row_number() - 1L) %% folds + 1L) |>
    dplyr::ungroup()
  dplyr::left_join(
    data,
    participants,
    by = c("site", "participant"),
    relationship = "many-to-one"
  )
}

h03_fold_metrics <- function(data, model_id, placement, fold) {
  training <- dplyr::filter(data, .data$fold != .env$fold)
  testing <- dplyr::filter(data, .data$fold == .env$fold)
  fitted <- h03_fit_candidate(
    training,
    model_id,
    c("site", "light_source")
  )
  prediction <- h03_predict_candidate(fitted$fit, testing, model_id)
  observed <- testing$geo_medi_1h
  clipped_prediction <- pmax(prediction, 0)
  hour_weight <- h03_weights(testing, "participant_hour_weighted")
  participant_weight <- h03_weights(testing, "participant_balanced")
  hour_sse <- h03_sse_components(observed, prediction, hour_weight)
  participant_sse <- h03_sse_components(
    observed,
    prediction,
    participant_weight
  )
  observed_log <- log10(observed + 0.1)
  predicted_log <- log10(clipped_prediction + 0.1)
  registry <- dplyr::filter(model_registry, .data$model_id == .env$model_id)
  tibble::tibble(
    placement = placement,
    model_id = model_id,
    model_label = registry$model_label,
    fold = fold,
    training_participants = dplyr::n_distinct(training$participant),
    test_participants = dplyr::n_distinct(testing$participant),
    test_observations = nrow(testing),
    converged = fitted$fit$converged,
    rank = fitted$fit$rank,
    coefficients = length(stats::coef(fitted$fit)),
    warning_count = length(fitted$warnings),
    negative_prediction_fraction = mean(prediction < 0),
    observed_mean_lx = sum(hour_weight * observed),
    predicted_mean_lx = sum(hour_weight * prediction),
    raw_rmse_lx = sqrt(hour_sse$sse),
    raw_mae_lx = sum(hour_weight * abs(observed - prediction)),
    raw_efron_r_squared = hour_sse$r_squared,
    participant_balanced_rmse_lx = sqrt(participant_sse$sse),
    participant_balanced_mae_lx = sum(
      participant_weight * abs(observed - prediction)
    ),
    participant_balanced_efron_r_squared = participant_sse$r_squared,
    log10_rmse = sqrt(sum(hour_weight * (observed_log - predicted_log)^2)),
    log10_mae = sum(hour_weight * abs(observed_log - predicted_log))
  )
}

fold_rows <- list()

for (id in names(bundles)) {
  data <- h03_fold_assignments(bundles[[id]]$data, folds = 5L)
  for (model_id in model_registry$model_id) {
    for (fold in 1:5) {
      key <- paste(id, model_id, fold, sep = "__")
      fold_rows[[key]] <- h03_fold_metrics(
        data,
        model_id,
        placements[[id]],
        fold
      )
    }
  }
}

fold_metrics <- dplyr::bind_rows(fold_rows)

cross_validation <- fold_metrics |>
  dplyr::group_by(.data$placement, .data$model_id, .data$model_label) |>
  dplyr::summarise(
    folds = dplyr::n(),
    all_converged = all(.data$converged),
    all_full_rank = all(.data$rank == .data$coefficients),
    warning_count = sum(.data$warning_count),
    negative_prediction_fraction = stats::weighted.mean(
      .data$negative_prediction_fraction,
      .data$test_observations
    ),
    observed_mean_lx = stats::weighted.mean(
      .data$observed_mean_lx,
      .data$test_observations
    ),
    predicted_mean_lx = stats::weighted.mean(
      .data$predicted_mean_lx,
      .data$test_observations
    ),
    raw_rmse_lx = sqrt(stats::weighted.mean(
      .data$raw_rmse_lx^2,
      .data$test_observations
    )),
    raw_mae_lx = stats::weighted.mean(
      .data$raw_mae_lx,
      .data$test_observations
    ),
    raw_efron_r_squared_fold_weighted = stats::weighted.mean(
      .data$raw_efron_r_squared,
      .data$test_observations
    ),
    participant_balanced_rmse_lx = sqrt(mean(
      .data$participant_balanced_rmse_lx^2
    )),
    participant_balanced_mae_lx = mean(
      .data$participant_balanced_mae_lx
    ),
    participant_balanced_efron_r_squared_fold_weighted = mean(
      .data$participant_balanced_efron_r_squared
    ),
    log10_rmse = sqrt(stats::weighted.mean(
      .data$log10_rmse^2,
      .data$test_observations
    )),
    log10_mae = stats::weighted.mean(
      .data$log10_mae,
      .data$test_observations
    ),
    test_observations = sum(.data$test_observations),
    test_participants_fold_sum = sum(.data$test_participants),
    comparison_role = paste(
      "deterministic five-fold participant-blocked cross-validation,",
      "stratified within site; descriptive model comparison"
    ),
    .groups = "drop"
  )

if (
  nrow(fold_metrics) != 30L ||
    any(!fold_metrics$converged) ||
    any(fold_metrics$rank != fold_metrics$coefficients) ||
    any(!is.finite(cross_validation$raw_rmse_lx)) ||
    any(!is.finite(r_squared$overall_r_squared))
) {
  h03_abort("H03 bounded GLM fit assessment failed a numerical assertion")
}

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

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

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

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

write_csv_artifact(
    model_registry,
    file.path(table_root, "H03_glm_candidate_model_registry.csv"),
    producer
  )

write_csv_artifact(
    r_squared,
    file.path(table_root, "H03_glm_r_squared_and_effect_partition.csv"),
    producer
  )

write_csv_artifact(
    cross_validation,
    file.path(table_root, "H03_glm_candidate_cross_validation.csv"),
    producer
  )

write_csv_artifact(
    fold_metrics,
    file.path(diagnostic_root, "H03_glm_candidate_cross_validation_folds.csv"),
    producer
  )

r_squared
# A tibble: 18 × 31
   placement model_id          model_label heterogeneity_archit…¹ analysis_scale
   <chr>     <chr>             <chr>       <chr>                  <chr>         
 1 Near-eye  quasi_tweedie_log Quasi-Twee… full_literal           melEDI (lx)   
 2 Near-eye  quasi_tweedie_log Quasi-Twee… full_literal           melEDI (lx)   
 3 Near-eye  quasi_tweedie_log Quasi-Twee… full_literal           melEDI (lx)   
 4 Near-eye  gaussian_identit… Gaussian, … full_literal           melEDI (lx)   
 5 Near-eye  gaussian_identit… Gaussian, … full_literal           melEDI (lx)   
 6 Near-eye  gaussian_identit… Gaussian, … full_literal           melEDI (lx)   
 7 Near-eye  gaussian_log10_r… Gaussian o… full_literal           log10(melEDI …
 8 Near-eye  gaussian_log10_r… Gaussian o… full_literal           log10(melEDI …
 9 Near-eye  gaussian_log10_r… Gaussian o… full_literal           log10(melEDI …
10 Chest     quasi_tweedie_log Quasi-Twee… full_observed_cell     melEDI (lx)   
11 Chest     quasi_tweedie_log Quasi-Twee… full_observed_cell     melEDI (lx)   
12 Chest     quasi_tweedie_log Quasi-Twee… full_observed_cell     melEDI (lx)   
13 Chest     gaussian_identit… Gaussian, … full_observed_cell     melEDI (lx)   
14 Chest     gaussian_identit… Gaussian, … full_observed_cell     melEDI (lx)   
15 Chest     gaussian_identit… Gaussian, … full_observed_cell     melEDI (lx)   
16 Chest     gaussian_log10_r… Gaussian o… full_observed_cell     log10(melEDI …
17 Chest     gaussian_log10_r… Gaussian o… full_observed_cell     log10(melEDI …
18 Chest     gaussian_log10_r… Gaussian o… full_observed_cell     log10(melEDI …
# ℹ abbreviated name: ¹​heterogeneity_architecture
# ℹ 26 more variables: loss_basis <chr>, weighting <chr>, scale_label <chr>,
#   overall_r_squared <dbl>,
#   site_partial_r_squared_conditional_on_category <dbl>,
#   category_partial_r_squared_conditional_on_site <dbl>,
#   interaction_partial_r_squared_conditional_on_additive <dbl>,
#   site_shapley_r_squared <dbl>, category_shapley_r_squared <dbl>, …

Exploratory participant random intercept

Fit auxiliary nested Tweedie random-intercept models on the primary near-eye sample. Report marginal and conditional explained variation, the intraclass correlation and a shared allocation of the fixed-effect contribution.

run_id <- "participant_random_intercept__near_eye"

working_power <- h03_specification()$working_tweedie_power

model_root <- file.path(root, "results/models/H03")

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

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

fitted_model_path <- file.path(
  model_root,
  "H03_additive_model_objects.rds"
)

if (!file.exists(fitted_model_path)) {
  h03_abort("Missing selected H03 additive model object")
}

selected <- readRDS(fitted_model_path)$main_near_eye

if (is.null(selected$data)) {
  h03_abort("Selected H03 near-eye model frame is unavailable")
}

model_formulas <- list(
  intercept = stats::as.formula(
    "geo_medi_1h ~ 1 + (1 | participant)"
  ),
  site = stats::as.formula(
    "geo_medi_1h ~ site + (1 | participant)"
  ),
  light_source = stats::as.formula(
    "geo_medi_1h ~ light_source + (1 | participant)"
  ),
  additive = stats::as.formula(
    "geo_medi_1h ~ site + light_source + (1 | participant)"
  ),
  full = stats::as.formula(
    "geo_medi_1h ~ site * light_source + (1 | participant)"
  )
)

model_formulas <- lapply(model_formulas, function(formula) {
  environment(formula) <- environment()
  formula
})

formula <- model_formulas$full

data <- selected$data |>
  dplyr::arrange(
    .data$site,
    .data$participant,
    .data$local_date,
    .data$interval_start_utc,
    .data$clock_minute
  ) |>
  h03_prepare_model_factors(formula)

if (
  nrow(data) != nrow(selected$data) ||
    nlevels(data$participant) != dplyr::n_distinct(selected$data$participant) ||
    nlevels(data$participant_day) != dplyr::n_distinct(selected$data$participant_day) ||
    nlevels(data$site) != 9L ||
    nlevels(data$light_source) != 7L ||
    nlevels(data$ar_sequence) != dplyr::n_distinct(selected$data$ar_sequence)
) {
  h03_abort(
    "Near-eye random-intercept frame differs from the selected primary sample"
  )
}

first_by_sequence <- !duplicated(data$ar_sequence)

if (
  !identical(as.logical(data$AR_start), first_by_sequence) ||
    sum(data$AR_start) != nlevels(data$ar_sequence)
) {
  h03_abort("AR sequence starts are inconsistent with the selected frame")
}

fit_nested_model <- function(model_id, formula) {
  message("Fitting auxiliary H03 model: ", model_id)
  elapsed <- system.time({
    captured <- h03_capture_warnings(glmmTMB::glmmTMB(
      formula = formula,
      data = data,
      family = glmmTMB::tweedie(link = "log"),
      REML = FALSE,
      start = list(psi = stats::qlogis(working_power - 1)),
      map = list(psi = factor(NA))
    ))
  })
  list(
    model_id = model_id,
    formula = formula,
    fit = captured$value,
    warnings = captured$warnings,
    elapsed_fit_seconds = unname(elapsed[["elapsed"]])
  )
}

nested_models <- Map(
  fit_nested_model,
  names(model_formulas),
  model_formulas
)

names(nested_models) <- names(model_formulas)

fit <- nested_models$full$fit

captured <- list(warnings = nested_models$full$warnings)

elapsed_fit <- c(elapsed = nested_models$full$elapsed_fit_seconds)

r_squared <- performance::r2_nakagawa(
  fit,
  approximation = "lognormal"
)

icc <- performance::icc(
  fit,
  approximation = "lognormal"
)

variance <- insight::get_variance(
  fit,
  approximation = "lognormal"
)

if (
  is.null(r_squared$R2_marginal) ||
    is.null(r_squared$R2_conditional) ||
    nrow(icc) < 1L ||
    any(!c("var.fixed", "var.random", "var.residual") %in% names(variance))
) {
  h03_abort("Could not recover the requested mixed-model variance summaries")
}

marginal_r_squared <- as.numeric(r_squared$R2_marginal[[1L]])

conditional_r_squared <- as.numeric(r_squared$R2_conditional[[1L]])

participant_r_squared_increment <-
  conditional_r_squared - marginal_r_squared

adjusted_icc <- as.numeric(icc$ICC_adjusted[[1L]])

unadjusted_icc <- as.numeric(icc$ICC_unadjusted[[1L]])

fixed_effect_variance <- as.numeric(variance$var.fixed)

participant_variance <- as.numeric(variance$var.random)

distribution_specific_variance <- as.numeric(variance$var.residual)

nested_model_summaries <- lapply(nested_models, function(item) {
  item_r_squared <- performance::r2_nakagawa(
    item$fit,
    approximation = "lognormal"
  )
  item_variance <- insight::get_variance(
    item$fit,
    approximation = "lognormal"
  )
  item_gradient <- if (!is.null(item$fit$sdr$gradient.fixed)) {
    max(abs(item$fit$sdr$gradient.fixed))
  } else {
    NA_real_
  }
  tibble::tibble(
    model_id = item$model_id,
    formula = paste(deparse(item$formula), collapse = " "),
    marginal_r_squared = as.numeric(item_r_squared$R2_marginal[[1L]]),
    conditional_r_squared = as.numeric(item_r_squared$R2_conditional[[1L]]),
    fixed_effect_variance = as.numeric(item_variance$var.fixed),
    participant_intercept_variance = as.numeric(item_variance$var.random),
    distribution_specific_variance = as.numeric(item_variance$var.residual),
    convergence_code = as.integer(item$fit$fit$convergence),
    converged = identical(as.integer(item$fit$fit$convergence), 0L) &&
      isTRUE(item$fit$sdr$pdHess),
    warning_count = length(item$warnings),
    warnings = paste(item$warnings, collapse = " | "),
    positive_definite_hessian = isTRUE(item$fit$sdr$pdHess),
    maximum_absolute_gradient = item_gradient,
    singular = isTRUE(performance::check_singularity(item$fit)),
    log_likelihood = as.numeric(stats::logLik(item$fit)),
    aic = stats::AIC(item$fit),
    elapsed_fit_seconds = item$elapsed_fit_seconds
  )
}) |>
  dplyr::bind_rows()

model_values <- stats::setNames(
  nested_model_summaries$marginal_r_squared,
  nested_model_summaries$model_id
)

site_shapley <- 0.5 * (
  (model_values[["site"]] - model_values[["intercept"]]) +
    (model_values[["additive"]] - model_values[["light_source"]])
)

light_source_shapley <- 0.5 * (
  (model_values[["light_source"]] - model_values[["intercept"]]) +
    (model_values[["additive"]] - model_values[["site"]])
)

interaction_shapley <-
  model_values[["full"]] - model_values[["additive"]]

allocated_marginal_r_squared <-
  site_shapley + light_source_shapley + interaction_shapley

allocation_target <-
  model_values[["full"]] - model_values[["intercept"]]

shapley_efficiency_error <-
  allocated_marginal_r_squared - allocation_target

component_r_squared <- c(
  site_shapley,
  light_source_shapley,
  interaction_shapley
)

shapley_components <- tibble::tibble(
  component_id = c("site", "light_source", "site_by_light_source"),
  component = c(
    "Study site",
    "Light source",
    "Study site × light source"
  ),
  marginal_r_squared_component = component_r_squared,
  share_of_full_marginal_r_squared_percent = 100 *
    component_r_squared / model_values[["full"]],
  full_marginal_r_squared = model_values[["full"]],
  null_marginal_r_squared = model_values[["intercept"]],
  allocated_marginal_r_squared = allocated_marginal_r_squared,
  shapley_efficiency_error = shapley_efficiency_error,
  allocation_definition = paste(
    "hierarchy-respecting Shapley/dominance allocation of Nakagawa",
    "marginal R-squared across refitted nested models; study site and",
    "light source are averaged over both admissible entry orders; the",
    "interaction enters only after both main effects"
  ),
  reference_invariance = paste(
    "nested-model value function; invariant to the factor reference levels"
  ),
  uncertainty = "point estimates; no bootstrap intervals",
  inferential_role = paste(
    "exploratory descriptive allocation; not a unique or causal partition",
    "and does not replace the selected H03 primary mean model"
  )
)

variance_components <- glmmTMB::VarCorr(fit)$cond

participant_sd <- unname(attr(
  variance_components$participant,
  "stddev"
)[[1L]])

participant_factor_per_sd <- exp(participant_sd)

pearson <- stats::residuals(fit, type = "pearson")

fitted_mean <- stats::fitted(fit)

lag_one <- h03_boundary_lag_correlation(
  pearson,
  data$AR_start,
  lag = 1L
)

dispersion <- stats::sigma(fit)

fitted_power <- unname(glmmTMB::family_params(fit)[[1L]])

lambda <- fitted_mean^(2 - fitted_power) /
  (dispersion * (2 - fitted_power))

tweedie_zero_probability <- exp(-lambda)

maximum_gradient <- if (!is.null(fit$sdr$gradient.fixed)) {
  max(abs(fit$sdr$gradient.fixed))
} else {
  NA_real_
}

singular <- isTRUE(performance::check_singularity(fit))

converged <- identical(as.integer(fit$fit$convergence), 0L) &&
  isTRUE(fit$sdr$pdHess)

assessment_summary <- tibble::tibble(
  run_id = run_id,
  placement = "Near-eye",
  formula = paste(deparse(formula), collapse = " "),
  family = "glmmTMB Tweedie",
  link = "log",
  fitting_method = "maximum likelihood",
  observations = nrow(data),
  participants = nlevels(data$participant),
  participant_days = nlevels(data$participant_day),
  sites = nlevels(data$site),
  light_source_categories = nlevels(data$light_source),
  working_power_fixed = working_power,
  marginal_r_squared = marginal_r_squared,
  conditional_r_squared = conditional_r_squared,
  participant_r_squared_increment = participant_r_squared_increment,
  adjusted_participant_icc = adjusted_icc,
  unadjusted_participant_icc = unadjusted_icc,
  fixed_effect_variance = fixed_effect_variance,
  participant_intercept_variance = participant_variance,
  distribution_specific_variance = distribution_specific_variance,
  participant_to_fixed_variance_ratio =
    participant_variance / fixed_effect_variance,
  participant_intercept_sd_log = participant_sd,
  participant_factor_per_sd = participant_factor_per_sd,
  r_squared_approximation = paste(
    "Nakagawa model-based variance decomposition with lognormal",
    "distribution-specific variance"
  ),
  uncertainty = "point estimates; no bootstrap intervals",
  inferential_role = paste(
    "exploratory participant random-intercept variance assessment;",
    "does not replace the selected H03 primary mean model"
  )
)

assessment_diagnostics <- tibble::tibble(
  run_id = run_id,
  placement = "Near-eye",
  convergence_code = as.integer(fit$fit$convergence),
  convergence_message = as.character(fit$fit$message),
  converged = converged,
  warning_count = length(captured$warnings),
  warnings = paste(captured$warnings, collapse = " | "),
  positive_definite_hessian = isTRUE(fit$sdr$pdHess),
  maximum_absolute_gradient = maximum_gradient,
  singular = singular,
  finite_fixed_coefficients = all(is.finite(glmmTMB::fixef(fit)$cond)),
  finite_participant_variance = is.finite(participant_variance) &&
    participant_variance > 0,
  tweedie_power = fitted_power,
  dispersion = dispersion,
  log_likelihood = as.numeric(stats::logLik(fit)),
  aic = stats::AIC(fit),
  pearson_mean = mean(pearson),
  pearson_sd = stats::sd(pearson),
  pearson_q01 = unname(stats::quantile(pearson, 0.01)),
  pearson_q99 = unname(stats::quantile(pearson, 0.99)),
  absolute_residual_fitted_spearman = stats::cor(
    abs(pearson),
    fitted_mean,
    method = "spearman"
  ),
  lag1_pearson_residual_correlation = unname(lag_one[["correlation"]]),
  lag1_pairs = as.integer(lag_one[["pairs"]]),
  observed_zero_fraction = mean(data$geo_medi_1h == 0),
  tweedie_implied_zero_fraction = mean(tweedie_zero_probability),
  observed_minus_implied_zero_fraction =
    mean(data$geo_medi_1h == 0) - mean(tweedie_zero_probability),
  fitted_minimum_lx = min(fitted_mean),
  fitted_median_lx = stats::median(fitted_mean),
  fitted_maximum_lx = max(fitted_mean),
  diagnostic_role = paste(
    "numerical and working-distribution checks for the exploratory",
    "variance assessment; no simulation"
  )
)

if (
  !converged || singular ||
    any(!nested_model_summaries$converged) ||
    any(nested_model_summaries$singular) ||
    any(nested_model_summaries$warning_count != 0L) ||
    abs(model_values[["intercept"]]) > 1e-10 ||
    abs(shapley_efficiency_error) > 1e-10 ||
    any(!is.finite(c(
      marginal_r_squared,
      conditional_r_squared,
      participant_variance,
      distribution_specific_variance,
      shapley_components$marginal_r_squared_component
    ))) ||
    conditional_r_squared < marginal_r_squared
) {
  h03_abort("Auxiliary participant random-intercept assessment failed its check")
}

model_object <- list(
  run_id = run_id,
  placement = "Near-eye",
  data = data,
  formula = formula,
  family = "glmmTMB Tweedie with log link",
  working_power = working_power,
  fit = fit,
  nested_models = lapply(nested_models, `[[`, "fit"),
  nested_model_formulas = model_formulas,
  nested_model_warnings = lapply(nested_models, `[[`, "warnings"),
  nested_model_elapsed_fit_seconds = vapply(
    nested_models,
    `[[`,
    numeric(1L),
    "elapsed_fit_seconds"
  ),
  shapley_components = shapley_components,
  warnings = captured$warnings,
  elapsed_fit_seconds = unname(elapsed_fit[["elapsed"]]),
  input_path = normalizePath(
    fitted_model_path,
    winslash = "/",
    mustWork = TRUE
  ),
  inferential_role = assessment_summary$inferential_role[[1L]]
)


write_rds_artifact(
  model_object,
  file.path(
    model_root,
    "H03_near_eye_participant_random_intercept_assessment.rds"
  ),
  producer
)

write_csv_artifact(
  assessment_summary,
  file.path(
    table_root,
    "H03_near_eye_participant_random_intercept_summary.csv"
  ),
  producer
)

write_csv_artifact(
  shapley_components,
  file.path(
    table_root,
    paste0(
      "H03_near_eye_participant_random_intercept_",
      "marginal_r2_shapley.csv"
    )
  ),
  producer
)

write_csv_artifact(
  assessment_diagnostics,
  file.path(
    diagnostic_root,
    "H03_near_eye_participant_random_intercept_diagnostics.csv"
  ),
  producer
)

write_csv_artifact(
  nested_model_summaries,
  file.path(
    diagnostic_root,
    paste0(
      "H03_near_eye_participant_random_intercept_",
      "shapley_models.csv"
    )
  ),
  producer
)
assessment_summary
# A tibble: 1 × 26
  run_id placement formula family link  fitting_method observations participants
  <chr>  <chr>     <chr>   <chr>  <chr> <chr>                 <int>        <int>
1 parti… Near-eye  geo_me… glmmT… log   maximum likel…        17935          140
# ℹ 18 more variables: participant_days <int>, sites <int>,
#   light_source_categories <int>, working_power_fixed <dbl>,
#   marginal_r_squared <dbl>, conditional_r_squared <dbl>,
#   participant_r_squared_increment <dbl>, adjusted_participant_icc <dbl>,
#   unadjusted_participant_icc <dbl>, fixed_effect_variance <dbl>,
#   participant_intercept_variance <dbl>, distribution_specific_variance <dbl>,
#   participant_to_fixed_variance_ratio <dbl>, …
shapley_components
# A tibble: 3 × 12
  component_id         component   marginal_r_squared_c…¹ share_of_full_margin…²
  <chr>                <chr>                        <dbl>                  <dbl>
1 site                 Study site                  0.0563                   7.07
2 light_source         Light sour…                 0.710                   89.3 
3 site_by_light_source Study site…                 0.0291                   3.66
# ℹ abbreviated names: ¹​marginal_r_squared_component,
#   ²​share_of_full_marginal_r_squared_percent
# ℹ 8 more variables: full_marginal_r_squared <dbl>,
#   null_marginal_r_squared <dbl>, allocated_marginal_r_squared <dbl>,
#   shapley_efficiency_error <dbl>, allocation_definition <chr>,
#   reference_invariance <chr>, uncertainty <chr>, inferential_role <chr>

Exploratory time-of-day associations

Fit a cyclic temporal Tweedie model to the original light scale for each sensor. Estimate residual autocorrelation from a preliminary fit and use it in the final fit. Pointwise intervals describe conditional time patterns; they do not establish a significant period over the whole day.

source("scripts/hypotheses/H03/h03_raw_temporal.R")
spec <- h03_specification()

formula <- h03_formula_set()$temporal_category_raw_mean

working_power <- spec$working_tweedie_power

inputs <- h03_load_inputs(root)

model_root <- file.path(root, "results/models/H03")

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

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

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

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

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

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

dir.create(source_root, recursive = TRUE, showWarnings = FALSE)
additive_path <- file.path(model_root, "H03_additive_model_objects.rds")

if (!file.exists(additive_path)) {
  h03_abort("Run the H03 primary models before the raw temporal model")
}

additive_objects <- readRDS(additive_path)

placement_registry <- list(
  near_eye = list(
    placement = "Near-eye",
    bundle = additive_objects$main_near_eye,
    id = "near_eye"
  ),
  chest = list(
    placement = "Chest",
    bundle = additive_objects$main_chest,
    id = "chest"
  )
)
requested <- c("near_eye", "chest")

for (id in requested) {
  entry <- placement_registry[[id]]
  fitted <- h03_raw_temporal_object(entry)
  object <- fitted$object

  predictions <- h03_raw_temporal_predictions(object)
  diagnostics <- h03_raw_temporal_diagnostics(object)
  support <- h03_temporal_support(object, inputs$categories, spec) |>
    dplyr::mutate(placement = entry$placement)

  curve_path <- file.path(
    source_root,
    paste0("H03_temporal_raw_mean_", id, "_curves.csv")
  )
  ratio_path <- file.path(
    source_root,
    paste0("H03_temporal_raw_mean_", id, "_ratios.csv")
  )
  global_path <- file.path(
    source_root,
    paste0("H03_temporal_raw_mean_", id, "_global.csv")
  )
  diagnostic_path <- file.path(
    diagnostic_root,
    paste0("H03_temporal_raw_mean_", id, "_diagnostics.csv")
  )
  write_csv_artifact(
    predictions$curves,
    curve_path,
    producer
  )
  write_csv_artifact(
    predictions$ratios,
    ratio_path,
    producer
  )
  write_csv_artifact(
    predictions$global,
    global_path,
    producer
  )
  write_csv_artifact(
    diagnostics,
    diagnostic_path,
    producer
  )

  figure <- h03_temporal_figure(
    curves = predictions$curves,
    deviations = predictions$ratios,
    global = predictions$global,
    support = support,
    category_registry = inputs$categories,
    placement = entry$placement,
    ratio_data = predictions$ratios,
    curve_title = paste0(
      entry$placement,
      ": raw-scale conditional mean one-hour melEDI by time of day"
    ),
    model_caption = paste0(
      "Fixed-power Tweedie mean GAM (p = ",
      sprintf("%.6f", working_power),
      ", log link); exploratory analysis."
    ),
    facet_ncol = 7L,
    curve_breaks = c(0, 1, 10, 100, 250, 1000, 10000)
  )
  h03_save_plot(
    figure,
    paste0("H03_temporal_raw_mean_", id, ""),
    figure_root,
    width = 18,
    height = 11.5,
    producer = producer
  )

  print(diagnostics)
}
# A tibble: 1 × 38
  run_id         placement formula response estimand family working_power method
  <chr>          <chr>     <chr>   <chr>    <chr>    <chr>          <dbl> <chr> 
1 temporal_raw_… Near-eye  "geo_m… geo_med… conditi… mgcv:…          1.54 fREML 
# ℹ 30 more variables: discrete <lgl>, nthreads <int>, observations <int>,
#   participants <int>, participant_days <int>, sites <int>, categories <int>,
#   rho <dbl>, rank <int>, coefficients <int>, total_edf <dbl>,
#   adjusted_r_squared <dbl>, deviance_explained <dbl>, residual_scale <dbl>,
#   converged <lgl>, convergence <chr>, preliminary_warning_count <int>,
#   preliminary_warnings <chr>, final_warning_count <int>,
#   final_warnings <chr>, warning_count <int>, …
# A tibble: 1 × 38
  run_id         placement formula response estimand family working_power method
  <chr>          <chr>     <chr>   <chr>    <chr>    <chr>          <dbl> <chr> 
1 temporal_raw_… Chest     "geo_m… geo_med… conditi… mgcv:…          1.54 fREML 
# ℹ 30 more variables: discrete <lgl>, nthreads <int>, observations <int>,
#   participants <int>, participant_days <int>, sites <int>, categories <int>,
#   rho <dbl>, rank <int>, coefficients <int>, total_edf <dbl>,
#   adjusted_r_squared <dbl>, deviance_explained <dbl>, residual_scale <dbl>,
#   converged <lgl>, convergence <chr>, preliminary_warning_count <int>,
#   preliminary_warnings <chr>, final_warning_count <int>,
#   final_warnings <chr>, warning_count <int>, …
support_all <- dplyr::bind_rows(lapply(names(placement_registry), function(id) {
 object <- readRDS(file.path(model_root,paste0("H03_temporal_raw_mean_",id,"_object.rds")))
 h03_temporal_support(object,inputs$categories,spec) |> dplyr::mutate(placement=placement_registry[[id]]$placement)
}))
write_csv_artifact(support_all,file.path(source_root,"H03_temporal_clock_support.csv"),producer)

Temporal model summaries and diagnostics

Use the fitted curves to summarise clock-hour support, observed zeros, residual dependence and variance allocation. Sparse contexts remain descriptive.

model_root <- file.path(root, "results/models/H03")

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

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

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

source_root <- file.path(root, "results/csv/source_data/H03")
inputs <- h03_load_inputs(root)

support_all <- readr::read_csv(
  file.path(source_root, "H03_temporal_clock_support.csv"),
  show_col_types = FALSE
)

working_power <- h03_specification()$working_tweedie_power


write_h03_summary_csv <- function(data, path, role) {
  write_csv_artifact(data, path, producer)
  invisible(path)
}

placements <- list(
  near_eye = list(id = "near_eye", placement = "Near-eye"),
  chest = list(id = "chest", placement = "Chest")
)

model_summaries <- list()

weighted_r_squared <- list()

variance_allocations <- list()

variance_covariances <- list()

penalty_variances <- list()

k_checks <- list()

residual_points <- list()

residual_bins <- list()

residual_acf <- list()

zero_calibration <- list()

for (key in names(placements)) {
  entry <- placements[[key]]
  message("Processing saved raw-mean temporal fit: ", entry$placement)
  model_path <- file.path(
    model_root,
    paste0("H03_temporal_raw_mean_", entry$id, "_object.rds")
  )
  object <- readRDS(model_path)
  if (
    !inherits(object$final, "gam") ||
      !identical(object$placement, entry$placement) ||
      !isTRUE(all.equal(object$working_power, working_power)) ||
      nrow(object$data) < 1L
  ) {
    h03_abort("Saved temporal object violates the contract")
  }

  diagnostic_path <- file.path(
    diagnostic_root,
    paste0("H03_temporal_raw_mean_", entry$id, "_diagnostics.csv")
  )
  model_summaries[[key]] <- readr::read_csv(
    diagnostic_path,
    show_col_types = FALSE
  ) |>
    dplyr::mutate(
      run_id = paste0("reader_temporal_raw_mean__", entry$id),
      inferential_role = paste(
        "exploratory time-of-day conditional-mean context;",
        "no simultaneous-band or curve-wide inference"
      )
    )

  curve_source <- readr::read_csv(
    file.path(
      source_root,
      paste0("H03_temporal_raw_mean_", entry$id, "_curves.csv")
    ),
    show_col_types = FALSE
  ) |>
    dplyr::mutate(run_id = paste0("reader_temporal_raw_mean__", entry$id))
  ratio_source <- readr::read_csv(
    file.path(
      source_root,
      paste0("H03_temporal_raw_mean_", entry$id, "_ratios.csv")
    ),
    show_col_types = FALSE
  ) |>
    dplyr::mutate(run_id = paste0("reader_temporal_raw_mean__", entry$id))
  global_source <- readr::read_csv(
    file.path(
      source_root,
      paste0("H03_temporal_raw_mean_", entry$id, "_global.csv")
    ),
    show_col_types = FALSE
  ) |>
    dplyr::mutate(run_id = paste0("reader_temporal_raw_mean__", entry$id))
  support <- support_all |>
    dplyr::filter(.data$placement == entry$placement)

  write_h03_summary_csv(
    curve_source,
    file.path(
      source_root,
      paste0("H03_reader_temporal_", entry$id, "_curves.csv")
    ),
    paste0(key, "_curves")
  )
  write_h03_summary_csv(
    ratio_source,
    file.path(
      source_root,
      paste0("H03_reader_temporal_", entry$id, "_ratios.csv")
    ),
    paste0(key, "_ratios")
  )
  write_h03_summary_csv(
    global_source,
    file.path(
      source_root,
      paste0("H03_reader_temporal_", entry$id, "_global.csv")
    ),
    paste0(key, "_global")
  )
  write_h03_summary_csv(
    support,
    file.path(
      source_root,
      paste0("H03_reader_temporal_", entry$id, "_support.csv")
    ),
    paste0(key, "_support")
  )

  temporal_figure <- h03_temporal_figure(
    curves = curve_source,
    deviations = ratio_source,
    global = global_source,
    support = support,
    category_registry = inputs$categories,
    placement = entry$placement,
    ratio_data = ratio_source,
    curve_title = paste0(
      entry$placement,
      ": expected one-hour melEDI by time of day"
    ),
    model_caption = paste0(
      "Exploratory fixed-power Tweedie mean GAM (p = ",
      sprintf("%.6f", working_power),
      "; log link)."
    ),
    ratio_caption = paste(
      "Panel B divides each displayed category mean by the displayed global",
      "time-of-day mean; the dashed reference is 1."
    ),
    facet_ncol = 7L,
    curve_breaks = c(0, 1, 10, 100, 250, 1000, 10000)
  )
  h03_save_plot(
    temporal_figure,
    paste0("H03_reader_temporal_", entry$id),
    figure_root,
    width = 15.75,
    height = 10.4,
    producer = producer
  )


  weights <- h03_temporal_weights(object$data)
  response <- object$data$geo_medi_1h
  fitted_mean <- stats::fitted(object$final)
  response_mean <- sum(weights * response)
  weighted_sse <- sum(weights * (response - fitted_mean)^2)
  weighted_sst <- sum(weights * (response - response_mean)^2)
  weighted_r_squared[[key]] <- tibble::tibble(
    run_id = paste0("reader_temporal_raw_mean__", entry$id),
    placement = entry$placement,
    estimand = paste(
      "site-standardized participant-balanced in-sample R-squared",
      "for the conditional arithmetic mean"
    ),
    scale = "raw one-hour geometric melEDI (lx)",
    r_squared = 1 - weighted_sse / weighted_sst,
    weighted_sse = weighted_sse,
    weighted_sst = weighted_sst,
    weight_sum = sum(weights),
    sites_equal_weight = TRUE,
    participants_equal_within_site = TRUE,
    hours_equal_within_participant = TRUE,
    uncertainty = "point estimate; no resampling interval computed"
  )

  term_names <- colnames(stats::predict(object$final, type = "terms"))
  expected_terms <- c(
    "s(time_hour)",
    "s(time_hour,light_source)",
    "s(time_hour,site)",
    "s(time_hour,participant)",
    "s(participant_day)"
  )
  if (!setequal(term_names, expected_terms)) {
    h03_abort(
      "Unexpected temporal terms for %s: %s",
      entry$placement,
      paste(term_names, collapse = "; ")
    )
  }
  groups <- list(
    global_time = "s(time_hour)",
    light_source_deviations = "s(time_hour,light_source)",
    site_deviations = "s(time_hour,site)",
    participant_curves = "s(time_hour,participant)",
    participant_day_shifts = "s(participant_day)"
  )
  partition <- gamm_variance_partition(
    object$final,
    data = object$data,
    groups = groups,
    weights = weights,
    n_draws = 0L
  )
  variance_allocations[[key]] <- partition$allocation |>
    dplyr::mutate(
      run_id = paste0("reader_temporal_raw_mean__", entry$id),
      placement = entry$placement,
      total_fitted_predictor_variance = partition$total_variance,
      shapley_efficiency_error = partition$shapley_efficiency_error,
      scale = "natural-log conditional-mean linear predictor",
      reference_distribution = paste(
        "sites equally weighted; participants equally weighted within site;",
        "hours equally weighted within participant"
      ),
      uncertainty = "point allocation; no simulation interval computed",
      .before = 1
    )
  variance_covariances[[key]] <- as.data.frame(
    as.table(partition$covariance)
  ) |>
    tibble::as_tibble() |>
    dplyr::rename(
      group_1 = "Var1",
      group_2 = "Var2",
      covariance = "Freq"
    ) |>
    dplyr::mutate(
      run_id = paste0("reader_temporal_raw_mean__", entry$id),
      placement = entry$placement,
      scale = "natural-log conditional-mean linear predictor",
      .before = 1
    )
  penalty_variances[[key]] <- h03_temporal_variance_components(
    object,
    entry$placement
  ) |>
    dplyr::mutate(
      run_id = paste0("reader_temporal_raw_mean__", entry$id),
      interpretation = paste(
        "penalty-scale variance/standard-deviation parameter;",
        "not a percentage of raw melEDI variance"
      )
    )
  k_checks[[key]] <- h03_temporal_k_check(object, entry$placement) |>
    dplyr::mutate(run_id = paste0("reader_temporal_raw_mean__", entry$id))

  standardized_residual <- if (
    !is.null(object$final$std.rsd) &&
      length(object$final$std.rsd) == nrow(object$data)
  ) {
    object$final$std.rsd
  } else {
    stats::residuals(object$final, type = "pearson")
  }
  dispersion <- summary(object$final)$scale
  lambda <- fitted_mean^(2 - working_power) /
    (dispersion * (2 - working_power))
  working_zero_probability <- exp(-lambda)
  points <- tibble::tibble(
    run_id = paste0("reader_temporal_raw_mean__", entry$id),
    placement = entry$placement,
    participant = as.character(object$data$participant),
    participant_day = as.character(object$data$participant_day),
    site = as.character(object$data$site),
    light_source = as.character(object$data$light_source),
    time_hour = object$data$time_hour,
    fitted_mean_lx = fitted_mean,
    observed_mel_edi_lx = response,
    standardized_residual = standardized_residual,
    observed_zero = response == 0,
    working_zero_probability = working_zero_probability,
    AR_start = object$data$AR_start
  )
  residual_points[[key]] <- points
  residual_bins[[key]] <- points |>
    dplyr::mutate(bin = dplyr::ntile(.data$fitted_mean_lx, 24L)) |>
    dplyr::group_by(.data$run_id, .data$placement, .data$bin) |>
    dplyr::summarise(
      observations = dplyr::n(),
      fitted_mean_lx = mean(.data$fitted_mean_lx),
      residual_mean = mean(.data$standardized_residual),
      residual_q25 = stats::quantile(
        .data$standardized_residual,
        0.25,
        names = FALSE
      ),
      residual_q75 = stats::quantile(
        .data$standardized_residual,
        0.75,
        names = FALSE
      ),
      .groups = "drop"
    )
  residual_acf[[key]] <- h03_temporal_residual_acf(
    object,
    entry$placement,
    max_lag = 6L
  ) |>
    dplyr::mutate(run_id = paste0("reader_temporal_raw_mean__", entry$id))
  zero_calibration[[key]] <- points |>
    dplyr::mutate(bin = dplyr::ntile(.data$fitted_mean_lx, 10L)) |>
    dplyr::group_by(.data$run_id, .data$placement, .data$bin) |>
    dplyr::summarise(
      observations = dplyr::n(),
      fitted_mean_lx = mean(.data$fitted_mean_lx),
      observed_zero_fraction = mean(.data$observed_zero),
      working_zero_fraction = mean(.data$working_zero_probability),
      .groups = "drop"
    )

  rm(object)
  invisible(gc())
}

model_summary_data <- dplyr::bind_rows(model_summaries)

weighted_r_squared_data <- dplyr::bind_rows(weighted_r_squared)

variance_allocation_data <- dplyr::bind_rows(variance_allocations)

variance_covariance_data <- dplyr::bind_rows(variance_covariances)

penalty_variance_data <- dplyr::bind_rows(penalty_variances)

k_check_data <- dplyr::bind_rows(k_checks)

residual_point_data <- dplyr::bind_rows(residual_points)

residual_bin_data <- dplyr::bind_rows(residual_bins)

residual_acf_data <- dplyr::bind_rows(residual_acf)

zero_calibration_data <- dplyr::bind_rows(zero_calibration)

if (any(abs(variance_allocation_data$shapley_efficiency_error) > 1e-10)) {
  h03_abort("Temporal Shapley allocation failed its efficiency identity")
}

write_h03_summary_csv(
  model_summary_data,
  file.path(table_root, "H03_reader_temporal_model_summary.csv"),
  "temporal_model_summary"
)

write_h03_summary_csv(
  weighted_r_squared_data,
  file.path(table_root, "H03_reader_temporal_weighted_r_squared.csv"),
  "temporal_weighted_r_squared"
)

write_h03_summary_csv(
  variance_allocation_data,
  file.path(table_root, "H03_reader_temporal_variance_allocation.csv"),
  "temporal_variance_allocation"
)

write_h03_summary_csv(
  variance_covariance_data,
  file.path(table_root, "H03_reader_temporal_component_covariance.csv"),
  "temporal_component_covariance"
)

write_h03_summary_csv(
  penalty_variance_data,
  file.path(table_root, "H03_reader_temporal_penalty_variances.csv"),
  "temporal_penalty_variances"
)

write_h03_summary_csv(
  k_check_data,
  file.path(diagnostic_root, "H03_reader_temporal_k_check.csv"),
  "temporal_k_check"
)

write_h03_summary_csv(
  residual_point_data,
  file.path(source_root, "H03_reader_temporal_residual_points.csv"),
  "temporal_residual_points"
)

write_h03_summary_csv(
  residual_bin_data,
  file.path(source_root, "H03_reader_temporal_residual_bins.csv"),
  "temporal_residual_bins"
)

write_h03_summary_csv(
  residual_acf_data,
  file.path(diagnostic_root, "H03_reader_temporal_residual_acf.csv"),
  "temporal_residual_acf"
)

write_h03_summary_csv(
  zero_calibration_data,
  file.path(source_root, "H03_reader_temporal_zero_calibration.csv"),
  "temporal_zero_calibration"
)

diagnostic_point_sample <- residual_point_data |>
  dplyr::group_by(.data$placement) |>
  dplyr::mutate(
    plot_row = dplyr::row_number(),
    plot_stride = ceiling(dplyr::n() / 8000L)
  ) |>
  dplyr::filter((.data$plot_row - 1L) %% .data$plot_stride == 0L) |>
  dplyr::select(-"plot_row", -"plot_stride") |>
  dplyr::ungroup()

diagnostic_a <- ggplot2::ggplot(
  diagnostic_point_sample,
  ggplot2::aes(
    x = .data$fitted_mean_lx,
    y = .data$standardized_residual
  )
) +
  ggplot2::geom_point(size = 0.45, alpha = 0.10, colour = "grey35") +
  ggplot2::geom_linerange(
    data = residual_bin_data,
    ggplot2::aes(
      x = .data$fitted_mean_lx,
      ymin = .data$residual_q25,
      ymax = .data$residual_q75
    ),
    inherit.aes = FALSE,
    linewidth = 0.6,
    colour = "#4477AA"
  ) +
  ggplot2::geom_line(
    data = residual_bin_data,
    ggplot2::aes(
      x = .data$fitted_mean_lx,
      y = .data$residual_mean,
      group = .data$placement
    ),
    inherit.aes = FALSE,
    linewidth = 0.8,
    colour = "#4477AA"
  ) +
  ggplot2::geom_hline(yintercept = 0, linetype = "dashed", colour = "grey45") +
  ggplot2::facet_wrap(ggplot2::vars(.data$placement), nrow = 1) +
  ggplot2::scale_x_continuous(
    trans = LightLogR::symlog_trans(base = 10, thr = 1, scale = 1),
    breaks = c(0, 1, 10, 100, 1000, 10000),
    labels = scales::label_number(big.mark = ",")
  ) +
  ggplot2::coord_cartesian(ylim = c(-4, 8)) +
  ggplot2::labs(
    title = "Residual pattern across fitted melEDI",
    x = "Fitted one-hour melEDI (lx)",
    y = "Standardized residual"
  ) +
  h03_figure_theme()

diagnostic_b <- ggplot2::ggplot(
  residual_acf_data,
  ggplot2::aes(
    x = .data$lag,
    y = .data$correlation,
    colour = .data$placement,
    group = .data$placement
  )
) +
  ggplot2::geom_hline(yintercept = 0, colour = "grey45") +
  ggplot2::geom_line(linewidth = 0.85) +
  ggplot2::geom_point(size = 2.3) +
  ggplot2::scale_colour_manual(
    values = c("Near-eye" = "#0072B2", "Chest" = "#D55E00")
  ) +
  ggplot2::scale_x_continuous(breaks = 1:6) +
  ggplot2::labs(
    title = "Residual dependence within uninterrupted sequences",
    x = "Lag (hours)",
    y = "Residual correlation",
    colour = "Placement"
  ) +
  h03_figure_theme() +
  ggplot2::theme(legend.position = "top")

zero_long <- zero_calibration_data |>
  tidyr::pivot_longer(
    c("observed_zero_fraction", "working_zero_fraction"),
    names_to = "series",
    values_to = "zero_fraction"
  ) |>
  dplyr::mutate(
    series = factor(
      .data$series,
      levels = c("observed_zero_fraction", "working_zero_fraction"),
      labels = c("Observed", "Working Tweedie")
    )
  )

diagnostic_c <- ggplot2::ggplot(
  zero_long,
  ggplot2::aes(
    x = .data$fitted_mean_lx,
    y = .data$zero_fraction,
    colour = .data$series,
    shape = .data$series,
    group = .data$series
  )
) +
  ggplot2::geom_line(linewidth = 0.8) +
  ggplot2::geom_point(size = 2.2) +
  ggplot2::facet_wrap(ggplot2::vars(.data$placement), nrow = 1) +
  ggplot2::scale_x_continuous(
    trans = LightLogR::symlog_trans(base = 10, thr = 1, scale = 1),
    breaks = c(0, 1, 10, 100, 1000, 10000),
    labels = scales::label_number(big.mark = ",")
  ) +
  ggplot2::scale_y_continuous(
    limits = c(0, 1),
    labels = scales::label_percent(accuracy = 1)
  ) +
  ggplot2::scale_colour_manual(
    values = c("Observed" = "#0072B2", "Working Tweedie" = "#CC6677")
  ) +
  ggplot2::labs(
    title = "Observed and working-model zero fractions",
    x = "Mean fitted melEDI in decile (lx)",
    y = "Exact-zero fraction",
    colour = NULL,
    shape = NULL
  ) +
  h03_figure_theme() +
  ggplot2::theme(legend.position = "top")

diagnostic_figure <- patchwork::wrap_plots(
  diagnostic_a,
  diagnostic_b,
  diagnostic_c,
  ncol = 1,
  heights = c(1.2, 0.9, 1.1)
) +
  patchwork::plot_annotation(tag_levels = "A")

saved_diagnostics <- h03_save_plot(
  diagnostic_figure,
  "H03_reader_temporal_diagnostics",
  figure_root,
  width = 13.4,
  height = 12.2,
  producer = producer
)
model_summary_data
# A tibble: 2 × 39
  run_id         placement formula response estimand family working_power method
  <chr>          <chr>     <chr>   <chr>    <chr>    <chr>          <dbl> <chr> 
1 reader_tempor… Near-eye  "geo_m… geo_med… conditi… mgcv:…          1.54 fREML 
2 reader_tempor… Chest     "geo_m… geo_med… conditi… mgcv:…          1.54 fREML 
# ℹ 31 more variables: discrete <lgl>, nthreads <dbl>, observations <dbl>,
#   participants <dbl>, participant_days <dbl>, sites <dbl>, categories <dbl>,
#   rho <dbl>, rank <dbl>, coefficients <dbl>, total_edf <dbl>,
#   adjusted_r_squared <dbl>, deviance_explained <dbl>, residual_scale <dbl>,
#   converged <lgl>, convergence <chr>, preliminary_warning_count <dbl>,
#   preliminary_warnings <chr>, final_warning_count <dbl>,
#   final_warnings <lgl>, warning_count <dbl>, …
weighted_r_squared_data
# A tibble: 2 × 12
  run_id placement estimand scale r_squared weighted_sse weighted_sst weight_sum
  <chr>  <chr>     <chr>    <chr>     <dbl>        <dbl>        <dbl>      <dbl>
1 reade… Near-eye  site-st… raw …     0.544      510765.     1121122.      1.00 
2 reade… Chest     site-st… raw …     0.499      867569.     1731609.      1.000
# ℹ 4 more variables: sites_equal_weight <lgl>,
#   participants_equal_within_site <lgl>, hours_equal_within_participant <lgl>,
#   uncertainty <chr>

Site-standardised context and exploratory latitude

Summarise the site interaction model using equal site weights. Replace site by absolute latitude in a separate exploratory model and repeat it with one site omitted to assess dependence on individual locations.

model_data_root <- file.path(root, "results/intermediate/model_data/H03")

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

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

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

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


write_display_csv <- function(data, path, role) {
  write_csv_artifact(data, path, producer)
  invisible(path)
}

inputs <- h03_load_inputs(root)

working_power <- h03_specification()$working_tweedie_power

primary <- readr::read_csv(
  file.path(table_root, "H03_primary_category_estimands.csv"),
  show_col_types = FALSE
)

site_context <- readr::read_csv(
  file.path(table_root, "H03_site_context_estimands.csv"),
  show_col_types = FALSE
)

frames <- readRDS(file.path(model_data_root, "H03_model_frames.rds"))$main

first_finite <- function(value) {
  available <- value[is.finite(value)]
  if (length(available) == 0L) NA_real_ else available[[1L]]
}

first_text <- function(value) {
  available <- value[!is.na(value) & nzchar(value)]
  if (length(available) == 0L) NA_character_ else available[[1L]]
}

interaction_overall <- site_context |>
  dplyr::group_by(
    .data$placement,
    .data$category_order,
    .data$category_code,
    .data$light_source,
    .data$short_label
  ) |>
  dplyr::summarise(
    architecture = first_text(.data$architecture),
    expected_mel_edi_lx = first_finite(
      .data$site_standardized_category_mean_lx
    ),
    expected_conf_low_lx = first_finite(.data$category_mean_conf_low_lx),
    expected_conf_high_lx = first_finite(.data$category_mean_conf_high_lx),
    ratio_to_indoor = first_finite(.data$category_ratio_to_indoor),
    ratio_conf_low = first_finite(.data$category_ratio_conf_low),
    ratio_conf_high = first_finite(.data$category_ratio_conf_high),
    standardization_sites = dplyr::n_distinct(
      .data$site[is.finite(.data$model_cell_mean_lx)]
    ),
    supported_sites = dplyr::n_distinct(
      .data$site[.data$reporting_status == "ESTIMABLE"]
    ),
    .groups = "drop"
  )

interaction_category <- primary |>
  dplyr::select(
    "placement", "category_order", "category_code", "light_source",
    "short_label", "hours", "participants", "participant_days", "sites",
    primary_additive_p_raw = "p_raw",
    primary_additive_p_adjusted = "p_adjusted"
  ) |>
  dplyr::left_join(
    interaction_overall,
    by = c(
      "placement", "category_order", "category_code", "light_source",
      "short_label"
    ),
    relationship = "one-to-one"
  ) |>
  dplyr::mutate(
    run_id = paste0(
      "reader_interaction_summary__",
      gsub("-", "_", tolower(.data$placement))
    ),
    distribution = "site_standardized",
    standardization_status = dplyr::if_else(
      is.finite(.data$expected_mel_edi_lx) &
        is.finite(.data$ratio_to_indoor),
      "ESTIMABLE",
      "CATEGORY_STANDARDIZATION_NON_ESTIMABLE"
    ),
    inferential_role = paste(
      "descriptive site-standardized estimate from selected heterogeneity",
      "model; primary additive omnibus remains separate"
    ),
    .before = 1
  ) |>
  dplyr::arrange(
    match(.data$placement, c("Near-eye", "Chest")),
    .data$category_order
  )

write_display_csv(
  interaction_category,
  file.path(table_root, "H03_reader_heterogeneity_category_estimands.csv"),
  "heterogeneity_category_estimands"
)

write_display_csv(
  interaction_category,
  file.path(
    source_root,
    "H03_reader_heterogeneity_category_figure_data.csv"
  ),
  "heterogeneity_category_figure_data"
)

interaction_figure <- h03_primary_category_figure(
  interaction_category,
  inputs$categories,
  caption_extra = paste0(
    "Estimates come from the selected category-by-site heterogeneity model.\n",
    "The complementary chest external-light category cannot be standardized ",
    "across all eight sites because one site-category cell is absent."
  )
)

h03_save_plot(
  interaction_figure,
  "H03_reader_heterogeneity_category_estimates",
  figure_root,
  width = 12.8,
  height = 10.2,
  producer = producer
)
$png
$png$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_reader_heterogeneity_category_estimates.png"

$png$bytes
[1] 233085

$png$producer
[1] "analyses/H03-light-source.qmd"

$png$r_version
[1] "4.6.1"

$png$written_utc
[1] "2026-09-22 12:49:43 UTC"


$pdf
$pdf$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_reader_heterogeneity_category_estimates.pdf"

$pdf$bytes
[1] 8084

$pdf$producer
[1] "analyses/H03-light-source.qmd"

$pdf$r_version
[1] "4.6.1"

$pdf$written_utc
[1] "2026-09-22 12:49:43 UTC"


$svg
$svg$path
[1] "/Users/zauner/Projects/ZaunerEtAl_reproducible_NH/results/images/H03/H03_reader_heterogeneity_category_estimates.svg"

$svg$bytes
[1] 26690

$svg$producer
[1] "analyses/H03-light-source.qmd"

$svg$r_version
[1] "4.6.1"

$svg$written_utc
[1] "2026-09-22 12:49:43 UTC"
latitude_formula <- stats::as.formula(paste(
  "geo_medi_1h ~ 0 + light_source +",
  "light_source:absolute_latitude_10deg_centered"
))

capture_warnings <- function(expression) {
  warnings <- character()
  value <- withCallingHandlers(
    expression,
    warning = function(condition) {
      warnings <<- c(warnings, conditionMessage(condition))
      invokeRestart("muffleWarning")
    }
  )
  list(value = value, warnings = unique(warnings))
}

prepare_latitude_frame <- function(data) {
  site_latitudes <- data |>
    dplyr::distinct(.data$site, .data$latitude_deg) |>
    dplyr::mutate(absolute_latitude_10deg = abs(.data$latitude_deg) / 10)
  if (
    any(!is.finite(site_latitudes$absolute_latitude_10deg)) ||
      anyDuplicated(site_latitudes$site)
  ) {
    h03_abort("Latitude is missing or non-unique within an H03 site")
  }
  center <- mean(site_latitudes$absolute_latitude_10deg)
  list(
    data = data |>
      dplyr::mutate(
        absolute_latitude_10deg_centered =
          abs(.data$latitude_deg) / 10 - center
      ),
    site_latitudes = site_latitudes,
    center_absolute_latitude_deg = center * 10
  )
}

fit_latitude <- function(data) {
  prepared <- prepare_latitude_frame(data)
  fit_capture <- capture_warnings(stats::glm(
    formula = latitude_formula,
    data = prepared$data,
    family = statmod::tweedie(
      var.power = working_power,
      link.power = 0
    )
  ))
  fit <- fit_capture$value
  covariance_capture <- capture_warnings(sandwich::vcovCL(
    fit,
    cluster = prepared$data$site,
    type = "HC1",
    cadjust = TRUE,
    fix = FALSE
  ))
  covariance <- covariance_capture$value
  list(
    data = prepared$data,
    site_latitudes = prepared$site_latitudes,
    center_absolute_latitude_deg =
      prepared$center_absolute_latitude_deg,
    fit = fit,
    covariance = covariance,
    warnings = unique(c(
      fit_capture$warnings,
      covariance_capture$warnings
    ))
  )
}

extract_latitude_slopes <- function(bundle, placement, run_id) {
  categories <- levels(bundle$data$light_source)
  coefficient_names <- names(stats::coef(bundle$fit))
  df <- dplyr::n_distinct(bundle$data$site) - 1L
  critical <- stats::qt(0.975, df = df)
  result <- lapply(seq_along(categories), function(index) {
    category <- categories[[index]]
    new_data <- tibble::tibble(
      light_source = factor(category, levels = categories),
      absolute_latitude_10deg_centered = c(0, 1)
    )
    design <- stats::model.matrix(
      stats::delete.response(stats::terms(bundle$fit)),
      data = new_data,
      contrasts.arg = bundle$fit$contrasts
    )
    design <- design[, coefficient_names, drop = FALSE]
    contrast <- design[2L, ] - design[1L, ]
    estimate <- drop(contrast %*% stats::coef(bundle$fit))
    variance <- drop(contrast %*% bundle$covariance %*% contrast)
    standard_error <- if (is.finite(variance) && variance >= 0) {
      sqrt(variance)
    } else {
      NA_real_
    }
    statistic <- estimate / standard_error
    p_raw <- 2 * stats::pt(-abs(statistic), df = df)
    support <- bundle$data |>
      dplyr::filter(as.character(.data$light_source) == .env$category)
    tibble::tibble(
      run_id = run_id,
      placement = placement,
      category_order = index,
      light_source = category,
      estimate_log_ratio_per_10deg = estimate,
      standard_error = standard_error,
      statistic = statistic,
      df = df,
      p_raw = p_raw,
      ratio_per_10deg = exp(estimate),
      ratio_conf_low = exp(estimate - critical * standard_error),
      ratio_conf_high = exp(estimate + critical * standard_error),
      hours = nrow(support),
      participants = dplyr::n_distinct(support$participant),
      participant_days = dplyr::n_distinct(support$participant_day),
      category_sites = dplyr::n_distinct(support$site),
      model_sites = dplyr::n_distinct(bundle$data$site),
      centered_at_absolute_latitude_deg =
        bundle$center_absolute_latitude_deg
    )
  }) |>
    dplyr::bind_rows() |>
    dplyr::left_join(
      inputs$categories |>
        dplyr::transmute(
          light_source = .data$category_label,
          .data$category_code,
          .data$short_label
        ),
      by = "light_source",
      relationship = "many-to-one"
    )
  result$p_adjusted <- stats::p.adjust(result$p_raw, method = "BH")
  result$family_id <- paste0(
    "H03-exploratory-latitude-slopes__",
    gsub("-", "_", tolower(placement))
  )
  result$family_n <- nrow(result)
  result
}

placement_frames <- list(
  near_eye = list(placement = "Near-eye", data = frames$near_eye),
  chest = list(placement = "Chest", data = frames$chest)
)

latitude_bundles <- list()

latitude_slopes <- list()

latitude_diagnostics <- list()

latitude_site_support <- list()

latitude_loso <- list()

for (key in names(placement_frames)) {
  entry <- placement_frames[[key]]
  message("Fitting exploratory latitude replacement: ", entry$placement)
  bundle <- fit_latitude(entry$data)
  latitude_bundles[[key]] <- bundle
  latitude_slopes[[key]] <- extract_latitude_slopes(
    bundle,
    entry$placement,
    paste0("latitude_full__", key)
  )
  design <- stats::model.matrix(bundle$fit)
  latitude_diagnostics[[key]] <- tibble::tibble(
    run_id = paste0("latitude_full__", key),
    placement = entry$placement,
    formula = paste(deparse(latitude_formula), collapse = " "),
    response = "raw one-hour zero-aware geometric melEDI (lx)",
    family = paste0(
      "quasi-Tweedie working mean, log link, p = ",
      sprintf("%.6f", working_power)
    ),
    observations = nrow(bundle$data),
    participants = dplyr::n_distinct(bundle$data$participant),
    participant_days = dplyr::n_distinct(bundle$data$participant_day),
    sites = dplyr::n_distinct(bundle$data$site),
    center_absolute_latitude_deg =
      bundle$center_absolute_latitude_deg,
    converged = isTRUE(bundle$fit$converged),
    iterations = bundle$fit$iter,
    design_rank = qr(design)$rank,
    coefficients = ncol(design),
    covariance_rank = qr(bundle$covariance)$rank,
    covariance_dimension = nrow(bundle$covariance),
    covariance_finite = all(is.finite(bundle$covariance)),
    covariance_diagonal_positive = all(diag(bundle$covariance) > 0),
    warning_count = length(bundle$warnings),
    warnings = if (length(bundle$warnings) == 0L) {
      NA_character_
    } else {
      paste(bundle$warnings, collapse = " | ")
    },
    inference = paste(
      "site-cluster HC1 coefficient-wise t tests with sites minus one df;",
      "seven slope p-values BH-adjusted within placement; no joint slope",
      "omnibus because the cluster covariance rank is bounded by sites minus one"
    )
  )
  latitude_site_support[[key]] <- bundle$site_latitudes |>
    dplyr::mutate(
      placement = entry$placement,
      centered_absolute_latitude_10deg =
        .data$absolute_latitude_10deg -
        bundle$center_absolute_latitude_deg / 10,
      .before = 1
    )

  sites <- levels(droplevels(bundle$data$site))
  for (omitted_site in sites) {
    reduced <- bundle$data |>
      dplyr::filter(as.character(.data$site) != .env$omitted_site) |>
      droplevels()
    reduced_capture <- tryCatch(
      list(value = fit_latitude(reduced), error = NA_character_),
      error = function(condition) {
        list(value = NULL, error = conditionMessage(condition))
      }
    )
    if (is.null(reduced_capture$value)) {
      latitude_loso[[paste(key, omitted_site, sep = "__")]] <-
        inputs$categories |>
        dplyr::transmute(
          placement = entry$placement,
          omitted_site = omitted_site,
          .data$category_order,
          .data$category_code,
          .data$short_label,
          estimate_log_ratio_per_10deg = NA_real_,
          ratio_per_10deg = NA_real_,
          converged = FALSE,
          error = reduced_capture$error
        )
      next
    }
    reduced_slopes <- extract_latitude_slopes(
      reduced_capture$value,
      entry$placement,
      paste0("latitude_loso__", key, "__", omitted_site)
    )
    latitude_loso[[paste(key, omitted_site, sep = "__")]] <-
      reduced_slopes |>
      dplyr::transmute(
        .data$placement,
        omitted_site = omitted_site,
        .data$category_order,
        .data$category_code,
        .data$short_label,
        .data$estimate_log_ratio_per_10deg,
        .data$ratio_per_10deg,
        converged = isTRUE(reduced_capture$value$fit$converged),
        error = NA_character_
      )
  }
}

latitude_slope_data <- dplyr::bind_rows(latitude_slopes)

latitude_diagnostic_data <- dplyr::bind_rows(latitude_diagnostics)

latitude_site_support_data <- dplyr::bind_rows(latitude_site_support)

latitude_loso_data <- dplyr::bind_rows(latitude_loso)

latitude_loso_summary <- latitude_loso_data |>
  dplyr::left_join(
    latitude_slope_data |>
      dplyr::select(
        "placement",
        "category_code",
        full_estimate = "estimate_log_ratio_per_10deg"
      ),
    by = c("placement", "category_code"),
    relationship = "many-to-one"
  ) |>
  dplyr::group_by(
    .data$placement,
    .data$category_order,
    .data$category_code,
    .data$short_label
  ) |>
  dplyr::summarise(
    omissions = dplyr::n(),
    successful = sum(.data$converged & is.finite(.data$ratio_per_10deg)),
    sign_agreement = sum(
      sign(.data$estimate_log_ratio_per_10deg) == sign(.data$full_estimate),
      na.rm = TRUE
    ),
    ratio_min = min(.data$ratio_per_10deg, na.rm = TRUE),
    ratio_max = max(.data$ratio_per_10deg, na.rm = TRUE),
    .groups = "drop"
  )

latitude_slope_data <- latitude_slope_data |>
  dplyr::left_join(
    latitude_loso_summary,
    by = c(
      "placement", "category_order", "category_code", "short_label"
    ),
    relationship = "one-to-one"
  ) |>
  dplyr::mutate(
    inferential_role = paste(
      "exploratory ecological association per 10-degree increase in",
      "absolute latitude; latitude replaces fixed site"
    )
  ) |>
  dplyr::arrange(
    match(.data$placement, c("Near-eye", "Chest")),
    .data$category_order
  )

write_display_csv(
  latitude_slope_data,
  file.path(table_root, "H03_reader_latitude_category_slopes.csv"),
  "latitude_category_slopes"
)

write_display_csv(
  latitude_diagnostic_data,
  file.path(diagnostic_root, "H03_reader_latitude_model_diagnostics.csv"),
  "latitude_model_diagnostics"
)

write_display_csv(
  latitude_loso_data,
  file.path(diagnostic_root, "H03_reader_latitude_leave_one_site_out.csv"),
  "latitude_leave_one_site_out"
)

write_display_csv(
  latitude_site_support_data,
  file.path(source_root, "H03_reader_latitude_site_support.csv"),
  "latitude_site_support"
)

write_display_csv(
  latitude_slope_data,
  file.path(source_root, "H03_reader_latitude_figure_data.csv"),
  "latitude_figure_data"
)

latitude_display <- latitude_slope_data |>
  dplyr::mutate(
    placement = factor(.data$placement, levels = c("Near-eye", "Chest")),
    short_label = factor(
      .data$short_label,
      levels = rev(inputs$categories$short_label)
    ),
    adjusted_label = .data$p_adjusted < 0.05
  )

latitude_palette <- stats::setNames(
  c(
    "#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE",
    "#AA3377", "#777777"
  ),
  inputs$categories$category_code
)

latitude_figure <- ggplot2::ggplot(
  latitude_display,
  ggplot2::aes(
    x = .data$ratio_per_10deg,
    y = .data$short_label,
    colour = .data$category_code
  )
) +
  ggplot2::geom_vline(
    xintercept = 1,
    linetype = "dashed",
    colour = "grey45"
  ) +
  ggplot2::geom_errorbar(
    ggplot2::aes(
      xmin = .data$ratio_conf_low,
      xmax = .data$ratio_conf_high
    ),
    orientation = "y",
    width = 0,
    linewidth = 0.7
  ) +
  ggplot2::geom_point(
    data = dplyr::filter(latitude_display, !.data$adjusted_label),
    shape = 21,
    fill = "white",
    size = 3,
    stroke = 0.7
  ) +
  ggplot2::geom_point(
    data = dplyr::filter(latitude_display, .data$adjusted_label),
    ggplot2::aes(fill = .data$category_code),
    shape = 21,
    size = 3,
    stroke = 0.7
  ) +
  ggplot2::facet_wrap(ggplot2::vars(.data$placement), nrow = 1) +
  ggplot2::scale_x_log10(
    breaks = c(0.5, 0.75, 1, 1.5, 2, 3),
    labels = scales::label_number(accuracy = 0.01)
  ) +
  ggplot2::scale_colour_manual(values = latitude_palette, guide = "none") +
  ggplot2::scale_fill_manual(values = latitude_palette, guide = "none") +
  ggplot2::labs(
    title = "Exploratory linear absolute-latitude gradient by light source",
    subtitle = paste(
      "Factor change in expected melEDI per 10° farther from the equator;",
      "site-level robust 95% intervals"
    ),
    x = "Ratio per +10° absolute latitude",
    y = NULL,
    caption = paste0(
      "Latitude replaces categorical site in a separate model. Filled points pass BH adjustment across seven category slopes within placement.\n",
      "Only nine near-eye and eight chest site latitudes are available; leave-one-site-out ranges are reported in the table."
    )
  ) +
  h03_figure_theme()

saved_latitude <- h03_save_plot(
  latitude_figure,
  "H03_reader_latitude_category_slopes",
  figure_root,
  width = 12.8,
  height = 6.4,
  producer = producer
)
interaction_category
# A tibble: 14 × 24
   run_id         distribution standardization_status inferential_role placement
   <chr>          <chr>        <chr>                  <chr>            <chr>    
 1 reader_intera… site_standa… ESTIMABLE              descriptive sit… Near-eye 
 2 reader_intera… site_standa… ESTIMABLE              descriptive sit… Near-eye 
 3 reader_intera… site_standa… ESTIMABLE              descriptive sit… Near-eye 
 4 reader_intera… site_standa… ESTIMABLE              descriptive sit… Near-eye 
 5 reader_intera… site_standa… ESTIMABLE              descriptive sit… Near-eye 
 6 reader_intera… site_standa… ESTIMABLE              descriptive sit… Near-eye 
 7 reader_intera… site_standa… ESTIMABLE              descriptive sit… Near-eye 
 8 reader_intera… site_standa… ESTIMABLE              descriptive sit… Chest    
 9 reader_intera… site_standa… ESTIMABLE              descriptive sit… Chest    
10 reader_intera… site_standa… ESTIMABLE              descriptive sit… Chest    
11 reader_intera… site_standa… ESTIMABLE              descriptive sit… Chest    
12 reader_intera… site_standa… ESTIMABLE              descriptive sit… Chest    
13 reader_intera… site_standa… ESTIMABLE              descriptive sit… Chest    
14 reader_intera… site_standa… CATEGORY_STANDARDIZAT… descriptive sit… Chest    
# ℹ 19 more variables: category_order <dbl>, category_code <chr>,
#   light_source <chr>, short_label <chr>, hours <dbl>, participants <dbl>,
#   participant_days <dbl>, sites <dbl>, primary_additive_p_raw <dbl>,
#   primary_additive_p_adjusted <dbl>, architecture <chr>,
#   expected_mel_edi_lx <dbl>, expected_conf_low_lx <dbl>,
#   expected_conf_high_lx <dbl>, ratio_to_indoor <dbl>, ratio_conf_low <dbl>,
#   ratio_conf_high <dbl>, standardization_sites <int>, supported_sites <int>
latitude_slope_data
# A tibble: 14 × 29
   run_id           placement category_order light_source estimate_log_ratio_p…¹
   <chr>            <chr>              <int> <chr>                         <dbl>
 1 latitude_full__… Near-eye               1 Electric li…                 0.180 
 2 latitude_full__… Near-eye               2 Electric li…                 0.0983
 3 latitude_full__… Near-eye               3 Daylight in…                 0.226 
 4 latitude_full__… Near-eye               4 Daylight ou…                 0.303 
 5 latitude_full__… Near-eye               5 Emissive di…                 0.158 
 6 latitude_full__… Near-eye               6 Darkness du…                 0.356 
 7 latitude_full__… Near-eye               7 Light enter…                 0.379 
 8 latitude_full__… Chest                  1 Electric li…                 0.133 
 9 latitude_full__… Chest                  2 Electric li…                 0.245 
10 latitude_full__… Chest                  3 Daylight in…                 0.0462
11 latitude_full__… Chest                  4 Daylight ou…                 0.235 
12 latitude_full__… Chest                  5 Emissive di…                 0.170 
13 latitude_full__… Chest                  6 Darkness du…                -0.201 
14 latitude_full__… Chest                  7 Light enter…                 0.553 
# ℹ abbreviated name: ¹​estimate_log_ratio_per_10deg
# ℹ 24 more variables: standard_error <dbl>, statistic <dbl>, df <int>,
#   p_raw <dbl>, ratio_per_10deg <dbl>, ratio_conf_low <dbl>,
#   ratio_conf_high <dbl>, hours <int>, participants <int>,
#   participant_days <int>, category_sites <int>, model_sites <int>,
#   centered_at_absolute_latitude_deg <dbl>, category_code <chr>,
#   short_label <chr>, p_adjusted <dbl>, family_id <chr>, family_n <int>, …

Findings and interpretation

The following views use the models and summaries calculated above.

Export results
options(stringsAsFactors = FALSE, scipen = 999, width = 140, dplyr.summarise.inform = FALSE)
suppressPackageStartupMessages({
    library(dplyr)
    library(gt)
    library(readr)
    library(tibble)
    library(tidyr)
})
locate_project_root <- function(start = getwd()) {
    candidate <- normalizePath(start, winslash = "/", mustWork = TRUE)
    repeat {
        if (file.exists(file.path(candidate, "renv.lock")) && file.exists(file.path(candidate, "_quarto.yml"))) {
            return(candidate)
        }
        parent <- dirname(candidate)
        if (identical(parent, candidate)) {
            stop("Could not locate the project root", call. = FALSE)
        }
        candidate <- parent
    }
}
root <- Sys.getenv("QUARTO_PROJECT_DIR", unset = "")
if (!nzchar(root) || !file.exists(file.path(root, "renv.lock"))) {
    root <- locate_project_root()
}
root <- normalizePath(root, winslash = "/", mustWork = TRUE)
source(file.path(root, "scripts/pipeline/p_value_display.R"))
read_h03 <- function(...) {
    readr::read_csv(file.path(root, "results", ...), show_col_types = FALSE, na = "")
}
samples <- read_h03("intermediate/model_data", "H03", "H03_model_frame_index.csv")
category_support <- read_h03("intermediate/model_data", "H03", "H03_category_support.csv")
primary <- read_h03("tables", "H03", "H03_primary_category_estimands.csv")
omnibus <- read_h03("tables", "H03", "H03_primary_omnibus_tests.csv")
site_context <- read_h03("tables", "H03", "H03_site_context_estimands.csv")
interaction_category <- read_h03("tables", "H03", "H03_reader_heterogeneity_category_estimands.csv")
latitude_slopes <- read_h03("tables", "H03", "H03_reader_latitude_category_slopes.csv")
latitude_diagnostics <- read_h03("csv/diagnostics", "H03", "H03_reader_latitude_model_diagnostics.csv")
interaction_check <- read_h03("csv/diagnostics", "H03", "H03_interaction_architecture_check.csv")
diagnostics <- read_h03("csv/diagnostics", "H03", "H03_model_diagnostics.csv")
zero_mass <- read_h03("csv/diagnostics", "H03", "H03_zero_mass_diagnostics.csv")
glm_r_squared <- read_h03("tables", "H03", "H03_glm_r_squared_and_effect_partition.csv")
participant_random_intercept <- read_h03("tables", "H03", "H03_near_eye_participant_random_intercept_summary.csv")
participant_random_intercept_diagnostics <- read_h03("csv/diagnostics", "H03", "H03_near_eye_participant_random_intercept_diagnostics.csv")
participant_random_intercept_shapley <- read_h03("tables", "H03", paste0("H03_near_eye_participant_random_intercept_",
    "marginal_r2_shapley.csv"))
participant_random_intercept_shapley_models <- read_h03("csv/diagnostics", "H03", paste0("H03_near_eye_participant_random_intercept_",
    "shapley_models.csv"))
sensitivity <- read_h03("tables", "H03", "H03_sensitivity_comparison.csv")
sensitivity_omnibus <- read_h03("tables", "H03", "H03_sensitivity_omnibus_tests.csv")
paired <- read_h03("tables", "H03", "H03_paired_placement_comparison.csv")
influence <- read_h03("tables", "H03", "H03_influence_category_refits.csv")
influence_jobs <- read_h03("csv/diagnostics", "H03", "H03_influence_refit_registry.csv")
temporal_summary <- read_h03("tables", "H03", "H03_reader_temporal_model_summary.csv")
temporal_r_squared <- read_h03("tables", "H03", "H03_reader_temporal_weighted_r_squared.csv")
temporal_allocation <- read_h03("tables", "H03", "H03_reader_temporal_variance_allocation.csv")
temporal_k <- read_h03("csv/diagnostics", "H03", "H03_reader_temporal_k_check.csv")
temporal_acf <- read_h03("csv/diagnostics", "H03", "H03_reader_temporal_residual_acf.csv")
site_registry <- arrange(readr::read_csv(file.path(root, "config", "site_display_registry.csv"), show_col_types = FALSE),
    .data$display_order)
format_p <- function(value) nh_format_p_value(value)
format_p_cell <- function(value, significant = FALSE) {
    ifelse(is.na(value), ";", {
        display <- nh_p_value_display(value, significant = significant)
        ifelse(display$p_bold, paste0("**", display$p_display, "**"), display$p_display)
    })
}
format_mean_ci <- function(estimate, low, high, digits = 1L) {
    paste0(formatC(estimate, digits = digits, format = "f", big.mark = ","), " (", formatC(low, digits = digits, format = "f",
        big.mark = ","), "–", formatC(high, digits = digits, format = "f", big.mark = ","), ")")
}
format_ratio_ci <- function(estimate, low, high, digits = 3L) {
    paste0(formatC(estimate, digits = digits, format = "f"), " (", formatC(low, digits = digits, format = "f"), "–", formatC(high,
        digits = digits, format = "f"), ")")
}
h03_gt <- function(table, font_size = 12) {
    gt::tab_options(gt::sub_missing(gt::opt_row_striping(table), missing_text = ";"), table.width = gt::pct(100), table.font.size = gt::px(font_size),
        container.overflow.x = TRUE, data_row.padding = gt::px(5), column_labels.font.weight = "600", source_notes.font.size = gt::px(11))
}
main_samples <- arrange(mutate(filter(samples, .data$run_id %in% c("main__near_eye", "main__chest")), Placement = factor(.data$placement,
    levels = c("Near-eye", "Chest"))), .data$Placement)
near_primary <- filter(primary, .data$placement == "Near-eye")
chest_primary <- filter(primary, .data$placement == "Chest")
near_interaction <- arrange(filter(interaction_category, .data$placement == "Near-eye"), .data$category_order)
chest_interaction <- arrange(filter(interaction_category, .data$placement == "Chest"), .data$category_order)
near_category_test <- filter(omnibus, .data$placement == "Near-eye", .data$test_id == "category_omnibus")
chest_category_test <- filter(omnibus, .data$placement == "Chest", .data$test_id == "category_omnibus")
near_heterogeneity_test <- filter(omnibus, .data$placement == "Near-eye", .data$test_id == "site_heterogeneity_omnibus")
chest_heterogeneity_test <- filter(omnibus, .data$placement == "Chest", .data$test_id == "site_heterogeneity_omnibus")
paired_near_sample <- filter(samples, .data$run_id == "paired__near_eye")
gap_near_sample <- filter(samples, .data$run_id == "gap__near_eye")
gap_chest_sample <- filter(samples, .data$run_id == "gap__chest")
influence_estimable <- filter(influence, .data$category_order != 1L, .data$estimability_status == "ESTIMABLE")
influence_exceptions <- filter(influence_estimable, !.data$full_ratio_inside_deletion_interval)
noteworthy_higher <- slice_max(filter(site_context, .data$placement == "Near-eye", .data$reporting_status == "ESTIMABLE",
    is.finite(.data$site_deviation_p_adjusted), .data$site_deviation_p_adjusted <= 0.05, .data$site_deviation_ratio > 1),
    .data$site_deviation_ratio, n = 3L, with_ties = FALSE)
noteworthy_lower <- slice_min(filter(site_context, .data$placement == "Near-eye", .data$reporting_status == "ESTIMABLE",
    is.finite(.data$site_deviation_p_adjusted), .data$site_deviation_p_adjusted <= 0.05, .data$site_deviation_ratio < 1),
    .data$site_deviation_ratio, n = 3L, with_ties = FALSE)
noteworthy_sites <- bind_rows(noteworthy_higher, noteworthy_lower)
answer_category_line <- function(category_order) {
    row <- filter(near_interaction, .data$category_order == .env$category_order)
    stopifnot(nrow(row) == 1L)
    ratio <- if (category_order == 1L) {
        "1.000 (reference)"
    }
    else {
        format_ratio_ci(row$ratio_to_indoor, row$ratio_conf_low, row$ratio_conf_high)
    }
    paste0("**", row$short_label, ":** mean ", format_mean_ci(row$expected_mel_edi_lx, row$expected_conf_low_lx, row$expected_conf_high_lx),
        " lx; ratio to indoor electric ", ratio, ".")
}
answer_site_line <- function(index) {
    row <- noteworthy_sites[index, , drop = FALSE]
    stopifnot(nrow(row) == 1L)
    paste0("**", row$site_display_name, ", ", row$short_label, ":** mean ", format_mean_ci(row$cell_mean_lx, row$cell_conf_low_lx,
        row$cell_conf_high_lx), " lx; deviation factor ", format_ratio_ci(row$site_deviation_ratio, row$site_deviation_conf_low,
        row$site_deviation_conf_high), "; FDR-adjusted *p* ", format_p(row$site_deviation_p_adjusted), ".")
}

Question

The preregistered hypothesis was:

H3: Hourly self-reported light exposure categories predict hourly geometric mean melanopic EDI.

For each selected participant-hour, participants reported one primary light source. This analysis asks whether the seven registered categories are associated with measured one-hour melanopic equivalent daylight illuminance (melEDI) after accounting for study site.

NoteAnswer in brief

The seven light-source categories were associated with near-eye melEDI, F(6, 139) = 97.54, p <0.001. The association between light-source category and melEDI was allowed to differ by study site in a category-by-site interaction model. Because that interaction was supported, descriptive category and site summaries come from this model; only the preregistered primary omnibus comes from the additive population-mean model. Outdoor daylight had the highest site-average near-eye melEDI, while sleep darkness had the lowest. Supported site-specific deviations showed that the magnitude of the category association varied materially across study sites.

Complementary chest summaries had the same broad ordering for the six categories for which a site-average estimate could be calculated across all eight chest sites. A site-average estimate for external light during sleep could not be calculated because one chest site-category cell was absent. The gap-timing-unaware dataset did not change the primary omnibus conclusion or any primary category classification.

What was analysed

The primary near-eye sensor position measures light close to the eyes. The complementary chest sensor position measures light at the chest and is not a measure of ocular exposure. Chest measurements are analysed separately; the positions are not pooled and similarity is not interpreted as equivalence. Sleep-category measurements describe the bedside sleep environment rather than light measured at a worn near-eye or chest position.

The response is the selected zero-aware geometric mean melEDI within each hour, including genuine zero values. A participant-hour is one selected participant-by-local-hour record; a participant-day comprises one participant’s selected hours on one local date. The primary model contains 17,935 near-eye participant-hours from 140 participants, 801 participant-days, and nine sites. The complementary model contains 19,512 chest participant-hours from 151 participants, 880 participant-days, and eight sites. The outcome derivation and model-ready inputs are documented in Preparation 04 and Preparation 06.

A predefined sensitivity uses the gap-timing-unaware dataset. This dataset still passed the general 50%-per-hour and 80%-per-day coverage rules. The term means that the timing of the remaining missing observations is not used for an additional metric-specific adjustment; it does not mean that gaps, missingness, or coverage were ignored. For this contrast only, the primary preparation could be interpreted as a time-sensitive primary metric dataset. Below, it is called simply the primary dataset.

All seven categories meet the pooled support rule. Outdoor electric light is the sparsest category, with 203 near-eye and 219 chest hours. Sparse category-by-site cells remain in the pooled models but are labelled non-estimable for site-specific contrasts when they contain fewer than 20 participant-hours, five participants, or five participants shared with the site’s indoor-electric reference.

Primary population-mean and category-by-site interaction models

The primary analysis is a population-mean quasi-Tweedie GLM with a log link, fixed site effects, fixed light-source effects, and variance power 1.539919. Indoor electric light is the category reference. The primary omnibus asks whether the six non-reference light-source coefficients are jointly zero.

Participant-clustered HC1 covariance and finite-cluster t and F reference distributions account for the fact that observations from the same participant may be related. This changes uncertainty; it does not remove temporal patterns from residuals.

A separate category-by-site interaction model allows the association between light-source category and melEDI to differ by study site. Near eye uses the full site * light_source interaction, while chest uses an equivalent observed-cell parameterization because one site-category cell is absent. This model supplies the descriptive category and site summaries; the additive population-mean model supplies the preregistered primary omnibus.

Cross-site results are reported as site-average estimates. Each included site contributes equally on the fitted log-mean scale, followed by one back-transformation that returns the estimate to melEDI in lux. The resulting absolute estimate is the geometric mean of the fitted site-specific expected values and is not weighted by the number of observations at each site.

Principal results

The additive primary model and the category-by-site interaction model do not impose the same mean structure. The additive model forces every site to share one category ratio and estimates that common ratio from all participant-hours; its site-average indoor-electric estimate is 88.9 lx. The interaction model first estimates each site-category cell and then gives every site equal weight on the fitted log-mean scale; its corresponding estimate is 85.1 lx. The difference reflects the supported category-by-site interaction, not a different sample or outcome.

The preregistered additive-model omnibus remains the primary inferential test. Because that common-effect model is descriptively restrictive, all category means, ratios, 95% CIs, and site summaries from this point use the selected category-by-site interaction model. The principal display is Figure 1, followed by the detailed values in Table 1.

include_project_graphics(file.path(
  root,
  "results/images/H03/H03_reader_heterogeneity_category_estimates.png"
))
Two-panel plot of site-average estimates from the category-by-site interaction model for seven light-source categories. Outdoor daylight is highest and sleep darkness lowest. Near-eye outdoor electric is about one tenth of indoor electric. The complementary chest external-light estimate is absent because a site-average estimate cannot be calculated across all eight chest sites.
Figure 1: Site-average one-hour melEDI and ratios to indoor electric light from the category-by-site interaction model; bars are participant-cluster-robust 95% CIs.
interaction_category |>
  mutate(
    Placement = factor(.data$placement, levels = c("Near-eye", "Chest")),
    Category = .data$short_label,
    `Expected melEDI, lx (95% CI)` = if_else(
      .data$standardization_status == "ESTIMABLE",
      format_mean_ci(
        .data$expected_mel_edi_lx,
        .data$expected_conf_low_lx,
        .data$expected_conf_high_lx
      ),
      "Site-average estimate not estimable"
    ),
    `Ratio to indoor electric (95% CI)` = case_when(
      .data$standardization_status != "ESTIMABLE" ~
        "Site-average estimate not estimable",
      .data$category_order == 1L ~ "1.000 (reference)",
      TRUE ~ format_ratio_ci(
          .data$ratio_to_indoor,
          .data$ratio_conf_low,
          .data$ratio_conf_high
        )
    )
  ) |>
  arrange(.data$Placement, .data$category_order) |>
  select(
    Placement,
    Category,
    `Expected melEDI, lx (95% CI)`,
    `Ratio to indoor electric (95% CI)`
  ) |>
  gt::gt(groupname_col = "Placement") |>
  gt::tab_source_note(
    gt::md(paste(
      "These descriptive site-average estimates use the selected",
      "category-by-site interaction model. Each site receives",
      "equal weight on the fitted log-mean scale before back-transformation",
      "to melEDI in lux. The 95% CIs account for related observations within",
      "participants using participant-clustered HC1 covariance. No p-value is",
      "attached to a different-model estimate; the additive primary omnibus",
      "is reported separately. The complementary chest external light during",
      "sleep category lacks one site-category cell, so its site-average estimate",
      "across all eight chest sites remains non-estimable."
    ))
  ) |>
  h03_gt(12)
Table 1: Site-average category means and ratios to indoor electric light from the category-by-site interaction model.
Category Expected melEDI, lx (95% CI) Ratio to indoor electric (95% CI)
Near-eye
Indoor electric 85.1 (71.6–101.1) 1.000 (reference)
Outdoor electric 9.4 (6.9–12.8) 0.110 (0.078–0.156)
Indoor daylight 198.8 (169.2–233.4) 2.335 (1.877–2.905)
Outdoor daylight 959.6 (786.8–1,170.2) 11.274 (8.709–14.595)
Emissive display 23.8 (17.4–32.5) 0.280 (0.197–0.398)
Sleep darkness 2.2 (1.5–3.3) 0.026 (0.018–0.039)
External light during sleep 11.2 (8.1–15.5) 0.131 (0.091–0.189)
Chest
Indoor electric 86.2 (72.1–103.0) 1.000 (reference)
Outdoor electric 49.4 (35.8–68.2) 0.573 (0.404–0.813)
Indoor daylight 201.5 (170.4–238.3) 2.338 (1.876–2.912)
Outdoor daylight 1,105.7 (914.5–1,336.8) 12.827 (9.925–16.576)
Emissive display 27.3 (18.9–39.4) 0.316 (0.210–0.477)
Sleep darkness 2.5 (1.7–3.8) 0.029 (0.018–0.046)
External light during sleep Site-average estimate not estimable Site-average estimate not estimable
These descriptive site-average estimates use the selected category-by-site interaction model. Each site receives equal weight on the fitted log-mean scale before back-transformation to melEDI in lux. The 95% CIs account for related observations within participants using participant-clustered HC1 covariance. No p-value is attached to a different-model estimate; the additive primary omnibus is reported separately. The complementary chest external light during sleep category lacks one site-category cell, so its site-average estimate across all eight chest sites remains non-estimable.

For near-eye melEDI, the category-by-site interaction model gave a site-average outdoor-daylight mean of 959.6 lx (95% CI 786.8–1,170.2), or 11.274 times the indoor-electric mean (95% CI 8.709–14.595). Outdoor electric was instead 9.4 lx (95% CI 6.9–12.8), or 0.110 times the reference (95% CI 0.078–0.156). The latter differs markedly from the additive-model summary because the outdoor-electric contrast varies strongly by site and is sparse in several site-category cells.

The complementary chest interaction model gave the same broad ordering for the six categories with estimable site-average results. The external-light- during-sleep site-average estimate remains absent because one chest site-category cell was unavailable. These separate placement fits do not establish that the two sensor positions are interchangeable.

Result source data and figure source data retain the unrounded estimates and untransformed melEDI values.

Omnibus tests

omnibus |>
  mutate(
    Placement = factor(.data$placement, levels = c("Near-eye", "Chest")),
    Test = recode(
      .data$test_id,
      category_omnibus = "Category association",
      site_heterogeneity_omnibus = "Category-by-site interaction"
    ),
    `F (numerator df, denominator df)` = sprintf(
      "%.2f (%d, %d)",
      .data$f_statistic,
      .data$restrictions,
      .data$denominator_df
    ),
    `Raw p` = format_p_cell(.data$p_raw, .data$p_raw <= 0.05)
  ) |>
  arrange(.data$Placement, .data$test_id) |>
  select(Placement, Test, `F (numerator df, denominator df)`, `Raw p`) |>
  gt::gt(groupname_col = "Placement") |>
  gt::fmt_markdown(columns = `Raw p`) |>
  gt::tab_source_note(
    gt::md("Each omnibus p-value is unadjusted and bold at its stated 0.050 label.")
  ) |>
  h03_gt()
Table 2: Category-association and category-by-site interaction omnibus tests.
Test F (numerator df, denominator df) Raw p
Near-eye
Category association 97.54 (6, 139) <0.001
Category-by-site interaction 79.99 (48, 139) <0.001
Chest
Category association 82.21 (6, 150) <0.001
Category-by-site interaction 13.15 (41, 150) <0.001
Each omnibus p-value is unadjusted and bold at its stated 0.050 label.

The formal category-by-site interaction tests were also supported near eye, F(48, 139) = 79.99, p <0.001, and at chest, F(41, 150) = 13.15, p <0.001. The pooled category ratios are therefore cross-site summaries rather than effects expected to be identical at every site.

Site-specific context

The interaction model can be read as

\[ \mu_{sc} = A \times R_c \times D_{sc}, \]

where (A) is the site-average indoor-electric mean, (R_c) is the category ratio to indoor electric light, and (D_{sc}) is a site’s deviation from the site-average mean for that category. The following near-eye table keeps those two reference systems distinct. Its overall row and site rows all come from the category-by-site interaction model; the primary additive-model omnibus remains the preregistered inferential result reported above.

category_levels <- site_context |>
  filter(.data$placement == "Near-eye") |>
  distinct(.data$category_order, .data$short_label) |>
  arrange(.data$category_order) |>
  pull(.data$short_label)

overall_context <- site_context |>
  filter(.data$placement == "Near-eye") |>
  distinct(
    .data$category_order,
    .data$short_label,
    .data$site_standardized_category_mean_lx,
    .data$category_mean_conf_low_lx,
    .data$category_mean_conf_high_lx,
    .data$category_ratio_to_indoor,
    .data$category_ratio_conf_low,
    .data$category_ratio_conf_high
  ) |>
  mutate(
    Location = "Site-average result",
    Value = paste0(
      sprintf("%.1f lx", .data$site_standardized_category_mean_lx),
      "<br><small>",
      sprintf(
        "mean CI %.1f–%.1f",
        .data$category_mean_conf_low_lx,
        .data$category_mean_conf_high_lx
      ),
      "</small><br>",
      if_else(
        .data$category_order == 1L,
        "1.000× reference",
        sprintf("%.3f× indoor", .data$category_ratio_to_indoor)
      ),
      if_else(
        .data$category_order == 1L,
        "",
        paste0(
          "<br><small>",
          sprintf(
            "ratio CI %.3f–%.3f",
            .data$category_ratio_conf_low,
            .data$category_ratio_conf_high
          ),
          "</small>"
        )
      )
    ),
    row_order = 0,
    row_type = "overall",
    site_color_hex = NA_character_
  ) |>
  select(
    .data$Location,
    .data$row_order,
    .data$row_type,
    .data$site_color_hex,
    .data$short_label,
    .data$Value
  )

separator_context <- tidyr::expand_grid(short_label = category_levels) |>
  mutate(
    Location = "&nbsp;",
    row_order = 0.5,
    row_type = "separator",
    site_color_hex = NA_character_,
    Value = "&nbsp;",
    .before = 1
  )

site_context_rows <- site_context |>
  filter(.data$placement == "Near-eye") |>
  mutate(
    Location = paste0(
      "<span style='color:", .data$site_color_hex,
      ";font-size:1.15em'>●</span>&nbsp;", .data$site_display_name
    ),
    row_order = .data$display_order,
    row_type = "site",
    Value = case_when(
      .data$reporting_status != "ESTIMABLE" ~
        "<small>Not estimable</small>",
      TRUE ~ paste0(
        if_else(
          !is.na(.data$site_deviation_p_adjusted) &
            .data$site_deviation_p_adjusted <= 0.05,
          "**",
          ""
        ),
        sprintf("%.2f×", .data$site_deviation_ratio),
        if_else(
          !is.na(.data$site_deviation_p_adjusted) &
            .data$site_deviation_p_adjusted <= 0.05,
          "**",
          ""
        ),
        "<br><small>",
        sprintf(
          "95%% CI %.2f–%.2f",
          .data$site_deviation_conf_low,
          .data$site_deviation_conf_high
        ),
        "</small>"
      )
    )
  ) |>
  select(
    .data$Location,
    .data$row_order,
    .data$row_type,
    .data$site_color_hex,
    .data$short_label,
    .data$Value
  )

site_factor_table_data <- bind_rows(
  overall_context,
  separator_context,
  site_context_rows
) |>
  mutate(
    short_label = factor(.data$short_label, levels = category_levels)
  ) |>
  arrange(.data$row_order) |>
  select(-.data$row_order, -.data$row_type, -.data$site_color_hex) |>
  pivot_wider(names_from = .data$short_label, values_from = .data$Value) |>
  select(.data$Location, all_of(category_levels))

site_factor_table <- site_factor_table_data |>
  gt::gt() |>
  gt::fmt_markdown(columns = everything()) |>
  gt::cols_label(Location = "Site") |>
  gt::cols_label(
    `Indoor electric` = gt::md("Indoor<br>electric"),
    `Outdoor electric` = gt::md("Outdoor<br>electric"),
    `Indoor daylight` = gt::md("Indoor<br>daylight"),
    `Outdoor daylight` = gt::md("Outdoor<br>daylight"),
    `Emissive display` = gt::md("Emissive<br>display"),
    `Sleep darkness` = gt::md("Sleep<br>darkness"),
    `External light during sleep` = gt::md("External light<br>during sleep")
  ) |>
  gt::cols_width(Location ~ gt::px(165)) |>
  h03_gt(12) |>
  gt::tab_style(
    style = list(
      gt::cell_fill(color = "#E8EEF3"),
      gt::cell_text(weight = "700"),
      gt::cell_borders(
        sides = "bottom",
        color = "#8192A3",
        weight = gt::px(2)
      )
    ),
    locations = gt::cells_body(rows = 1)
  ) |>
  gt::tab_style(
    style = list(
      gt::cell_fill(color = "#FFFFFF"),
      gt::cell_text(color = "#FFFFFF", size = gt::px(1)),
      gt::cell_borders(
        sides = c("top", "bottom"),
        color = "#FFFFFF",
        weight = gt::px(0)
      )
    ),
    locations = gt::cells_body(rows = 2)
  ) |>
  gt::tab_options(data_row.padding = gt::px(5)) |>
  gt::tab_source_note(
    gt::md(paste(
      "The blue-grey site-average row gives both the expected mean and",
      "ratio to indoor electric for every category; the empty row separates",
      "it from the site-specific deviations. Coloured circles use the shared",
      "country-coded site order and colours. Site cells are deviation ratios around the corresponding",
      "category mean. Bold site ratios pass the FDR adjustment across 53",
      "supported deviations."
    ))
  )

site_factor_table
Table 3: Near-eye category effects and site-specific deviations in one factorized display.
Site Indoor
electric
Outdoor
electric
Indoor
daylight
Outdoor
daylight
Emissive
display
Sleep
darkness
External light
during sleep
Site-average result 85.1 lx
mean CI 71.6–101.1
1.000× reference
9.4 lx
mean CI 6.9–12.8
0.110× indoor
ratio CI 0.078–0.156
198.8 lx
mean CI 169.2–233.4
2.335× indoor
ratio CI 1.877–2.905
959.6 lx
mean CI 786.8–1170.2
11.274× indoor
ratio CI 8.709–14.595
23.8 lx
mean CI 17.4–32.5
0.280× indoor
ratio CI 0.197–0.398
2.2 lx
mean CI 1.5–3.3
0.026× indoor
ratio CI 0.018–0.039
11.2 lx
mean CI 8.1–15.5
0.131× indoor
ratio CI 0.091–0.189
               
● Borås (SE) 1.43×
95% CI 1.02–1.99
Not estimable 1.61×
95% CI 1.12–2.32
3.07×
95% CI 1.92–4.90
0.31×
95% CI 0.16–0.57
1.52×
95% CI 0.39–5.90
0.70×
95% CI 0.21–2.36
● Delft (NL) 1.83×
95% CI 1.32–2.54
Not estimable 1.46×
95% CI 0.93–2.29
1.58×
95% CI 0.89–2.80
7.17×
95% CI 4.00–12.86
0.94×
95% CI 0.49–1.80
Not estimable
● Dortmund (DE) 1.26×
95% CI 0.69–2.29
Not estimable 1.62×
95% CI 0.83–3.17
1.48×
95% CI 0.91–2.40
2.86×
95% CI 1.66–4.93
0.89×
95% CI 0.45–1.75
9.22×
95% CI 2.50–34.03
● Tübingen (DE) 0.56×
95% CI 0.38–0.82
20.42×
95% CI 3.95–105.59
1.04×
95% CI 0.72–1.50
0.89×
95% CI 0.51–1.56
0.25×
95% CI 0.14–0.44
0.78×
95% CI 0.32–1.92
1.59×
95% CI 0.71–3.59
● Munich (DE) 1.70×
95% CI 0.63–4.57
Not estimable 1.00×
95% CI 0.65–1.55
0.94×
95% CI 0.55–1.61
Not estimable 6.60×
95% CI 2.11–20.69
11.65×
95% CI 7.19–18.85
● Madrid (ES) 1.48×
95% CI 1.15–1.90
2.68×
95% CI 1.56–4.61
0.83×
95% CI 0.57–1.19
0.65×
95% CI 0.33–1.26
3.68×
95% CI 2.24–6.03
1.59×
95% CI 0.30–8.32
Not estimable
● Izmir (TR) 0.69×
95% CI 0.52–0.92
1.85×
95% CI 0.84–4.07
0.86×
95% CI 0.65–1.13
1.12×
95% CI 0.65–1.94
1.32×
95% CI 0.70–2.47
0.97×
95% CI 0.43–2.18
1.59×
95% CI 0.92–2.76
● San José (CR) 0.54×
95% CI 0.38–0.77
Not estimable 0.72×
95% CI 0.49–1.05
0.46×
95% CI 0.21–1.01
1.32×
95% CI 0.65–2.66
0.60×
95% CI 0.25–1.42
Not estimable
● Kumasi (GH) 0.58×
95% CI 0.38–0.88
6.24×
95% CI 2.28–17.06
0.49×
95% CI 0.26–0.92
0.50×
95% CI 0.36–0.68
1.27×
95% CI 0.40–4.07
0.17×
95% CI 0.06–0.49
Not estimable
The blue-grey site-average row gives both the expected mean and ratio to indoor electric for every category; the empty row separates it from the site-specific deviations. Coloured circles use the shared country-coded site order and colours. Site cells are deviation ratios around the corresponding category mean. Bold site ratios pass the FDR adjustment across 53 supported deviations.
include_project_graphics(file.path(
  root,
  "results/images/H03/H03_near_eye_site_context_estimates.png"
))
Seven-facet interval plot of near-eye melEDI by country-coded study site and light source on one common axis spanning zero through at least one thousand lux. Dashed lines show site-average category means. Site-coloured filled points identify FDR-labelled site deviations, site-coloured open points show other supported estimates, and crosses show unsupported contrasts.
Figure 2: Near-eye site-specific expected melEDI. Filled points pass the FDR adjustment; crosses denote unsupported contrasts.

Site-context source data contain every site-category estimate, interval, support label, and adjusted site-deviation p-value.

Descriptive fixed-effect R²

The primary analysis has no random effects, so mixed-model marginal and conditional R² are not defined. Descriptive R² summaries are instead based on the selected category-by-site interaction model.

glm_r_squared |>
  filter(.data$model_id == "quasi_tweedie_log") |>
  mutate(
    Placement = factor(.data$placement, levels = c("Near-eye", "Chest")),
    Definition = case_when(
      .data$loss_basis == "model_deviance" ~ "Working quasi-Tweedie deviance",
      .data$weighting == "participant_balanced" ~
        "Participant-balanced squared error",
      TRUE ~ "Participant-hour squared error"
    ),
    Definition = factor(
      .data$Definition,
      levels = c(
        "Working quasi-Tweedie deviance",
        "Participant-hour squared error",
        "Participant-balanced squared error"
      )
    ),
    `Overall R²` = sprintf("%.3f", .data$overall_r_squared),
    `Category allocation` = sprintf(
      "%.3f (%.1f%%)",
      .data$category_shapley_r_squared,
      .data$category_shapley_share_percent
    ),
    `Site allocation` = sprintf(
      "%.3f (%.1f%%)",
      .data$site_shapley_r_squared,
      .data$site_shapley_share_percent
    ),
    `Interaction allocation` = sprintf(
      "%.3f (%.1f%%)",
      .data$interaction_r_squared,
      .data$interaction_share_percent
    ),
    `Interaction partial R²` = sprintf(
      "%.3f",
      .data$interaction_partial_r_squared_conditional_on_additive
    )
  ) |>
  arrange(.data$Placement, .data$Definition) |>
  select(
    Placement,
    Definition,
    `Overall R²`,
    `Category allocation`,
    `Site allocation`,
    `Interaction allocation`,
    `Interaction partial R²`
  ) |>
  gt::gt(groupname_col = "Placement") |>
  gt::tab_source_note(
    gt::md(paste(
      "Allocations are R² units with shares of overall R² in parentheses.",
      "They are point descriptions without resampling intervals or additional",
      "effect tests."
    ))
  ) |>
  h03_gt(12)
Table 4: Descriptive R² and hierarchy-respecting allocation for the category-by-site interaction model.
Definition Overall R² Category allocation Site allocation Interaction allocation Interaction partial R²
Near-eye
Working quasi-Tweedie deviance 0.522 0.450 (86.3%) 0.045 (8.7%) 0.026 (5.0%) 0.052
Participant-hour squared error 0.129 0.096 (74.9%) 0.017 (12.9%) 0.016 (12.2%) 0.018
Participant-balanced squared error 0.128 0.095 (74.4%) 0.016 (12.6%) 0.017 (13.0%) 0.019
Chest
Working quasi-Tweedie deviance 0.536 0.454 (84.7%) 0.040 (7.6%) 0.041 (7.7%) 0.082
Participant-hour squared error 0.152 0.101 (66.7%) 0.021 (13.6%) 0.030 (19.6%) 0.034
Participant-balanced squared error 0.149 0.098 (65.9%) 0.020 (13.5%) 0.031 (20.6%) 0.035
Allocations are R² units with shares of overall R² in parentheses. They are point descriptions without resampling intervals or additional effect tests.

Under the near-eye working quasi-deviance definition, overall R² was 0.522; category, site, and interaction allocations were 86.3%, 8.7%, and 5.0% of that fitted improvement. These are scale-dependent descriptions of the fitted mean structure, not causal shares of exposure variance.

Exploratory participant random-intercept assessment

An auxiliary near-eye model quantified stable participant-level differences: geo_medi_1h ~ site * light_source + (1 | participant). It used the selected 17,935 participant-hours from 140 participants, a Tweedie distribution with a log link, and the primary working power fixed at 1.539919. The model contains neither a participant-day intercept nor a temporal correlation term. It was fitted only for variance description and does not replace the selected population-mean analysis or estimate participant-specific light-source slopes.

participant_random_intercept_table <- tibble::tribble(
  ~Component, ~Quantity, ~Estimate, ~Interpretation,
  "Model-based R²", "Marginal R²",
  participant_random_intercept$marginal_r_squared[[1L]],
  "Site, light source, and their interaction",
  "Model-based R²", "Conditional R²",
  participant_random_intercept$conditional_r_squared[[1L]],
  "Fixed effects plus the participant intercept",
  "Model-based R²", "Participant increment",
  participant_random_intercept$participant_r_squared_increment[[1L]],
  "Conditional minus marginal R²"
) |>
  dplyr::bind_rows(
    participant_random_intercept_shapley |>
      dplyr::transmute(
        Component = "Marginal R² Shapley allocation",
        Quantity = .data$component,
        Estimate = .data$marginal_r_squared_component,
        Interpretation = sprintf(
          "%.1f%% of full marginal R²",
          .data$share_of_full_marginal_r_squared_percent
        )
      )
  ) |>
  dplyr::bind_rows(tibble::tribble(
  ~Component, ~Quantity, ~Estimate, ~Interpretation,
  "Participant-level variation", "Adjusted participant ICC",
  participant_random_intercept$adjusted_participant_icc[[1L]],
  "Participant share after excluding fixed-effect variance",
  "Participant-level variation", "Unadjusted participant ICC",
  participant_random_intercept$unadjusted_participant_icc[[1L]],
  "Participant share of total model-based variance",
  "Participant-level variation", "Participant SD on log scale",
  participant_random_intercept$participant_intercept_sd_log[[1L]],
  "Spread of stable participant intercepts",
  "Participant-level variation", "One-SD multiplicative factor",
  participant_random_intercept$participant_factor_per_sd[[1L]],
  "Expected-mean factor for a participant one SD above average",
  "Participant-level variation", "Participant/fixed variance ratio",
  participant_random_intercept$participant_to_fixed_variance_ratio[[1L]],
  "Participant-intercept variance divided by fixed-predictor variance"
  )) |>
  mutate(
    Estimate = if_else(
      .data$Quantity == "One-SD multiplicative factor",
      paste0(sprintf("%.2f", .data$Estimate), "×"),
      sprintf("%.3f", .data$Estimate)
    )
  )

participant_random_intercept_table |>
  gt::gt(groupname_col = "Component") |>
  gt::cols_align(align = "right", columns = "Estimate") |>
  gt::tab_source_note(
    gt::md(paste(
      "Point estimates use Nakagawa's model-based variance decomposition",
      "with a lognormal distribution-specific variance approximation; no",
      "bootstrap intervals were calculated. The decomposition uses the",
      "observed participant-hour frame rather than a site-average grid.",
      "The Shapley allocation refits hierarchy-valid nested models and",
      "averages both entry orders for site and light source; the interaction",
      "enters only after both main effects."
    ))
  ) |>
  h03_gt(12)
Table 5: Exploratory participant random-intercept variance assessment for the near-eye outcome.
Quantity Estimate Interpretation
Model-based R²
Marginal R² 0.796 Site, light source, and their interaction
Conditional R² 0.876 Fixed effects plus the participant intercept
Participant increment 0.080 Conditional minus marginal R²
Marginal R² Shapley allocation
Study site 0.056 7.1% of full marginal R²
Light source 0.710 89.3% of full marginal R²
Study site × light source 0.029 3.7% of full marginal R²
Participant-level variation
Adjusted participant ICC 0.394 Participant share after excluding fixed-effect variance
Unadjusted participant ICC 0.080 Participant share of total model-based variance
Participant SD on log scale 0.694 Spread of stable participant intercepts
One-SD multiplicative factor 2.00× Expected-mean factor for a participant one SD above average
Participant/fixed variance ratio 0.101 Participant-intercept variance divided by fixed-predictor variance
Point estimates use Nakagawa’s model-based variance decomposition with a lognormal distribution-specific variance approximation; no bootstrap intervals were calculated. The decomposition uses the observed participant-hour frame rather than a site-average grid. The Shapley allocation refits hierarchy-valid nested models and averages both entry orders for site and light source; the interaction enters only after both main effects.

Marginal R² was 0.796 for the combined site, light-source, and interaction terms. Conditional R² was 0.876 after adding the participant intercept, a difference of 0.080. The adjusted ICC was 0.394 because it excludes fixed-effect variance from its denominator; the unadjusted ICC was 0.080 on the total model-based variance scale. A one-SD participant intercept was a 2.00-fold conditional expected mean relative to a participant at the model-average intercept while holding site and light source fixed. This is a common participant multiplier across categories, not a participant-specific category response. The variance decomposition is evaluated over observed participant-hours and is not a site-average estimand.

Marginal R² has no unique term decomposition. In the hierarchy-respecting Shapley analysis, the study-site component was 0.056 (7.1% of the full marginal R²), the light-source component was 0.710 (89.3%), and the site-by-light-source interaction component was 0.029 (3.7%). The three point allocations sum to the full marginal R². The analysis averages both hierarchy-valid entry orders for site and light source and introduces the interaction only after both main effects. It is consequently invariant to the chosen reference site and light-source category. It remains a descriptive allocation tied to this model-based R² definition and nested-model set, not a unique or causal partition of exposure variance.

The fit returned convergence code 0 without warnings, had a positive-definite Hessian, and was not singular. Its lag-one Pearson residual correlation was 0.288, so a participant intercept did not remove all within-participant temporal dependence. Exact zeros comprised 27.8%, compared with 39.8% under the fitted Tweedie distribution. The variance assessment is therefore descriptive, not evidence that the model predicts individual participant-hours well. Its Nakagawa R² values are not numerically interchangeable with the quasi-deviance R² and fixed-effect allocations in the preceding table. All five models used for the Shapley value function likewise converged without warnings, had positive-definite Hessians, and were non-singular.

Random-intercept assessment estimates and model diagnostics, together with the hierarchy-respecting Shapley allocation and nested-model checks, provide the complete stored point estimates and checks.

Model checks

Both population-mean fits converged without warnings, retained full design rank, and had finite positive-definite participant-cluster covariances. Their working distribution nevertheless does not reproduce important features of individual-hour data.

Hours close together in time still show similar unexplained deviations: the technical lag-one residual correlations are 0.478 near eye and 0.615 at chest. Participant clustering protects coefficient uncertainty against arbitrary dependence within a participant, but it does not whiten residuals or turn the working Tweedie family into a calibrated probability model.

The discrepancy is clearest for zeros. Exact zeros comprise 27.8% of hours, whereas the working Tweedie relationship implies 85.2% near eye and 94.8% at chest. The model is therefore interpreted only as a log-mean model with participant-robust inference, not as an individual-hour prediction model or a model of zero probability.

Show detailed primary model checks
diagnostics |>
  filter(.data$scenario_id == "primary_dataset") |>
  left_join(
    zero_mass |>
      filter(.data$scope == "overall") |>
      select(
        .data$placement,
        working_zero = .data$working_expected_zero_fraction
      ),
    by = "placement",
    relationship = "one-to-one"
  ) |>
  transmute(
    Placement = .data$placement,
    Converged = .data$converged,
    `Full rank` = .data$full_rank,
    `Robust covariance positive definite` =
      .data$covariance_positive_definite,
    `|Residual|-fitted Spearman` = sprintf(
      "%.3f",
      .data$absolute_residual_fitted_spearman
    ),
    `Lag-1 residual correlation` = sprintf(
      "%.3f",
      .data$residual_lag1_correlation
    ),
    `Observed zero fraction` = sprintf("%.3f", .data$exact_zero_fraction),
    `Working zero fraction` = sprintf("%.3f", .data$working_zero)
  ) |>
  gt::gt() |>
  h03_gt(12)
Table 6: Numerical, residual, temporal, and zero-mass model checks.
Placement Converged Full rank Robust covariance positive definite |Residual|-fitted Spearman Lag-1 residual correlation Observed zero fraction Working zero fraction
Near-eye TRUE TRUE TRUE 0.554 0.478 0.278 0.852
Chest TRUE TRUE TRUE 0.565 0.615 0.277 0.948
include_project_graphics(file.path(
  root,
  "results/images/H03/H03_primary_residual_diagnostics.png"
))
Three-panel model-check plot. Individual residuals show mean-dependent spread, residual correlations remain positive across hourly lags, and observed zero fractions are much lower than the working Tweedie fractions.
Figure 3: Primary-model residual, temporal-dependence, and zero-mass checks. The melEDI transformation is display-only.

Deleting each available site and the five participants with the largest score contributions at each placement produced 27 refits. No category-omnibus decision changed, and 146 of 149 estimable non-reference category ratios retained the full-data ratio within the deletion-refit interval. The three exceptions were localized chest results: sleep darkness after excluding Kumasi (GH), external light during sleep after excluding Munich (DE), and sleep darkness after excluding one Kumasi (GH) participant.

Sensitivity analyses

The sensitivity analyses make one defined change at a time: they use the same participants and participant-hours available at both sensor positions; use the gap-timing-unaware dataset defined above; exclude hours spanning a diary-state or measurement-context boundary; exclude site-category cells that fail the predefined support rule; set the quasi-Tweedie working variance power to 1.30 or 1.80; or add participant-level category proportions to separate within- participant from between-participant information.

Show detailed sensitivity results
sensitivity_counts <- sensitivity |>
  group_by(.data$run_id, .data$placement, .data$scenario_id) |>
  summarise(
    Stable = sum(.data$stability == "stable"),
    `Support-non-estimable` = sum(
      .data$stability == "support_non_estimable"
    ),
    `Direction shifts` = sum(.data$stability == "direction_shift"),
    `Magnitude shifts` = sum(.data$stability == "magnitude_shift"),
    .groups = "drop"
  )

sensitivity_counts |>
  left_join(
    samples |>
      select(
        .data$run_id,
        .data$observations,
        .data$participants,
        .data$participant_days,
        .data$sites
      ),
    by = "run_id",
    relationship = "many-to-one"
  ) |>
  left_join(
    sensitivity_omnibus |>
      select(.data$run_id, omnibus_p = .data$p_raw),
    by = "run_id",
    relationship = "many-to-one"
  ) |>
  mutate(
    Placement = factor(.data$placement, levels = c("Near-eye", "Chest")),
    Scenario = recode(
      .data$scenario_id,
      paired_common_sample = "Same-participant, same-hour sample",
      gap_timing_unaware_dataset = "Gap-timing-unaware dataset",
      boundary_hours_excluded = "Boundary hours excluded",
      unsupported_cells_excluded = "Unsupported cells excluded",
      working_power_1_30 = "Working power 1.30",
      working_power_1_80 = "Working power 1.80",
      mundlak_within_between = "Within/between participant"
    ),
    `Raw omnibus p` = format_p_cell(
      .data$omnibus_p,
      .data$omnibus_p <= 0.05
    )
  ) |>
  arrange(.data$Placement, .data$Scenario) |>
  select(
    Placement,
    Scenario,
    Hours = .data$observations,
    Participants = .data$participants,
    `Participant-days` = .data$participant_days,
    Sites = .data$sites,
    `Raw omnibus p`,
    Stable,
    `Support-non-estimable`,
    `Direction shifts`,
    `Magnitude shifts`
  ) |>
  gt::gt(groupname_col = "Placement") |>
  gt::fmt_integer(
    columns = c(
      Hours, Participants, `Participant-days`, Sites, Stable,
      `Support-non-estimable`, `Direction shifts`, `Magnitude shifts`
    ),
    use_seps = TRUE
  ) |>
  gt::fmt_markdown(columns = `Raw omnibus p`) |>
  gt::tab_source_note(
    gt::md("Each raw omnibus p-value is bold at its stated 0.050 label.")
  ) |>
  h03_gt(12)
Table 7: Samples, omnibus tests, and category stability across predefined sensitivities.
Scenario Hours Participants Participant-days Sites Raw omnibus p Stable Support-non-estimable Direction shifts Magnitude shifts
Near-eye
Boundary hours excluded 15,539 140 801 9 <0.001 6 1 0 0
Gap-timing-unaware dataset 17,616 140 785 9 <0.001 7 0 0 0
Same-participant, same-hour sample 14,103 111 631 8 <0.001 6 1 0 0
Unsupported cells excluded 17,818 140 801 9 <0.001 6 1 0 0
Within/between participant 17,935 140 801 9 <0.001 5 0 1 1
Working power 1.30 17,935 140 801 9 <0.001 7 0 0 0
Working power 1.80 17,935 140 801 9 <0.001 6 0 1 0
Chest
Boundary hours excluded 16,942 151 880 8 <0.001 6 1 0 0
Gap-timing-unaware dataset 19,220 151 865 8 <0.001 7 0 0 0
Same-participant, same-hour sample 14,103 111 631 8 <0.001 6 1 0 0
Unsupported cells excluded 19,416 151 880 8 <0.001 6 1 0 0
Within/between participant 19,512 151 880 8 <0.001 6 0 0 1
Working power 1.30 19,512 151 880 8 <0.001 7 0 0 0
Working power 1.80 19,512 151 880 8 <0.001 7 0 0 0
Each raw omnibus p-value is bold at its stated 0.050 label.

The gap-timing-unaware dataset retained 17,616 near-eye and 19,220 chest hours; all seven category rows were stable at both placements. Excluding boundary hours or unsupported cells and changing the working variance power also left every omnibus decision unchanged. The sparse outdoor-electric contrast became non-estimable in three reduced samples and crossed the null direction under two alternatives, but no alternative established a non-null outdoor-electric contrast. The within/between-participant sensitivity changed the magnitude of the external-light-during-sleep contrast without reversing its direction.

Same-participant, same-hour placement comparison

This comparison uses the same participants and participant-hours at both sensor positions: 14,103 matched participant-hours from 111 participants, 631 participant-days, and eight sites. Separately fitted placement estimates do not constitute an equivalence test or a direct test of a sensor-position effect. Outdoor electric light has only 162 matched hours and fails its prespecified support rule. The remaining contrasts have the same broad direction at both positions. The identity line is a visual concordance reference, not an equivalence boundary.

include_project_graphics(file.path(
  root,
  "results/images/H03/H03_paired_placement_comparison.png"
))
Log-log scatter plot of near-eye against chest category ratios to indoor electric light using the same participants and participant-hours at both sensor positions. Daylight categories lie above one at both positions, while display and sleep categories lie below one.
Figure 4: Same-participant, same-hour near-eye and chest category ratios. The identity line does not define equivalence.

Same-participant, same-hour source data give both component intervals and exact category-specific support.

Exploratory nonlinear time-of-day context

NoteExploratory analysis

This nonlinear generalized additive model (GAM) analysis is separate from the preregistered category inference. The association with clock time is allowed to bend across the day rather than follow a straight line. The model uses one global cyclic time-of-day smooth plus sum-to-zero light-source and site deviations, participant curves, participant-day shifts, and a boundary-aware AR(1) correction. Light-source curves are ratios to the global daily smooth, not to indoor electric light, and no curve-wide test is made.

The exploratory model uses the untransformed one-hour geometric melEDI outcome with a fixed-power Tweedie mean and log link. This targets the conditional arithmetic mean rather than a back-transformed mean of a log-transformed outcome, which is why its daytime curves are substantially higher than curves for a geometric-scale centre. Site, participant, and participant-day smooths remain in the fitted model but are set to zero in the displayed global and category curves.

The temporal model is closer to the primary model’s raw-outcome mean target, but the two displays are not numerically interchangeable. The primary scalar averages over all contributed times and forms a site-average on its fitted log-mean scale. The temporal curves condition on clock time and show only the global and light-source components. Their values therefore need not average to the primary category estimates.

Show exploratory nonlinear time-of-day details
include_project_graphics(file.path(
  root,
  "results/images/H03/H03_reader_temporal_near_eye.png"
))
Three-row, seven-category near-eye figure. The first row shows time-specific melEDI means and a global dashed curve, the second category-to-global ratios, and the third participant-hour counts. Filled circles mark supported observed hours, open circles sparse observed hours, and grey gaps zero observations.
Figure 5: Exploratory near-eye time-of-day means, ratios to the global smooth, and available participant-hours.

Near-eye curve data, ratio data, and clock-time support contain the plotted values and untransformed melEDI.

include_project_graphics(file.path(
  root,
  "results/images/H03/H03_reader_temporal_chest.png"
))
Three-row, seven-category chest figure showing time-specific melEDI means, category-to-global ratios, and participant-hour counts. Filled and open circles distinguish adequate from sparse observed support; grey gaps have no observations or fitted curve.
Figure 6: Exploratory chest time-of-day means, ratios to the global smooth, and available participant-hours.

Chest curve data, ratio data, and clock-time support contain the plotted values and untransformed melEDI.

R² and variance allocation

temporal_summary |>
  select(
    .data$placement,
    .data$observations,
    .data$participants,
    .data$participant_days,
    .data$rho,
    .data$adjusted_r_squared,
    .data$deviance_explained
  ) |>
  left_join(
    temporal_r_squared |>
      select(.data$placement, balanced_r_squared = .data$r_squared),
    by = "placement",
    relationship = "one-to-one"
  ) |>
  transmute(
    Placement = .data$placement,
    Hours = .data$observations,
    Participants = .data$participants,
    `Participant-days` = .data$participant_days,
    `AR rho` = sprintf("%.3f", .data$rho),
    `Adjusted R²` = sprintf("%.3f", .data$adjusted_r_squared),
    `Deviance explained` = sprintf("%.3f", .data$deviance_explained),
    `Site-average participant-balanced R²` = sprintf(
      "%.3f",
      .data$balanced_r_squared
    )
  ) |>
  gt::gt() |>
  gt::fmt_integer(columns = c(Hours, Participants, `Participant-days`), use_seps = TRUE) |>
  gt::tab_source_note(
    gt::md(paste(
      "The participant-balanced R² is calculated on raw melEDI.",
      "All R² values are descriptive in-sample point estimates without",
      "resampling intervals."
    ))
  ) |>
  h03_gt(12)
Table 8: Exploratory temporal-model fit summaries.
Placement Hours Participants Participant-days AR rho Adjusted R² Deviance explained Site-average participant-balanced R²
Near-eye 17,935 140 801 0.156 0.514 0.841 0.544
Chest 19,512 151 880 0.106 0.460 0.824 0.499
The participant-balanced R² is calculated on raw melEDI. All R² values are descriptive in-sample point estimates without resampling intervals.
temporal_allocation |>
  mutate(
    Placement = factor(.data$placement, levels = c("Near-eye", "Chest")),
    Component = recode(
      .data$group,
      global_time = "Global time of day",
      light_source_deviations = "Light-source deviations",
      site_deviations = "Site deviations",
      participant_curves = "Participant curves",
      participant_day_shifts = "Participant-day shifts"
    ),
    `Allocated share` = sprintf("%.1f%%", 100 * .data$shapley_share),
    `Unique partial share` = sprintf(
      "%.1f%%",
      100 * .data$partial_unique_share
    )
  ) |>
  select(Placement, Component, `Allocated share`, `Unique partial share`) |>
  gt::gt(groupname_col = "Placement") |>
  gt::tab_source_note(
    gt::md(paste(
      "Exact point allocation on the natural-log conditional-mean predictor",
      "under site-average, participant-balanced weights. Components",
      "share covariance; no simulation interval was computed."
    ))
  ) |>
  h03_gt()
Table 9: Point allocation of fitted temporal linear-predictor variance.
Component Allocated share Unique partial share
Near-eye
Global time of day 60.4% 28.8%
Light-source deviations 24.2% 5.8%
Site deviations 5.2% 4.5%
Participant curves 7.6% 6.2%
Participant-day shifts 2.5% 1.9%
Chest
Global time of day 58.3% 24.1%
Light-source deviations 26.4% 5.8%
Site deviations 6.5% 6.8%
Participant curves 5.7% 4.5%
Participant-day shifts 3.1% 2.4%
Exact point allocation on the natural-log conditional-mean predictor under site-average, participant-balanced weights. Components share covariance; no simulation interval was computed.

Temporal model checks

temporal_global_k <- temporal_k |>
  filter(.data$term == "s(time_hour)") |>
  select(
    .data$placement,
    global_edf = .data$effective_df,
    global_k_prime = .data$k_prime,
    global_k_index = .data$k_index
  )

temporal_summary |>
  left_join(
    temporal_global_k,
    by = "placement",
    relationship = "one-to-one"
  ) |>
  transmute(
    Placement = .data$placement,
    Converged = .data$converged,
    `Smoothing Hessian positive definite` =
      .data$smoothing_hessian_positive_definite,
    `Maximum |gradient|` = formatC(
      .data$smoothing_gradient_maximum_absolute,
      digits = 2,
      format = "e"
    ),
    `Global edf / k′` = sprintf(
      "%.2f / %d",
      .data$global_edf,
      .data$global_k_prime
    ),
    `Global k-index` = sprintf("%.3f", .data$global_k_index),
    `Lag-1 residual correlation` = sprintf(
      "%.3f",
      .data$standardized_residual_lag1
    ),
    `|Residual|-fitted Spearman` = sprintf(
      "%.3f",
      .data$absolute_residual_fitted_spearman
    ),
    `Observed / working zero fraction` = sprintf(
      "%.3f / %.3f",
      .data$observed_zero_fraction,
      .data$working_expected_zero_fraction
    )
  ) |>
  gt::gt() |>
  gt::tab_source_note(
    gt::md("The deterministic k-index check used zero permutation replicates.")
  ) |>
  h03_gt(12)
Table 10: Convergence, basis-capacity, residual, and zero-mass checks for the temporal models.
Placement Converged Smoothing Hessian positive definite Maximum |gradient| Global edf / k′ Global k-index Lag-1 residual correlation |Residual|-fitted Spearman Observed / working zero fraction
Near-eye TRUE TRUE 9.18e-05 9.36 / 10 0.890 0.146 0.417 0.278 / 0.465
Chest TRUE TRUE 1.14e-04 9.47 / 10 0.884 0.144 0.462 0.277 / 0.508
The deterministic k-index check used zero permutation replicates.
include_project_graphics(file.path(
  root,
  "results/images/H03/H03_reader_temporal_diagnostics.png"
))
Three-part model-check figure. Residual spread changes with fitted melEDI, lag-one residual correlation remains about 0.14 at both placements, and working Tweedie zero fractions exceed observed fractions across fitted-value deciles.
Figure 7: Exploratory temporal-model residual, autocorrelation, and zero-mass checks.

Residual points, residual bins, autocorrelations, and zero calibration provide the exact model-check source values.

Global time of day received 60.4% of the near-eye and 58.3% of the chest point allocation. Light-source deviations received 24.2% and 26.4%, site deviations 5.2% and 6.5%, participant curves 7.6% and 5.7%, and participant-day shifts 2.5% and 3.1%. The Shapley allocation averages a component’s contribution across all orders in which components could be added, allocating shared fitted-model information rather than counting it repeatedly. These are point summaries without uncertainty intervals and are not independent or causal percentages of raw melEDI variance.

Both final fits converged at full coefficient rank with positive-definite smoothing-parameter Hessians. The chest fit with zero AR correlation, used only to estimate the fixed AR value, did not converge; the final fixed-AR fit did. The global smooths used 9.36 and 9.47 effective degrees of freedom out of ten available, with deterministic k-indices of 0.890 and 0.884. The smooths are therefore close to their basis capacity, and no permutation p-value was computed.

The AR adjustment reduces but does not eliminate the tendency for nearby hours to retain similar unexplained deviations: the technical lag-one residual correlations are 0.146 near eye and 0.144 at chest. Residual spread remains fitted-value dependent, and the working zero fractions of 0.465 and 0.508 exceed the observed fractions of 0.278 and 0.277. These limitations confine the temporal analysis to descriptive conditional-mean context. The ribbons are pointwise 95% intervals; they are not simultaneous bands and do not support curve-wide significance claims.

Exploratory linear latitude context

Latitude cannot be estimated alongside a saturated fixed-site term because each site has only one latitude. A separate exploratory model therefore replaces site with category-specific linear absolute-latitude slopes:

geo_medi_1h ~ 0 + light_source +
  light_source:absolute_latitude_10deg_centered

The coefficient for each category is the multiplicative change in expected one-hour melEDI per 10° farther from the equator. Centering at the site-average mean absolute latitude, 39.2° near eye and 38.1° at chest, changes the intercepts but not the slopes. Uncertainty is clustered at site, not participant, because latitude varies only across nine near-eye and eight chest sites. Seven slope p-values receive an FDR adjustment within each placement.

Show exploratory latitude details
latitude_slopes |>
  mutate(
    Placement = factor(.data$placement, levels = c("Near-eye", "Chest")),
    Category = factor(
      .data$short_label,
      levels = near_interaction$short_label
    ),
    `Ratio per +10° (95% CI)` = format_ratio_ci(
      .data$ratio_per_10deg,
      .data$ratio_conf_low,
      .data$ratio_conf_high
    ),
    `Raw p` = format_p_cell(.data$p_raw, FALSE),
    `FDR-adjusted p` = format_p_cell(
      .data$p_adjusted,
      .data$p_adjusted < 0.05
    ),
    `Leave-one-site-out sign` = paste0(
      .data$sign_agreement,
      "/",
      .data$omissions
    ),
    `Leave-one-site-out ratio range` = sprintf(
      "%.2f–%.2f",
      .data$ratio_min,
      .data$ratio_max
    )
  ) |>
  arrange(.data$Placement, .data$Category) |>
  select(
    Placement,
    Category,
    `Ratio per +10° (95% CI)`,
    `Raw p`,
    `FDR-adjusted p`,
    Sites = .data$category_sites,
    `Leave-one-site-out sign`,
    `Leave-one-site-out ratio range`
  ) |>
  gt::gt(groupname_col = "Placement") |>
  gt::fmt_markdown(columns = c(`Raw p`, `FDR-adjusted p`)) |>
  gt::fmt_integer(columns = Sites) |>
  gt::tab_source_note(
    gt::md(paste(
      "Ratios and site-cluster HC1 95% intervals are coefficient-wise",
      "ecological summaries. FDR adjustment forms one seven-slope family",
      "within each placement. The omission columns show how often the slope",
      "kept its full-model direction and its point-estimate range."
    ))
  ) |>
  h03_gt(12)
Table 11: Exploratory category-specific linear absolute-latitude associations.
Category Ratio per +10° (95% CI) Raw p FDR-adjusted p Sites Leave-one-site-out sign Leave-one-site-out ratio range
Near-eye
Indoor electric 1.197 (1.081–1.326) 0.004 0.013 9 9/9 1.17–1.25
Outdoor electric 1.103 (0.733–1.660) 0.594 0.594 9 8/9 0.79–2.50
Indoor daylight 1.253 (1.131–1.388) <0.001 0.007 9 9/9 1.23–1.32
Outdoor daylight 1.354 (1.105–1.661) 0.009 0.021 9 9/9 1.26–1.75
Emissive display 1.171 (0.827–1.660) 0.326 0.380 9 8/9 0.96–1.34
Sleep darkness 1.428 (1.076–1.895) 0.020 0.035 9 9/9 1.12–1.50
External light during sleep 1.461 (0.791–2.701) 0.192 0.269 9 9/9 1.22–2.10
Chest
Indoor electric 1.142 (1.061–1.229) 0.004 0.025 8 8/8 1.11–1.16
Outdoor electric 1.278 (0.795–2.055) 0.262 0.367 8 8/8 1.15–1.72
Indoor daylight 1.047 (0.887–1.237) 0.532 0.532 8 7/8 1.00–1.39
Outdoor daylight 1.265 (0.988–1.620) 0.059 0.138 8 8/8 1.14–1.45
Emissive display 1.185 (0.904–1.554) 0.182 0.318 8 8/8 1.08–1.34
Sleep darkness 0.818 (0.499–1.340) 0.368 0.429 8 7/8 0.70–1.30
External light during sleep 1.739 (1.082–2.795) 0.028 0.099 7 7/8 1.00–2.19
Ratios and site-cluster HC1 95% intervals are coefficient-wise ecological summaries. FDR adjustment forms one seven-slope family within each placement. The omission columns show how often the slope kept its full-model direction and its point-estimate range.
include_project_graphics(file.path(
  root,
  "results/images/H03/H03_reader_latitude_category_slopes.png"
))
Two-panel forest plot of category-specific ratios per ten degrees farther from the equator. Point outlines and intervals use the corresponding light-source colour. Four near-eye slopes (indoor electric, indoor daylight, outdoor daylight, and sleep darkness) are filled in that colour because their FDR-adjusted p-values are below 0.050. Only indoor electric is filled at chest. Outdoor electric, display, and external-light intervals are wider.
Figure 8: Exploratory factor change in expected melEDI per 10° increase in absolute latitude; points filled in their light-source colour pass the seven-slope within-placement FDR label.

Latitude result data, site latitudes, leave-one-site-out fits, and model checks provide the exact exploratory outputs.

Near eye showed FDR-labelled positive gradients for indoor electric light (ratio 1.197 per +10°, 95% CI 1.081–1.326), indoor daylight (1.253, 1.131–1.388), outdoor daylight (1.354, 1.105–1.661), and sleep darkness (1.428, 1.076–1.895). Each retained a positive slope in all nine leave-one-site-out fits. At chest, only indoor electric light retained the FDR label (1.142, 1.061–1.229) and remained positive in all eight omissions.

Both latitude models converged without warnings and retained design rank 14/14. Their site-cluster covariance ranks were necessarily limited to eight and seven because only nine and eight independent site clusters were available. Accordingly, the report gives coefficient-wise slope tests and no joint seven-slope Wald test.

These are ecological associations across a small number of sites. Absolute latitude may stand in for season, climate, built environment, behaviour, or other site differences; the analysis does not identify a causal latitude effect or show that latitude adequately replaces the supported category-by-site interaction.

Interpretation

Self-reported primary light source is strongly associated with measured one-hour personal light exposure. Daylight, especially outdoors, corresponds to substantially higher melEDI than indoor electric light, whereas display and sleep-environment categories correspond to lower values. The magnitude of these contrasts varies materially by site. Outdoor electric is especially sparse and site-dependent, so its cross-site descriptive value depends strongly on whether a common category contrast is imposed.

The result is observational. It does not show that changing a reported light source causes a particular exposure, predict a health outcome, or establish equivalence between near-eye and chest measurements. Participant-clustered inference, sensitivity analyses, and deletion checks support the overall category conclusion, while residual and zero-mass model checks limit claims about the working Tweedie distribution. The nonlinear time-of-day GAMs add useful descriptive context but remain separate from the preregistered inference. The linear absolute-latitude context identifies several exploratory ecological gradients, but the small number of sites and site-level confounding preclude a causal latitude interpretation.

Detailed analysis record

Exact sample and category support

main_samples |>
  transmute(
    Placement = as.character(.data$Placement),
    `Participant-hours` = .data$observations,
    Participants = .data$participants,
    `Participant-days` = .data$participant_days,
    Sites = .data$sites,
    `Exact-zero hours` = .data$exact_zero_hours
  ) |>
  gt::gt() |>
  gt::fmt_integer(columns = where(is.numeric), use_seps = TRUE) |>
  h03_gt()
Table 12: Exact fitted samples for the primary and complementary models.
Placement Participant-hours Participants Participant-days Sites Exact-zero hours
Near-eye 17,935 140 801 9 4,977
Chest 19,512 151 880 8 5,409
primary |>
  mutate(Placement = factor(.data$placement, levels = c("Near-eye", "Chest"))) |>
  arrange(.data$Placement, .data$category_order) |>
  transmute(
    Placement,
    Category = .data$short_label,
    `Participant-hours` = .data$hours,
    Participants = .data$participants,
    `Participant-days` = .data$participant_days,
    Sites = .data$sites
  ) |>
  gt::gt(groupname_col = "Placement") |>
  gt::fmt_integer(
    columns = c(`Participant-hours`, Participants, `Participant-days`, Sites),
    use_seps = TRUE
  ) |>
  h03_gt(12)
Table 13: Exact fitted support for every light-source category.
Category Participant-hours Participants Participant-days Sites
Near-eye
Indoor electric 4,629 138 695 9
Outdoor electric 203 54 118 9
Indoor daylight 4,804 138 692 9
Outdoor daylight 1,557 135 512 9
Emissive display 664 88 235 9
Sleep darkness 5,225 130 712 9
External light during sleep 853 66 233 9
Chest
Indoor electric 5,468 149 765 8
Outdoor electric 219 59 129 8
Indoor daylight 4,979 148 741 8
Outdoor daylight 1,686 145 554 8
Emissive display 861 97 275 8
Sleep darkness 5,540 142 767 8
External light during sleep 759 63 225 7

Complete category and supported site summaries

These are site-average estimates: each site receives equal weight on the fitted log-mean scale, followed by one back-transformation to melEDI in lux. They are shown with 95% confidence intervals (95% CIs):

  • Indoor electric: mean 85.1 (71.6–101.1) lx; ratio to indoor electric 1.000 (reference).
  • Outdoor electric: mean 9.4 (6.9–12.8) lx; ratio to indoor electric 0.110 (0.078–0.156).
  • Indoor daylight: mean 198.8 (169.2–233.4) lx; ratio to indoor electric 2.335 (1.877–2.905).
  • Outdoor daylight: mean 959.6 (786.8–1,170.2) lx; ratio to indoor electric 11.274 (8.709–14.595).
  • Emissive display: mean 23.8 (17.4–32.5) lx; ratio to indoor electric 0.280 (0.197–0.398).
  • Sleep darkness: mean 2.2 (1.5–3.3) lx; ratio to indoor electric 0.026 (0.018–0.039).
  • External light during sleep: mean 11.2 (8.1–15.5) lx; ratio to indoor electric 0.131 (0.091–0.189).

The three largest higher and three largest lower supported near-eye site deviations among those labelled after the predeclared false-discovery-rate (FDR) adjustment were:

  • Tübingen (DE), Outdoor electric: mean 191.8 (30.8–1,194.4) lx; deviation factor 20.421 (3.949–105.591); FDR-adjusted p 0.002.
  • Munich (DE), External light during sleep: mean 130.2 (87.2–194.3) lx; deviation factor 11.647 (7.195–18.853); FDR-adjusted p <0.001.
  • Dortmund (DE), External light during sleep: mean 103.0 (24.6–432.1) lx; deviation factor 9.217 (2.497–34.028); FDR-adjusted p 0.004.
  • Kumasi (GH), Sleep darkness: mean 0.4 (0.1–1.2) lx; deviation factor 0.166 (0.056–0.488); FDR-adjusted p 0.004.
  • Tübingen (DE), Emissive display: mean 5.8 (3.4–10.1) lx; deviation factor 0.245 (0.138–0.435); FDR-adjusted p <0.001.
  • Borås (SE), Emissive display: mean 7.3 (4.0–13.4) lx; deviation factor 0.306 (0.165–0.568); FDR-adjusted p 0.002.

Preregistration deviations

  • Study-site structure (H03-H04 site structure). The registered random-effects architecture was not retained because site support did not justify the registered random-slope formulation and the participant-day component collapsed. The selected analysis uses fixed study-site adjustment for the population-average category association and a separate category-by-site interaction model.
  • Response model (H03-H04 error distribution). The registered Gaussian response formulation had inadequate distributional diagnostics. The selected fixed-power quasi-Tweedie log-mean models with participant-clustered covariance accommodate the non-negative, right-skewed response and exact zeros. They are mean models, not calibrated zero-generating distributions.
  • Light-source categories (H03 light-source categories). The analysis retains all seven prespecified categories under one placement-independent support rule and labels unsupported site-category cells rather than silently deleting categories.
  • Primary and site-interaction questions (H03 primary test estimand). The additive model supplies the primary six-restriction category omnibus; the separate category-by-site interaction model supplies descriptive means, ratios, and site-specific variation.
  • Multiple-testing adjustment (H03 multiplicity and contrasts). False-discovery-rate adjustment is applied to each explicitly assembled complete contrast family; category and site-interaction questions remain separate families.