inst/tinytest/test_ancestry.R

library(tinytest)
library(DBI)

test_ancestry_relations <- function() {
  con <- rduckhts_connect()
  on.exit(dbDisconnect(con, shutdown = TRUE), add = TRUE)
  dbExecute(con, paste(
    "CREATE TABLE ancestry_ref AS SELECT 'chr1' AS chromosome, (100+i)::INTEGER AS position,",
    "CASE WHEN i % 2 = 0 THEN 'A' ELSE 'C' END AS allele_a,",
    "CASE WHEN i % 2 = 0 THEN 'G' ELSE 'T' END AS allele_b,",
    "g.group_id, CASE WHEN g.group_id = 'A' THEN 0.15 + i*0.06",
    "ELSE 0.8 - i*0.04 END AS frequency",
    "FROM range(8) t(i), (VALUES ('A'), ('B')) g(group_id)"
  ))
  dbExecute(con, paste(
    "CREATE TABLE ancestry_pc AS SELECT DISTINCT chromosome, position, allele_a, allele_b,",
    "pc, CASE WHEN pc = 1 THEN position - 103.0 ELSE sin(position) END AS loading",
    "FROM ancestry_ref, (VALUES (1), (2)) p(pc)"
  ))
  dbExecute(con, "CREATE TABLE ancestry_correction AS SELECT 1 AS pc, 1.0 AS coefficient UNION ALL SELECT 2, 1.0")
  panel <- rduckhts_ancestry_panel(con, "ancestry_ref", "ancestry_pc",
                                   "ancestry_selected", "GRCh38", spacing_bp = 1,
                                   max_sites = 5)
  # 8 eligible sites on one contig, max_sites = 5: 5 sites spread along it.
  expect_equal(panel$sites, 5)
  expect_equal(nchar(panel$panel_sha256), 64L)
  selected <- dbGetQuery(con, "SELECT site_index, position FROM ancestry_selected ORDER BY site_index")
  expect_equal(selected$site_index, 0:4)
  expect_equal(selected$position, c(100, 102, 104, 105, 107))
  # A capped panel spans every contig rather than filling from the first one.
  dbExecute(con, "CREATE TEMP VIEW ancestry_two_contig_ref AS SELECT * FROM ancestry_ref UNION ALL SELECT * REPLACE ('chr2' AS chromosome) FROM ancestry_ref")
  dbExecute(con, "CREATE TEMP VIEW ancestry_two_contig_pc AS SELECT * FROM ancestry_pc UNION ALL SELECT * REPLACE ('chr2' AS chromosome) FROM ancestry_pc")
  two_contig <- rduckhts_ancestry_panel(con, "ancestry_two_contig_ref", "ancestry_two_contig_pc",
                                        "ancestry_two_contig", "GRCh38", spacing_bp = 1,
                                        max_sites = 8)
  expect_equal(two_contig$sites, 8)
  expect_equal(dbGetQuery(con, "SELECT region, count(*)::INTEGER AS n FROM ancestry_two_contig GROUP BY region ORDER BY region")$n,
               c(4L, 4L))
  # Uneven contigs keep every contig: 7 sites on chr1 and 1 on chr2 with
  # max_sites = 4 give chr1 three sites and chr2 its only one.
  dbExecute(con, "CREATE TEMP VIEW ancestry_uneven_ref AS SELECT * FROM ancestry_ref WHERE position < 107 UNION ALL SELECT * REPLACE ('chr2' AS chromosome) FROM ancestry_ref WHERE position = 107")
  dbExecute(con, "CREATE TEMP VIEW ancestry_uneven_pc AS SELECT * FROM ancestry_pc WHERE position < 107 UNION ALL SELECT * REPLACE ('chr2' AS chromosome) FROM ancestry_pc WHERE position = 107")
  uneven <- rduckhts_ancestry_panel(con, "ancestry_uneven_ref", "ancestry_uneven_pc",
                                    "ancestry_uneven", "GRCh38", spacing_bp = 1, max_sites = 4)
  expect_equal(uneven$sites, 4)
  expect_equal(dbGetQuery(con, "SELECT region, count(*)::INTEGER AS n FROM ancestry_uneven GROUP BY region ORDER BY region")$n,
               c(3L, 1L))
  # After one site each, the rest follow eligible sites minus one: 2, 2 and 100
  # sites with max_sites = 50 share 47 remaining slots by weights 1, 1 and 99,
  # giving 2, 1 and 47 (the tie between the small contigs goes by region order).
  dbExecute(con, paste0(
    "CREATE TEMP TABLE ancestry_weight_sites AS SELECT * FROM (VALUES ('chrA', 2), ",
    "('chrB', 2), ('chrC', 100)) t(chromosome, n), range(n) r(i)"))
  for (side in c("ref", "pc")) {
    dbExecute(con, sprintf(paste0(
      "CREATE TEMP VIEW ancestry_weight_%s AS SELECT a.* REPLACE (s.chromosome AS chromosome, ",
      "1000 + 10 * s.i AS position) FROM ancestry_%s a, ancestry_weight_sites s ",
      "WHERE a.position = 100"), side, side))
  }
  weighted <- rduckhts_ancestry_panel(con, "ancestry_weight_ref", "ancestry_weight_pc",
                                      "ancestry_weighted", "GRCh38", spacing_bp = 1,
                                      max_sites = 50)
  expect_equal(weighted$sites, 50)
  expect_equal(dbGetQuery(con, "SELECT region, count(*)::INTEGER AS n FROM ancestry_weighted GROUP BY region ORDER BY region")$n,
               c(2L, 1L, 47L))
  # A locus needs one allele pair, and every group and every PC exactly once: a
  # repeated group (101), a missing group (102), a second allele pair (103), a
  # repeated PC (104) and a missing PC (105) each exclude only their locus.
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_locus_ref AS SELECT * REPLACE (",
    "CASE WHEN position = 101 THEN 'A' ELSE group_id END AS group_id, ",
    "CASE WHEN position = 103 AND group_id = 'B' THEN 'A' ELSE allele_b END AS allele_b) ",
    "FROM ancestry_ref WHERE NOT (position = 102 AND group_id = 'B')"))
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_locus_pc AS SELECT * REPLACE ",
    "(CASE WHEN position = 104 THEN 1 ELSE pc END AS pc) FROM ancestry_pc ",
    "WHERE NOT (position = 105 AND pc = 2)"))
  locus <- rduckhts_ancestry_panel(con, "ancestry_locus_ref", "ancestry_locus_pc",
                                   "ancestry_locus", "GRCh38", spacing_bp = 1,
                                   max_sites = 100)
  expect_equal(locus$sites, 3)
  expect_equal(dbGetQuery(con, "SELECT position FROM ancestry_locus ORDER BY site_index")$position,
               c(100, 106, 107))
  # With fewer slots than contigs, the largest contigs keep one site each, and equal
  # contigs are taken in region order.
  contig_panel <- function(label, sizes, max_sites) {
    dbExecute(con, sprintf(paste0(
      "CREATE OR REPLACE TEMP TABLE %s_sites AS SELECT * FROM (VALUES %s) t(chromosome, n), ",
      "range(n) r(i)"), label,
      paste(sprintf("('%s', %d)", names(sizes), sizes), collapse = ", ")))
    for (side in c("ref", "pc")) {
      dbExecute(con, sprintf(paste0(
        "CREATE OR REPLACE TEMP VIEW %s_%s AS SELECT a.* REPLACE (s.chromosome AS chromosome, ",
        "1000 + 10 * s.i AS position) FROM ancestry_%s a, %s_sites s WHERE a.position = 100"),
        label, side, side, label))
    }
    rduckhts_ancestry_panel(con, paste0(label, "_ref"), paste0(label, "_pc"), label, "GRCh38",
                            spacing_bp = 1, max_sites = max_sites)
    dbGetQuery(con, sprintf("SELECT region, count(*)::INTEGER AS n FROM %s GROUP BY region ORDER BY region",
                            label))
  }
  largest <- contig_panel("ancestry_largest", c(chrA = 1, chrB = 5, chrC = 3), 2)
  expect_equal(largest$region, c("chrB", "chrC"))
  expect_equal(largest$n, c(1L, 1L))
  tied <- contig_panel("ancestry_tied", c(chrC = 2, chrA = 2, chrB = 2), 2)
  expect_equal(tied$region, c("chrA", "chrB"))
  # Group and PC masks at the contract limits: 30 groups (one with a quote in its
  # identifier) and 64 PCs. A locus missing the highest-ordinal group (1001) or PC 64
  # (1002) is excluded; the complete locus (1000) is kept.
  dbExecute(con, paste0(
    "CREATE TEMP TABLE ancestry_mask_groups AS SELECT CASE WHEN g = 30 THEN 'O''Neil' ",
    "ELSE printf('g%02d', g) END AS group_id FROM range(1, 31) t(g)"))
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_mask_ref AS SELECT 'chr1' AS chromosome, p AS position, ",
    "'A' AS allele_a, 'G' AS allele_b, group_id, 0.3 AS frequency ",
    "FROM range(1000, 1003) t(p), ancestry_mask_groups ",
    "WHERE NOT (p = 1001 AND group_id = 'g29')"))
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_mask_pc AS SELECT 'chr1' AS chromosome, p AS position, ",
    "'A' AS allele_a, 'G' AS allele_b, pc, sin(p * pc) AS loading ",
    "FROM range(1000, 1003) t(p), range(1, 65) u(pc) WHERE NOT (p = 1002 AND pc = 64)"))
  mask <- rduckhts_ancestry_panel(con, "ancestry_mask_ref", "ancestry_mask_pc",
                                  "ancestry_mask", "GRCh38", spacing_bp = 1, max_sites = 10)
  expect_equal(mask$sites, 1)
  expect_equal(dbGetQuery(con, "SELECT position FROM ancestry_mask")$position, 1000)
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_null_allele_ref AS SELECT * REPLACE ",
    "(CASE WHEN position = 100 AND group_id = 'A' THEN NULL ELSE allele_a END ",
    "AS allele_a) FROM ancestry_ref"))
  null_panel <- rduckhts_ancestry_panel(
    con, "ancestry_null_allele_ref", "ancestry_pc", "ancestry_null_panel",
    "GRCh38", spacing_bp = 1, max_sites = 5)
  expect_equal(null_panel$sites, 5)
  expect_equal(dbGetQuery(con, "SELECT position FROM ancestry_null_panel ORDER BY site_index")$position,
               c(101, 103, 104, 106, 107))
  # Sites whose loadings are not finite, or whose frequencies fall outside [0, 1],
  # are not selected.
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_bad_loading_pc AS SELECT * REPLACE ",
    "(CASE WHEN position = 100 AND pc = 2 THEN NULL ",
    "WHEN position = 101 AND pc = 1 THEN 'inf'::DOUBLE ELSE loading END AS loading) ",
    "FROM ancestry_pc"))
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_bad_frequency_ref AS SELECT * REPLACE ",
    "(CASE WHEN position = 102 AND group_id = 'B' THEN 1.2 ELSE frequency END ",
    "AS frequency) FROM ancestry_ref"))
  # A non-consecutive global PC set would give a panel its source products reject.
  dbExecute(con, "CREATE TEMP VIEW ancestry_shifted_pc AS SELECT * REPLACE (pc + 1 AS pc) FROM ancestry_pc")
  expect_error(rduckhts_ancestry_panel(
    con, "ancestry_ref", "ancestry_shifted_pc", "ancestry_shifted_panel", "GRCh38"),
    pattern = "consecutive 1..64 PCs")
  expect_false(dbExistsTable(con, "ancestry_shifted_panel"))
  invalid_panel <- rduckhts_ancestry_panel(
    con, "ancestry_bad_frequency_ref", "ancestry_bad_loading_pc", "ancestry_invalid_panel",
    "GRCh38", spacing_bp = 1, max_sites = 5)
  expect_equal(dbGetQuery(con, "SELECT position FROM ancestry_invalid_panel ORDER BY site_index")$position,
               103:107)
  dbExecute(con, "CREATE TABLE ancestry_candidates AS SELECT region, position, allele_a, allele_b FROM ancestry_selected")
  matched_panel <- rduckhts_ancestry_panel(con, "ancestry_ref", "ancestry_pc",
                                           "ancestry_matched", "GRCh38",
                                           candidate_table = "ancestry_candidates")
  expect_equal(matched_panel$panel_sha256, panel$panel_sha256)
  dbExecute(con, "CREATE TABLE ancestry_empty_candidates AS SELECT * FROM ancestry_candidates WHERE false")
  expect_error(rduckhts_ancestry_panel(
    con, "ancestry_ref", "ancestry_pc", "ancestry_retry", "GRCh38",
    candidate_table = "ancestry_empty_candidates"), pattern = "panel is empty")
  expect_false(dbExistsTable(con, "ancestry_retry"))
  expect_error(rduckhts_ancestry_panel(
    con, "ancestry_ref", "ancestry_pc", "ancestry_matched", "GRCh38",
    candidate_table = "ancestry_empty_candidates", overwrite = TRUE),
    pattern = "panel is empty")
  expect_equal(dbGetQuery(con, "SELECT count(*) AS sites FROM ancestry_matched")$sites, 5)
  expect_equal(dbGetQuery(con,
    "SELECT duckhts_somalier_panel_sha256('ancestry_matched') AS hash")$hash,
    matched_panel$panel_sha256)
  dbExecute(con, "INSERT INTO ancestry_empty_candidates SELECT * FROM ancestry_candidates WHERE position = 100")
  retry <- rduckhts_ancestry_panel(
    con, "ancestry_ref", "ancestry_pc", "ancestry_retry", "GRCh38",
    candidate_table = "ancestry_empty_candidates", overwrite = FALSE)
  expect_equal(retry$sites, 1)
  replaced <- rduckhts_ancestry_panel(
    con, "ancestry_ref", "ancestry_pc", "ancestry_matched", "GRCh38",
    candidate_table = "ancestry_empty_candidates", overwrite = TRUE)
  expect_equal(replaced$panel_sha256, retry$panel_sha256)
  # Inside a caller's transaction: the panel builds, and rollback removes it.
  dbBegin(con)
  in_transaction <- rduckhts_ancestry_panel(
    con, "ancestry_ref", "ancestry_pc", "ancestry_in_transaction", "GRCh38",
    candidate_table = "ancestry_candidates")
  expect_equal(in_transaction$panel_sha256, matched_panel$panel_sha256)
  dbRollback(con)
  expect_false(dbExistsTable(con, "ancestry_in_transaction"))
  expect_equal(dbGetQuery(con, paste(
    "SELECT count(*) AS n FROM duckdb_tables()",
    "WHERE table_name LIKE '__duckhts_ancestry_panel_%'"))$n, 0)
  dbExecute(con, paste(
    "CREATE TABLE ancestry_input AS SELECT 'S' AS sample_id, chromosome, position, allele_a, allele_b,",
    "sum(CASE WHEN group_id = 'A' THEN 0.7 ELSE 0.3 END * frequency) AS frequency",
    "FROM ancestry_ref GROUP BY chromosome, position, allele_a, allele_b"
  ))
  dbExecute(con, "UPDATE ancestry_input SET allele_a = 'T', allele_b = 'C' WHERE position = 100")
  dbExecute(con, paste("UPDATE ancestry_input SET allele_a = 'T', allele_b = 'C',",
                       "frequency = 1 - frequency WHERE position = 101"))
  dbExecute(con, "UPDATE ancestry_input SET frequency = frequency + 0.04 WHERE position = 102")
  dbExecute(con, "UPDATE ancestry_input SET frequency = NULL WHERE position = 107")
  dbExecute(con, "INSERT INTO ancestry_input SELECT * FROM ancestry_input WHERE position = 106")
  dbExecute(con, "INSERT INTO ancestry_input VALUES ('S', 'chr1', 999, 'A', 'G', 0.5)")
  dbExecute(con, "INSERT INTO ancestry_input VALUES ('S', 'chr1', 998, 'A', 'T', 0.5)")
  default_q <- dbGetQuery(con, paste(
    "SELECT duckhts_ancestry_proportions(",
    "[1.0, 0.0, 0.0, 1.0], [0.25, 0.75], 2) AS q"
  ))$q[[1L]]
  expect_equal(default_q, c(0.25, 0.75))
  out <- rduckhts_ancestry_proportions(con, "ancestry_input", "ancestry_ref",
                                       "ancestry_pc", "ancestry_correction", min_cor = 0)
  # Numeric long-format group IDs are labels: same fit, returned as their SQL text.
  dbExecute(con, paste(
    "CREATE TEMP VIEW ancestry_numeric_ref AS SELECT * REPLACE",
    "(CASE WHEN group_id = 'A' THEN 1 ELSE 1000000 END AS group_id) FROM ancestry_ref"))
  numeric_ids <- rduckhts_ancestry_proportions(con, "ancestry_input", "ancestry_numeric_ref",
                                               "ancestry_pc", "ancestry_correction", min_cor = 0)
  expect_equal(numeric_ids$group_id, c("1", "1000000"))
  expect_equal(numeric_ids$proportion, out$proportion)
  # A long reference with an integer chromosome column compares numbers, as an
  # integer wide reference does: chr01 joins 1, whether the loadings name the
  # contig as a number or as text. Loadings on a name that gives no integer
  # belong to no locus of such a reference and change nothing.
  dbExecute(con, "CREATE TEMP VIEW ancestry_integer_ref AS SELECT * REPLACE (1::INTEGER AS chromosome) FROM ancestry_ref")
  dbExecute(con, "CREATE TEMP VIEW ancestry_integer_pc AS SELECT * REPLACE (1::BIGINT AS chromosome) FROM ancestry_pc")
  dbExecute(con, "CREATE TEMP VIEW ancestry_chr01_input AS SELECT * REPLACE ('chr01' AS chromosome) FROM ancestry_input")
  dbExecute(con, paste("CREATE TEMP VIEW ancestry_extra_pc AS SELECT * FROM ancestry_pc UNION ALL",
                       "SELECT * REPLACE ('chrX' AS chromosome) FROM ancestry_pc"))
  for (loadings in c("ancestry_pc", "ancestry_integer_pc", "ancestry_extra_pc")) {
    integer_long <- rduckhts_ancestry_proportions(con, "ancestry_chr01_input", "ancestry_integer_ref",
                                                  loadings, "ancestry_correction", min_cor = 0)
    expect_equal(integer_long$proportion, out$proportion, info = loadings)
    expect_equal(integer_long$used_variants, out$used_variants, info = loadings)
  }
  # The same input joins nothing of the text reference: 01 is another name.
  text_long <- rduckhts_ancestry_proportions(con, "ancestry_chr01_input", "ancestry_ref",
                                             "ancestry_pc", "ancestry_correction", min_cor = 0)
  expect_equal(text_long$used_variants, rep(0, 2L))
  expect_equal(out$status, rep("ok", 2L))
  expect_equal(out$used_variants, rep(6, 2L))
  expect_equal(out$input_variants, rep(11, 2L))
  expect_equal(out$reversed_variants, rep(1, 2L))
  expect_equal(out$flipped_variants, rep(1, 2L))
  # A site matching only after both strand flip and allele swap (flipped_reversed)
  # is the same evidence as its direct spelling: same fit, counted as a reversal
  # and a flip.
  dbExecute(con, "CREATE TABLE ancestry_input_flip_rev AS SELECT * FROM ancestry_input")
  dbExecute(con, paste("UPDATE ancestry_input_flip_rev SET allele_a = 'A', allele_b = 'G',",
                       "frequency = 1 - frequency WHERE position = 103"))
  flip_rev <- rduckhts_ancestry_proportions(con, "ancestry_input_flip_rev", "ancestry_ref",
                                            "ancestry_pc", "ancestry_correction", min_cor = 0)
  expect_equal(flip_rev$proportion, out$proportion)
  expect_equal(flip_rev$reversed_variants, rep(2, 2L))
  expect_equal(flip_rev$flipped_variants, rep(2, 2L))
  expect_equal(out$duplicate_variants, rep(2, 2L))
  expect_equal(out$ambiguous_variants, rep(1, 2L))
  expect_equal(out$unmatched_variants, rep(1, 2L))
  expect_equal(out$missing_variants, rep(1, 2L))
  ref <- dbGetQuery(con, "SELECT position, group_id, frequency FROM ancestry_ref WHERE position BETWEEN 100 AND 105 ORDER BY position, group_id")
  f <- matrix(ref$frequency, ncol = 2L, byrow = TRUE)[1:6, ]
  input <- dbGetQuery(con, "SELECT position, frequency FROM ancestry_input WHERE position BETWEEN 100 AND 105 ORDER BY position")
  input$frequency[input$position == 101L] <- 1 - input$frequency[input$position == 101L]
  p <- cbind((100:105) - 103, sin(100:105))
  x <- crossprod(p, f)
  y <- drop(crossprod(p, input$frequency))
  delta <- x[, 1L] - x[, 2L]
  expected_a <- max(0, min(1, sum(delta * (y - x[, 2L])) / sum(delta^2)))
  expect_true(max(abs(out$proportion - c(expected_a, 1 - expected_a))) < 1e-6)
  dbExecute(con, paste0(
    "CREATE VIEW ancestry_wide AS SELECT try_cast(regexp_replace(r.chromosome, '^chr', '') ",
    "AS INTEGER) AS chromosome, r.position, r.allele_a, r.allele_b, ",
    "max(p.loading) FILTER (WHERE p.pc = 1) AS PC1, ",
    "max(p.loading) FILTER (WHERE p.pc = 2) AS PC2, ",
    paste(paste0("0.0::DOUBLE AS PC", 3:16), collapse = ", "), ", ",
    "max(r.frequency) FILTER (WHERE r.group_id = 'A') AS A, ",
    "max(r.frequency) FILTER (WHERE r.group_id = 'B') AS B ",
    "FROM ancestry_ref r JOIN ancestry_pc p USING (chromosome, position, allele_a, allele_b) ",
    "GROUP BY r.chromosome, r.position, r.allele_a, r.allele_b"
  ))
  dbExecute(con, paste0("CREATE VIEW ancestry_wide_correction AS SELECT * FROM ancestry_correction ",
                        "UNION ALL SELECT i, 1.0 FROM range(3, 17) t(i)"))
  wide <- rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_wide", "ancestry_wide_correction", min_cor = 0)
  expect_equal(wide$group_id, out$group_id)
  expect_equal(wide$status, out$status)
  expect_equal(wide$proportion, out$proportion, tolerance = 1e-6)
  expect_equal(wide$cor_pred, out$cor_pred, tolerance = 1e-6)
  expect_equal(wide$used_variants, out$used_variants)
  expect_equal(wide$input_variants, out$input_variants)
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_one_group_pc AS SELECT chromosome, position, ",
    "allele_a, allele_b, A, PC1 FROM ancestry_wide"))
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_one_correction AS SELECT * ",
    "FROM ancestry_wide_correction WHERE pc = 1"))
  one <- rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_one_group_pc", "ancestry_one_correction",
    min_cor = 0)
  expect_equal(nrow(one), 1L)
  expect_equal(one$group_id, "A")
  expect_equal(one$input_variants, 11L)
  dbExecute(con, "CREATE TEMP VIEW ancestry_zero_input AS SELECT * FROM ancestry_input WHERE false")
  zero <- rduckhts_ancestry_proportions(
    con, "ancestry_zero_input", "ancestry_one_group_pc", "ancestry_one_correction")
  expect_equal(nrow(zero), 0L)
  expect_equal(names(zero), names(one))
  for (bad_position in c(0, -1, 1.5)) {
    dbExecute(con, paste0(
      "CREATE OR REPLACE TEMP VIEW ancestry_bad_position AS ",
      "SELECT * REPLACE (CASE WHEN position = 100 THEN ", bad_position,
      " ELSE position END AS position) FROM ancestry_input"))
    expect_error(rduckhts_ancestry_proportions(
      con, "ancestry_bad_position", "ancestry_wide", "ancestry_wide_correction"),
      pattern = "input positions must be positive whole numbers")
  }
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_bad_long_ref AS SELECT * REPLACE ",
    "(CASE WHEN position = 100 THEN 0 ELSE position END AS position) ",
    "FROM ancestry_ref"))
  expect_error(rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_bad_long_ref", "ancestry_pc",
    "ancestry_correction"), pattern = "reference sites require")
  local({
    observed <- character()
    previous <- getOption("duckhts.ancestry_step_hook")
    on.exit(options(duckhts.ancestry_step_hook = previous))
    options(duckhts.ancestry_step_hook = function(stage) {
      observed <<- c(observed, stage)
    })
    rduckhts_ancestry_proportions(
      con, "ancestry_input", "ancestry_wide", "ancestry_wide_correction",
      min_cor = 0)
    expect_equal(observed, c("entry", "validation", "duplicate", "audit",
                             "alignment", "sample_index", "finite", "moments",
                             "solver", "prediction", "publish"))
  })
  for (labels in list(c("PC1", "pc1"), c("group with space", "O'Brien"),
                      c("__g1", "PC1"))) {
    dbExecute(con, paste0(
      "CREATE OR REPLACE TEMP VIEW ancestry_named_ref AS SELECT * REPLACE ",
      "(CASE group_id WHEN 'A' THEN ", dbQuoteString(con, labels[[1L]]),
      " ELSE ", dbQuoteString(con, labels[[2L]]),
      " END AS group_id) FROM ancestry_ref"))
    named <- rduckhts_ancestry_proportions(
      con, "ancestry_input", "ancestry_named_ref", "ancestry_pc",
      "ancestry_correction", min_cor = 0)
    expect_equal(sort(named$group_id), sort(labels))
    expect_equal(named$proportion[match(labels, named$group_id)], out$proportion)
    expect_equal(named$used_variants, out$used_variants)
    dbExecute(con, paste0(
      "CREATE OR REPLACE TEMP VIEW ancestry_named_wide AS ",
      "SELECT * EXCLUDE (A, B), A AS __g1, B AS __g2 FROM ancestry_wide"))
    mapped <- rduckhts_ancestry_proportions(
      con, "ancestry_input", "ancestry_named_wide", "ancestry_wide_correction",
      min_cor = 0, group_ids = stats::setNames(labels, c("__g1", "__g2")))
    expect_equal(sort(mapped$group_id), sort(labels))
    expect_equal(mapped$proportion[match(labels, mapped$group_id)], out$proportion,
                 tolerance = 1e-6)
    expect_equal(mapped$used_variants, out$used_variants)
  }
  dbExecute(con, paste0(
    "CREATE TEMP VIEW ancestry_quoted_wide AS SELECT * EXCLUDE (A, B), ",
    "A AS \"group with space\", B AS \"O'Brien\" FROM ancestry_wide"))
  quoted <- rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_quoted_wide", "ancestry_wide_correction",
    min_cor = 0)
  expect_equal(sort(quoted$group_id), sort(c("group with space", "O'Brien")))
  expect_equal(quoted$proportion[match(c("group with space", "O'Brien"),
                                      quoted$group_id)], out$proportion,
               tolerance = 1e-6)
  expect_error(rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_wide", "ancestry_wide_correction",
    group_ids = c(A = "PC1")), pattern = "group_ids must map every")
  expect_error(rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_wide", "ancestry_wide_correction",
    group_ids = c(A = "PC1", B = "PC1")), pattern = "group_ids must map every")
  for (format in c("wide", "long")) {
    source <- if (format == "wide") "ancestry_wide_correction" else "ancestry_pc"
    pc_cases <- list(
      numeric_strings = "pc::VARCHAR",
      fractional = "CASE WHEN pc = 2 THEN 2.5 ELSE pc END",
      fractional_strings = "(CASE WHEN pc = 2 THEN 2.5 ELSE pc END)::VARCHAR",
      gap = "CASE WHEN pc = 2 THEN 17 ELSE pc END",
      duplicate = "CASE WHEN pc = 2 THEN 1 ELSE pc END"
    )
    for (kind in names(pc_cases)) {
      dbExecute(con, paste0("CREATE OR REPLACE TEMP VIEW ancestry_test_pc AS ",
                            "SELECT * REPLACE (", pc_cases[[kind]], " AS pc) FROM ", source))
      if (kind == "numeric_strings") {
        actual <- if (format == "wide") {
          rduckhts_ancestry_proportions(
            con, "ancestry_input", "ancestry_wide", "ancestry_test_pc", min_cor = 0)
        } else {
          rduckhts_ancestry_proportions(
            con, "ancestry_input", "ancestry_ref", "ancestry_test_pc",
            "ancestry_correction", min_cor = 0)
        }
        expect_equal(actual$proportion, out$proportion, tolerance = 1e-6)
      } else if (format == "wide") {
        expect_error(rduckhts_ancestry_proportions(
          con, "ancestry_input", "ancestry_wide", "ancestry_test_pc"),
          pattern = "one finite coefficient for every PC")
      } else {
        expected_error <- if (kind == "duplicate") {
          "one finite loading per PC"
        } else {
          "consecutive 1..64 PCs"
        }
        expect_error(rduckhts_ancestry_proportions(
          con, "ancestry_input", "ancestry_ref", "ancestry_test_pc",
          "ancestry_correction"), pattern = expected_error)
      }
    }
  }
  dbExecute(con, paste(
    "CREATE TEMP VIEW ancestry_ordered_correction AS SELECT pc, pc::DOUBLE AS coefficient",
    "FROM ancestry_wide_correction"
  ))
  dbExecute(con, paste(
    "CREATE TEMP VIEW ancestry_string_correction AS SELECT pc::VARCHAR AS pc,",
    "pc::DOUBLE AS coefficient FROM ancestry_wide_correction"
  ))
  numeric_order <- rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_wide", "ancestry_ordered_correction", min_cor = -1)
  string_order <- rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_wide", "ancestry_string_correction", min_cor = -1)
  expect_true(any(!is.na(numeric_order$proportion)))
  expect_equal(string_order$proportion, numeric_order$proportion)
  for (value in c(-0.01, 1.01)) {
    dbExecute(con, paste0(
      "CREATE OR REPLACE TEMP VIEW ancestry_range_wide AS SELECT * REPLACE ",
      "(CASE WHEN position = 100 THEN ", value, " ELSE A END AS A) FROM ancestry_wide"))
    expect_error(rduckhts_ancestry_proportions(
      con, "ancestry_input", "ancestry_range_wide", "ancestry_wide_correction"),
      pattern = "reference sites require frequencies in \\[0, 1\\]")
    dbExecute(con, paste0(
      "CREATE OR REPLACE TEMP VIEW ancestry_range_long AS SELECT * REPLACE ",
      "(CASE WHEN position = 100 AND group_id = 'A' THEN ", value,
      " ELSE frequency END AS frequency) FROM ancestry_ref"))
    expect_error(rduckhts_ancestry_proportions(
      con, "ancestry_input", "ancestry_range_long", "ancestry_pc",
      "ancestry_correction"),
      pattern = "reference sites require frequencies in \\[0, 1\\]")
  }
  wide_gated <- rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_wide", "ancestry_wide_correction", min_cor = 1)
  expect_equal(wide_gated$status, rep("low_correlation", 2L))
  expect_true(all(is.na(wide_gated$proportion)))
  dbExecute(con, paste0("CREATE VIEW ancestry_duplicate_input AS ",
                        "SELECT * FROM ancestry_input UNION ALL ",
                        "SELECT * FROM ancestry_input WHERE position = 100"))
  wide_audit <- rduckhts_ancestry_proportions(
    con, "ancestry_duplicate_input", "ancestry_wide", "ancestry_wide_correction",
    min_cor = -1)
  expect_equal(unique(wide_audit$input_variants), 12)
  expect_equal(unique(wide_audit$used_variants), 5)
  expect_equal(unique(wide_audit$duplicate_variants), 4)
  dbExecute(con, paste(
    "CREATE VIEW ancestry_multi_input AS SELECT * FROM ancestry_input UNION ALL",
    "SELECT 'T' AS sample_id, chromosome, position, allele_a, allele_b,",
    "1-frequency AS frequency FROM ancestry_input"
  ))
  multi <- rduckhts_ancestry_proportions(
    con, "ancestry_multi_input", "ancestry_wide", "ancestry_wide_correction",
    min_cor = 0)
  expect_equal(sort(unique(multi$sample_id)), c("S", "T"))
  expect_equal(multi$proportion[multi$sample_id == "S"], wide$proportion)
  expect_equal(unique(multi$used_variants), 6)
  dbExecute(con, paste(
    "CREATE VIEW ancestry_unmatched_input AS SELECT 'U' AS sample_id, chromosome,",
    "position + 1000 AS position, allele_a, allele_b, frequency FROM ancestry_input"
  ))
  unmatched <- rduckhts_ancestry_proportions(
    con, "ancestry_unmatched_input", "ancestry_wide", "ancestry_wide_correction")
  expect_equal(unmatched$group_id, c("A", "B"))
  expect_equal(unmatched$status, rep("no_matched_variants", 2L))
  expect_true(all(is.na(unmatched$proportion)))
  expect_equal(unmatched$used_variants, rep(0, 2L))
  dbExecute(con, paste(
    "CREATE VIEW ancestry_cohort_input AS SELECT * FROM ancestry_input",
    "UNION ALL SELECT * FROM ancestry_unmatched_input"
  ))
  cohort <- rduckhts_ancestry_proportions(
    con, "ancestry_cohort_input", "ancestry_wide", "ancestry_wide_correction",
    min_cor = -1)
  expect_equal(cohort$group_id[cohort$sample_id == "U"], c("A", "B"))
  expect_equal(cohort$status[cohort$sample_id == "U"],
               rep("no_matched_variants", 2L))
  expect_true(all(is.na(cohort$proportion[cohort$sample_id == "U"])))
  expect_equal(cohort$proportion[cohort$sample_id == "S"], wide$proportion)
  expect_equal(nrow(cohort), 4L)
  expect_equal(dbGetQuery(con, paste(
    "SELECT count(*) AS n FROM duckdb_tables() WHERE temporary",
    "AND (table_name LIKE 'ancestry_audit_%' OR table_name LIKE 'ancestry_moments_%'",
    "OR table_name LIKE 'ancestry_solved_%' OR table_name LIKE 'ancestry_prediction_%')"
  ))$n, 0)
  gated <- rduckhts_ancestry_proportions(con, "ancestry_input", "ancestry_ref",
                                         "ancestry_pc", "ancestry_correction", min_cor = 1)
  expect_equal(gated$status, rep("low_correlation", 2L))
  expect_true(all(is.na(gated$proportion)))
  dbExecute(con, paste(
    "CREATE TABLE ancestry_correlated_ref AS SELECT chromosome, position, allele_a, allele_b,",
    "group_id, CASE WHEN group_id='A' THEN 0.1+(position-100)*0.05",
    "ELSE 0.25+(position-100)*0.04 END AS frequency FROM ancestry_ref"
  ))
  dbExecute(con, paste(
    "CREATE TABLE ancestry_wrong_orientation AS SELECT 'S' AS sample_id, chromosome, position,",
    "allele_a, allele_b, 1-sum(CASE WHEN group_id='A' THEN 0.7 ELSE 0.3 END * frequency) AS frequency",
    "FROM ancestry_correlated_ref GROUP BY chromosome, position, allele_a, allele_b"
  ))
  reversed <- rduckhts_ancestry_proportions(
    con, "ancestry_wrong_orientation", "ancestry_correlated_ref",
    "ancestry_pc", "ancestry_correction", min_cor = -1)
  expect_equal(reversed$status, rep("reversed_alleles", 2L))
  expect_true(all(is.na(reversed$proportion)))
  dbExecute(con, paste0(
    "CREATE VIEW ancestry_correlated_wide AS SELECT w.chromosome, w.position, ",
    "w.allele_a, w.allele_b, ",
    paste(paste0("w.PC", 1:16), collapse = ", "), ", ",
    "max(r.frequency) FILTER (WHERE r.group_id = 'A') AS A, ",
    "max(r.frequency) FILTER (WHERE r.group_id = 'B') AS B ",
    "FROM ancestry_wide w JOIN ancestry_correlated_ref r ",
    "ON r.position = w.position AND r.allele_a = w.allele_a ",
    "AND r.allele_b = w.allele_b GROUP BY ALL"
  ))
  wide_reversed <- rduckhts_ancestry_proportions(
    con, "ancestry_wrong_orientation", "ancestry_correlated_wide",
    "ancestry_wide_correction", min_cor = -1)
  expect_equal(wide_reversed$status, reversed$status)
  expect_true(all(is.na(wide_reversed$proportion)))
  dbExecute(con, paste(
    "CREATE TABLE ancestry_scaled AS SELECT 'S' AS sample_id, chromosome, position,",
    "allele_a, allele_b, sum(CASE WHEN group_id = 'A' THEN 0.35 ELSE 0.15 END * frequency) AS frequency",
    "FROM ancestry_ref GROUP BY chromosome, position, allele_a, allele_b"
  ))
  scaled <- rduckhts_ancestry_proportions(con, "ancestry_scaled", "ancestry_ref",
                                          "ancestry_pc", "ancestry_correction",
                                          sum_to_one = FALSE, min_cor = 0)
  expect_true(all(scaled$status == "ok"))
  expect_true(abs(sum(scaled$proportion) - 0.5) < 1e-5)
  wide_scaled <- rduckhts_ancestry_proportions(
    con, "ancestry_scaled", "ancestry_wide", "ancestry_wide_correction",
    sum_to_one = FALSE, min_cor = 0)
  expect_equal(wide_scaled$status, scaled$status)
  expect_equal(wide_scaled$proportion, scaled$proportion, tolerance = 1e-6)
  dbExecute(con, paste(
    "CREATE TABLE ancestry_geno_calls AS SELECT chromosome AS CHROM, position AS POS,",
    "allele_a AS REF, [allele_b] AS ALT,",
    "[struct_pack(sample_index := 0, alleles := CASE WHEN position = 107",
    "THEN [NULL::INTEGER, 1] ELSE [0, 1] END)] AS calls",
    "FROM ancestry_ref GROUP BY chromosome, position, allele_a, allele_b"
  ))
  dbExecute(con, "CREATE TABLE ancestry_samples AS SELECT 0 AS sample_index, 'S' AS sample_name")
  genotype <- rduckhts_ancestry_geno(con, "ancestry_geno_calls", "ancestry_ref",
                                      "ancestry_pc", "ancestry_correction",
                                      samples_table = "ancestry_samples", min_cor = 0,
                                      non_reference_only = FALSE)
  expect_equal(genotype$sample_id, rep("S", 2L))
  expect_equal(genotype$used_variants, rep(7, 2L))
  expect_equal(genotype$missing_variants, rep(1, 2L))
  dbExecute(con, paste(
    "CREATE VIEW ancestry_ref_only_calls AS SELECT * REPLACE",
    "([struct_pack(sample_index := 1, alleles := [0, 0])] AS calls)",
    "FROM ancestry_geno_calls"
  ))
  dbExecute(con, "INSERT INTO ancestry_samples VALUES (1, 'S_ref')")
  reference_only <- rduckhts_ancestry_geno(
    con, "ancestry_ref_only_calls", "ancestry_ref", "ancestry_pc",
    "ancestry_correction", samples_table = "ancestry_samples", min_cor = 0,
    non_reference_only = FALSE)
  expect_equal(reference_only$sample_id, rep("S_ref", 2L))
  expect_equal(reference_only$input_variants, rep(8, 2L))
  expect_equal(reference_only$used_variants, rep(8, 2L))
  dbExecute(con, "CREATE TABLE ancestry_bad_samples AS SELECT * FROM ancestry_samples")
  for (change in c("DELETE FROM ancestry_bad_samples WHERE sample_index = 1",
                   "INSERT INTO ancestry_bad_samples VALUES (1, 'S_other')",
                   "INSERT INTO ancestry_bad_samples VALUES (2, 'S_ref')",
                   "UPDATE ancestry_bad_samples SET sample_name = NULL WHERE sample_index = 1")) {
    dbExecute(con, "DELETE FROM ancestry_bad_samples")
    dbExecute(con, "INSERT INTO ancestry_bad_samples SELECT * FROM ancestry_samples")
    dbExecute(con, change)
    expect_error(rduckhts_ancestry_geno(
      con, "ancestry_ref_only_calls", "ancestry_ref", "ancestry_pc",
      "ancestry_correction", samples_table = "ancestry_bad_samples",
      non_reference_only = FALSE), pattern = "samples_table must map")
  }
  path <- system.file("extdata", "geno_calls.vcf", package = "Rduckhts")
  rduckhts_geno(con, "ancestry_dense_fixture", path, samples = "S2")
  rduckhts_geno(con, "ancestry_sparse_fixture", path, samples = "S2",
                non_reference_only = TRUE)
  expect_equal(dbGetQuery(con, paste(
    "SELECT len(calls) AS n FROM ancestry_dense_fixture WHERE POS = 20"))$n,
    c(1, 1))
  expect_equal(dbGetQuery(con, paste(
    "SELECT len(calls) AS n FROM ancestry_sparse_fixture WHERE POS = 20"))$n,
    c(0, 0))
  expect_error(rduckhts_ancestry_geno(
    con, "ancestry_sparse_fixture", "ancestry_ref", "ancestry_pc",
    "ancestry_correction", non_reference_only = TRUE),
    pattern = "non_reference_only = FALSE")
  expect_error(rduckhts_ancestry_geno(
    con, "ancestry_dense_fixture", "ancestry_ref", "ancestry_pc",
    "ancestry_correction"), pattern = "non_reference_only = FALSE")
  dbExecute(con, "CREATE TABLE ancestry_bad_correction AS SELECT * FROM ancestry_correction")
  dbExecute(con, "UPDATE ancestry_bad_correction SET pc = 3 WHERE pc = 2")
  expect_error(rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_ref", "ancestry_pc", "ancestry_bad_correction"),
    pattern = "one finite coefficient for every PC")
  expect_error(rduckhts_ancestry_proportions(con, "ancestry_input", "ancestry_ref",
                                              "ancestry_pc", "ancestry_correction", min_cor = 2))
  expect_error(rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_wide", "ancestry_bad_correction"),
    pattern = "one finite coefficient")
  expect_error(rduckhts_ancestry_proportions(
    con, "ancestry_input", "ancestry_wide", "ancestry_wide_correction", min_cor = 2))
}

test_ancestry_relations()

test_ancestry_bam_cram <- function() {
  con <- rduckhts_connect()
  on.exit(dbDisconnect(con, shutdown = TRUE), add = TRUE)
  paths <- function(x) system.file("extdata", x, package = "Rduckhts")
  dbExecute(con, paste(
    "CREATE TABLE ancestry_bam_panel AS SELECT * FROM (VALUES",
    "('WBcel235', 0::UBIGINT, 'CHROMOSOME_I', 1::UBIGINT, 'A', 'G'),",
    "('WBcel235', 1::UBIGINT, 'CHROMOSOME_I', 914::UBIGINT, 'A', 'C'),",
    "('WBcel235', 2::UBIGINT, 'CHROMOSOME_I', 2::UBIGINT, 'A', 'G'))",
    "p(assembly, site_index, region, position, allele_a, allele_b)"
  ))
  dbExecute(con, paste(
    "CREATE TABLE ancestry_bam_ref AS SELECT region AS chromosome, position,",
    "allele_a, allele_b, group_id, CASE WHEN group_id='A' THEN 0.2 ELSE 0.8 END AS frequency",
    "FROM ancestry_bam_panel, (VALUES ('A'), ('B')) g(group_id)"
  ))
  dbExecute(con, paste(
    "CREATE TABLE ancestry_bam_pc AS SELECT region AS chromosome, position,",
    "allele_a, allele_b, pc, CASE WHEN pc=1 THEN 1.0 ELSE position/1000.0 END AS loading",
    "FROM ancestry_bam_panel, (VALUES (1), (2)) p(pc)"
  ))
  dbExecute(con, "CREATE TABLE ancestry_bam_correction AS SELECT 1 AS pc, 1.0 AS coefficient UNION ALL SELECT 2, 1.0")
  dbExecute(con, paste(
    "CREATE TABLE ancestry_complement_candidate AS SELECT 'CHROMOSOME_I' AS region,",
    "914 AS position, 'T' AS allele_a, 'G' AS allele_b"
  ))
  flipped_panel <- rduckhts_ancestry_panel(
    con, "ancestry_bam_ref", "ancestry_bam_pc", "ancestry_forward_panel", "WBcel235",
    candidate_table = "ancestry_complement_candidate")
  expect_equal(flipped_panel$sites, 1)
  expect_equal(dbGetQuery(con,
    "SELECT allele_a, allele_b FROM ancestry_forward_panel"),
    data.frame(allele_a = "A", allele_b = "C"))
  for (source in c("range.bam", "range.cram")) {
    index <- if (source == "range.bam") "range.bam.bai" else "range.cram.crai"
    counts <- rduckhts_somalier_bam_counts(
      con, paths(source), "S", paths("ce.fa"),
      panel_table = "ancestry_forward_panel", index_path = paths(index),
      reference_index_path = paths("ce.fa.fai"))
    expect_equal(counts$status, "measured")
    expect_equal(c(counts$a, counts$b), c(1, 0))
  }
  bam <- rduckhts_ancestry_bam(
    con, paths("range.bam"), "S", paths("ce.fa"), "ancestry_bam_panel",
    "ancestry_bam_ref", "ancestry_bam_pc", "ancestry_bam_correction",
    min_depth = 1, min_cor = 0, index_path = paths("range.bam.bai"),
    reference_index_path = paths("ce.fa.fai")
  )
  cram <- rduckhts_ancestry_bam(
    con, paths("range.cram"), "S", paths("ce.fa"), "ancestry_bam_panel",
    "ancestry_bam_ref", "ancestry_bam_pc", "ancestry_bam_correction",
    frequency_method = "called_genotype", min_depth = 1, min_cor = 0,
    index_path = paths("range.cram.crai"), reference_index_path = paths("ce.fa.fai")
  )
  dbExecute(con, paste0(
    "CREATE VIEW ancestry_bam_wide AS SELECT chromosome, position, allele_a, ",
    "allele_b, max(loading) FILTER (WHERE pc=1) AS PC1, ",
    "max(loading) FILTER (WHERE pc=2) AS PC2, ",
    "max(frequency) FILTER (WHERE group_id='A') AS A, ",
    "max(frequency) FILTER (WHERE group_id='B') AS B ",
    "FROM ancestry_bam_ref JOIN ancestry_bam_pc ",
    "USING (chromosome, position, allele_a, allele_b) ",
    "GROUP BY chromosome, position, allele_a, allele_b"))
  wide_bam <- rduckhts_ancestry_bam(
    con, paths("range.bam"), "S", paths("ce.fa"), "ancestry_bam_panel",
    "ancestry_bam_wide", "ancestry_bam_correction",
    min_depth = 1, min_cor = 0, index_path = paths("range.bam.bai"),
    reference_index_path = paths("ce.fa.fai")
  )
  expect_equal(wide_bam$used_variants, bam$used_variants)
  expect_equal(wide_bam$status, bam$status)
  expect_equal(bam$frequency_method, rep("allele_fraction", 2L))
  expect_equal(cram$frequency_method, rep("called_genotype", 2L))
  expect_equal(bam$used_variants, rep(1, 2L))
  expect_equal(cram$used_variants, rep(1, 2L))
  expect_equal(bam$status, rep("low_correlation", 2L))
  expect_true(all(is.na(cram$proportion)))
}

test_ancestry_bam_cram()

Try the Rduckhts package in your browser

Any scripts or data that you put into this service are public.

Rduckhts documentation built on Oct. 25, 2026, 1:08 a.m.