diff --git a/NAMESPACE b/NAMESPACE index ae47b63..7c76ae6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -20,3 +20,6 @@ export(get.counts) export(sim.dynamics) export(sim.inner.tree) export(sim.outer.tree) +export(plot.OuterTree.with.dynamics) +export(sim.arg) +export(resolve.arg) diff --git a/R/InnerTree.R b/R/InnerTree.R index ff50706..32a4539 100644 --- a/R/InnerTree.R +++ b/R/InnerTree.R @@ -17,17 +17,17 @@ InnerTree <- R6Class( public = list( initialize = function(outer, prefix='P', p.index=1) { private$inner.log <- data.frame( - time=numeric(), # time of event - event=character(), # type of event, e.g., coalescence + time=numeric(), + event=character(), from.comp=character(), to.comp=character(), - from.host=character(), # source Host name (transmission only) - to.host=character(), # recipient Host name (transmission only) + from.host=character(), + to.host=character(), pathogen1=character(), pathogen2=character() ) - private$mod <- outer$get.model() # inherits model from OuterTree + private$mod <- outer$get.model() private$prefix <- prefix private$p.index <- p.index @@ -38,11 +38,9 @@ InnerTree <- R6Class( } index.case <- root$sample.host() - # inactive Hosts are part of the transmission history private$inactive <- HostSet$new() private$sampled <- HostSet$new() - # note there is overlap between `retired` and `sampled` HostSets hosts <- outer$get.retired() sampled <- outer$get.sampled() for (host in hosts$get.hosts()) { @@ -53,20 +51,18 @@ InnerTree <- R6Class( } } - # handle index case if (index.case$get.name() %in% sampled$get.names()) { private$sampled$add.host(index.case$clone()) } else { private$inactive$add.host(index.case$clone()) } - # track Hosts with active Pathogen lineages private$active <- HostSet$new() - }, # accessor functions get.log = function() { private$inner.log }, + set.log = function(new.log) { private$inner.log <- new.log }, get.model = function() { private$mod }, get.prefix = function() { private$prefix }, get.pindex = function() { private$p.index }, @@ -75,6 +71,8 @@ InnerTree <- R6Class( get.active = function() { private$active }, n.active = function() { length(private$active) }, get.sampled = function() { private$sampled }, + get.pathogen = function(name) { private$pathogens[[name]] }, + get.all.pathogens = function() { private$pathogens }, has.target = function(cname) { is.element(cname, names(private$mod$get.sampling()$targets)) @@ -85,7 +83,8 @@ InnerTree <- R6Class( name=paste(private$prefix, private$p.index, sep="_"), end.time=time, ) - private$p.index <- private$p.index + 1 # increment counter + private$p.index <- private$p.index + 1 + private$pathogens[[path$get.name()]] <- path return(path) }, @@ -105,7 +104,8 @@ InnerTree <- R6Class( p.index = NULL, inactive = NULL, active = NULL, - sampled = NULL + sampled = NULL, + pathogens = list() ) ) @@ -138,7 +138,7 @@ as.phylo.InnerTree <- function(obj) { preorder <- .reorder.inner.events(events, root, order='preorder') idx <- match(preorder, events$pathogen2) - events <- events[idx[-1], ] # reorder events + events <- events[idx[-1], ] root.time <- min(events$time[events$pathogen1==root]) @@ -153,9 +153,8 @@ as.phylo.InnerTree <- function(obj) { e <- events[i,] if (events$pathogen2[i-1] == e$pathogen1) { - e.prev <- events[i-1, ] # next step in chain of events + e.prev <- events[i-1, ] } else { - # preceding event involving same pathogen temp <- events[1:(i-1), ] temp <- temp[temp$pathogen1 == e$pathogen1, ] e.prev <- temp[which.max(temp$time), ] @@ -171,7 +170,7 @@ as.phylo.InnerTree <- function(obj) { ) } - preorder <- preorder[-1] # discard first label + preorder <- preorder[-1] node.label <- preorder[!grepl("_sample$", preorder)] tip.label <- preorder[grepl("_sample$", preorder)] nodes <- c(tip.label, node.label) @@ -194,8 +193,6 @@ as.phylo.InnerTree <- function(obj) { #' Modify Pathogen names in the inner tree event log to make them unique. -#' This makes it easier to label single nodes (transmission, migration events) -#' in the tree. #' #' @param events: data frame including fields `time`, `event`, `pathogen1` and #' `pathogen2` @@ -204,7 +201,6 @@ as.phylo.InnerTree <- function(obj) { #' @keywords internal #' @noRd .relabel.inner.events <- function(events) { - # re-order events in forward time events$event <- factor( events$event, levels=c("transmission", "coalescent", "migration", "sampling")) @@ -213,11 +209,10 @@ as.phylo.InnerTree <- function(obj) { nodes <- na.omit(unique(c(events$pathogen1, events$pathogen2))) for (node in nodes) { idx <- which(events$pathogen1==node | events$pathogen2==node) - new.node <- node # for storing the revised label + new.node <- node label <- 1 for (i in idx) { if (events$pathogen1[i] == node) { - # overwrite original node name in case it's been updated events$pathogen1[i] <- new.node } @@ -266,11 +261,11 @@ as.phylo.InnerTree <- function(obj) { #' @export #' @noRd print.InnerTree <- function(obj) { - cat("twt InnerTree\n") # bold color! + cat("twt InnerTree\n") cat(" ", obj$get.sampled()$count.type(), "sampled Pathogens\n") cat(" ", obj$get.active()$count.type(), "active Pathogens\n") cat(" ", obj$get.inactive()$count.type(), "inactive Pathogens\n") events <- obj$get.log() cat(" ", nrow(events), "events in inner log: ") print(table(events$event)) -} +} diff --git a/R/Pathogen.R b/R/Pathogen.R index 58efc66..9726616 100644 --- a/R/Pathogen.R +++ b/R/Pathogen.R @@ -9,29 +9,33 @@ #' terminates the parent and starts two child lineages. #' @param end.time: numeric, end time of lineage; associated with a #' coalescent event (if parent) or sampling event. -#' @param parent: character, parent Pathogen object +#' @param parents: list, parent Pathogen objects; length 1 for transmission, +#' length 2 for recombination #' @param children: list, child Pathogen objects (NOTE: avoid cloning #' Pathogen objects or there will be a circular reference problem!) +#' @param breakpoint: integer, genomic position of recombination breakpoint; +#' NA for non-recombinant lineages #' @export Pathogen <- R6Class( "Pathogen", public = list( - initialize = function(name=NA, start.time=NA, end.time=NA, parent=NA, - children=list()) { + initialize = function(name=NA, start.time=NA, end.time=NA, parents=list(), + children=list(), breakpoint=NA) { private$name <- name private$start.time <- start.time private$end.time <- end.time - private$parent <- parent + private$parents <- parents private$children <- children + private$breakpoint <- breakpoint }, print = function() { cat("twt Pathogen", self$get.name(), "\n") cat(" Start time:", self$get.start.time(), "\n") cat(" End time:", self$get.end.time(), "\n") - cat(" Parent:", self$get.parent()$get.name(), "\n") - children <- sapply(self$get.children(), function(child) child$get.name()) - cat(" Children:", children, "\n") + cat(" Parents:", sapply(self$get.parents(), function(p) p$get.name()), "\n") + cat(" Children:", sapply(self$get.children(), function(c) c$get.name()), "\n") + cat(" Breakpoint:", self$get.breakpoint(), "\n") }, # immutable attributes @@ -41,19 +45,26 @@ Pathogen <- R6Class( # mutables get.start.time = function() { private$start.time }, set.start.time = function(time) { private$start.time <- time }, - get.parent = function() { private$parent }, - set.parent = function(parent) { private$parent <- parent }, + get.parents = function() { private$parents }, + get.parent = function() { private$parents[[1]] }, + set.parent = function(parent) { private$parents <- list(parent) }, + add.parent = function(parent) { + private$parents[[length(private$parents)+1]] <- parent + }, get.children = function() { private$children }, add.child = function(child) { private$children[[length(private$children)+1]] <- child - } + }, + get.breakpoint = function() { private$breakpoint }, + set.breakpoint = function(bp) { private$breakpoint <- bp } ), private = list( name = NULL, start.time = NULL, end.time = NULL, - parent = NULL, - children = NULL + parents = NULL, + children = NULL, + breakpoint = NULL ) ) diff --git a/R/simARG.R b/R/simARG.R new file mode 100644 index 0000000..83276fb --- /dev/null +++ b/R/simARG.R @@ -0,0 +1,369 @@ +#' sim.arg +#' +#' Simulate an ancestral recombination graph (ARG) for pathogen lineages +#' within hosts, given an outer transmission tree. Extends sim.inner.tree +#' by adding recombination as a competing process alongside coalescence. +#' +#' Going backwards in time, within each host interval two competing events +#' can occur: +#' - Coalescence: two lineages merge (rate 1/Ne per pair) +#' - Recombination: one lineage splits into two parental lineages at a +#' random genomic breakpoint (rate rho per lineage) +#' +#' Each lineage tracks the set of genomic segments it is ancestral to. +#' A recombination event splits those segments between two new parent +#' lineages. Simulation stops when all segments have coalesced to a +#' single ancestor (MRCA per segment). +#' +#' @param outer R6 object of class `OuterTree` +#' @param rho numeric, recombination rate per lineage per unit time +#' @param seq.length integer, genome length in bp (for breakpoint sampling) +#' +#' @return R6 object of class `InnerTree` (ARG events recorded in log) +#' @export +sim.arg <- function(outer, rho = 1e-4, seq.length = 9000L) { + if (!is.R6(outer) || !is.element("OuterTree", class(outer))) { + stop("Input argument must be an R6 object of class `OuterTree`") + } + + inner <- InnerTree$new(outer) + mod <- inner$get.model() + active <- inner$get.active() + inactive <- inner$get.inactive() + + env <- new.env() + eval(parse(text = "require(stats)"), envir = env) + breakpoints <- list() # named list: pathogen name -> breakpoint position + + # iterate through outer tree events in reverse time + events <- outer$get.log() + events$time <- as.numeric(events$time) + events <- events[order(events$time, decreasing = TRUE), ] + + time.delta <- -diff(events$time) + row <- 1 + + while (row <= nrow(events)) { + e <- events[row, ] + remaining <- ifelse(row == 1, NA, time.delta[row - 1]) + + # draw competing coalescence and recombination waiting times + if (row > 1 && inner$n.active() > 0) { + repeat { + if (inner$n.active() > 500) { + warning("Active lineage count exceeded 500 -- aborting inner simulation") + break + } + ev <- .draw.next.event(active, mod, rho, env) + if (is.null(ev) || is.na(ev$dt) || ev$dt >= remaining) break + + remaining <- remaining - ev$dt + event.time <- e$time + remaining + + if (ev$type == "coalescent") { + .do.coalescent(ev$host, inner, event.time, envir = env) + } else { + bp <- .do.recombination(ev$host, ev$pathogen, inner, event.time, + seq.length = seq.length) + breakpoints[[bp$child]] <- bp$position + } + } + } + + # handle outer tree event + if (e$event == "migration") { + if (inner$has.target(e$to.comp)) { + .do.sampling(e, inner) + } else { + .migrate.pathogens(e, inner) + } + } else if (e$event == "transmission") { + .do.infection(e, inner, envir = env) + } + + row <- row + 1 + } + + # finish coalescence in last host + count <- active$count.type() + if (count == 1) { + root <- active$get.hosts()[[1]] + cur.time <- e$time + while (root$count.pathogens() > 1) { + wait <- .rcoal(active, mod, envir = env) + cur.time <- cur.time - wait$dt + .do.coalescent(root$get.name(), inner, cur.time) + } + } else if (count > 1) { + warning("Multiple (", count, ") hosts remain active at end of simulation.") + } else { + warning("Empty active HostSet at end of simulation!") + } + + return(list(inner = inner, breakpoints = breakpoints)) +} + + +#' Draw the next event using the Gillespie algorithm. +#' +#' Computes all event rates into a single vector, draws one exponential +#' waiting time from the total rate, then selects which event occurred +#' using the broken-stick (normalized rates) method. +#' +#' Events tracked: +#' - Coalescence: one entry per host with 2+ lineages, rate = choose(k,2)/Ne +#' - Recombination: one entry per host, rate = k * rho (per-lineage rate) +#' +#' @param active HostSet of active hosts +#' @param mod Model R6 object +#' @param rho recombination rate per lineage per unit time +#' @param envir environment for rate evaluation +#' +#' @return list(dt, type, host, pathogen) or NULL if no events possible +#' @keywords internal +#' @noRd +.draw.next.event <- function(active, mod, rho, envir = baseenv()) { + hosts <- active$get.hosts() + rates <- numeric(0) + event.list <- list() + + for (h in hosts) { + k <- h$count.pathogens() + comp <- h$get.compartment() + + # coalescence rate for this host (requires 2+ lineages) + if (k >= 2) { + expr <- mod$get.coalescent.rate(comp) + rate <- eval(parse(text = expr), envir = envir) + if (rate > 0) { + rates <- c(rates, choose(k, 2) * rate) + event.list <- c(event.list, list( + list(type = "coalescent", host = h$get.name(), pathogen = NA) + )) + } + } + + # recombination rate for this host (all lineages combined) + if (k >= 1 && rho > 0) { + rates <- c(rates, k * rho) + event.list <- c(event.list, list( + list(type = "recombination", host = h$get.name(), pathogen = NULL) + )) + } + } + + if (length(rates) == 0 || sum(rates) == 0) return(NULL) + + # single exponential draw from total rate + total.rate <- sum(rates) + dt <- rexp(1, total.rate) + + # broken-stick selection: choose event proportional to its rate + u <- runif(1) * total.rate + chosen <- which(cumsum(rates) >= u)[1] + ev <- event.list[[chosen]] + + # for recombination, choose a random lineage from the host now + if (ev$type == "recombination") { + h <- active$get.host.by.name(ev$host) + paths <- h$get.pathogens() + ev$pathogen <- paths[[sample(length(paths), 1)]] + } + + return(list(dt = dt, type = ev$type, host = ev$host, pathogen = ev$pathogen)) +} + + +#' Recombination event: split one lineage into two parental lineages. +#' +#' Going backwards in time, a recombination event means a lineage we are +#' tracking was produced by recombination — it has two parents, one for +#' each side of the breakpoint. We sample a breakpoint and create two new +#' parental lineages, then remove the original. +#' +#' @param host.name character, name of host where event occurs +#' @param pathogen Pathogen R6 object to split +#' @param inner InnerTree R6 object +#' @param time numeric, time of event +#' @param seq.length integer, genome length +#' +#' @keywords internal +#' @noRd +.do.recombination <- function(host.name, pathogen, inner, time, + seq.length = 9000L) { + active <- inner$get.active() + host <- active$get.host.by.name(host.name) + + # sample breakpoint uniformly across genome + breakpoint <- sample.int(seq.length - 1L, 1L) + pathogen$set.breakpoint(breakpoint) + + # end the current lineage at this recombination event + pathogen$set.start.time(time) + + # create two parental lineages — left and right of breakpoint + parent.left <- inner$new.pathogen(time) + parent.right <- inner$new.pathogen(time) + + # record parent-child relationships (recombination has two parents) + parent.left$add.child(pathogen) + parent.right$add.child(pathogen) + pathogen$add.parent(parent.left) + pathogen$add.parent(parent.right) + + # remove original pathogen by finding its index + paths <- host$get.pathogens() + idx <- which(sapply(paths, function(p) p$get.name()) == pathogen$get.name()) + if (length(idx) == 1) host$remove.pathogen(idx) + host$add.pathogen(parent.left) + host$add.pathogen(parent.right) + + # log the recombination event (breakpoint not stored in log — fixed schema) + event <- list( + time = time, event = "recombination", + from.comp = host$get.compartment(), to.comp = NA, + from.host = host.name, to.host = NA, + pathogen1 = pathogen$get.name(), + pathogen2 = parent.left$get.name() + ) + inner$add.event(event) + event$pathogen2 <- parent.right$get.name() + inner$add.event(event) + + return(list(child = pathogen$get.name(), position = breakpoint, + left = parent.left$get.name(), right = parent.right$get.name())) +} + + +#' resolve.arg +#' +#' Resolve an ARG into local trees per genomic segment by traversing +#' the event log. For each segment, at each recombination node the +#' appropriate parent lineage is followed (left if position <= breakpoint, +#' right otherwise). +#' +#' @param arg.result list returned by sim.arg ($inner, $breakpoints) +#' @param seq.length integer, total genome length in bp +#' +#' @return list: +#' - segments: data.frame(start, end) +#' - local.trees: list of phylo objects, one per segment +#' - breakpoints: sorted numeric vector +#' @export +resolve.arg <- function(arg.result, seq.length = 9000L) { + inner <- arg.result$inner + bps <- sort(unlist(arg.result$breakpoints)) + log <- inner$get.log() + log$time <- as.numeric(log$time) + + # genomic segments + starts <- c(1L, as.integer(bps) + 1L) + ends <- c(as.integer(bps), as.integer(seq.length)) + + coal.rows <- log[log$event == "coalescent", ] + recomb.rows <- log[log$event == "recombination", ] + samp.rows <- log[log$event == "sampling", ] + trans.rows <- log[log$event == "transmission", ] + + # tip names (sampled pathogens) + tips <- unique(samp.rows$pathogen1) + + # for each segment, trace from tips to root + local.trees <- vector("list", length(starts)) + + for (i in seq_along(starts)) { + pos <- (starts[i] + ends[i]) / 2 # representative position + + # build a parent map for this segment + # start with coalescent parents: child -> parent + parent.map <- setNames(coal.rows$pathogen1, coal.rows$pathogen2) + + # add transmission parents: pathogen2 -> pathogen1 + for (r in seq_len(nrow(trans.rows))) { + p2 <- trans.rows$pathogen2[r] + p1 <- trans.rows$pathogen1[r] + if (!is.na(p2) && !is.na(p1)) parent.map[p2] <- p1 + } + + # add recombination parents: choose left or right based on position + recomb.children <- unique(recomb.rows$pathogen1) + for (child in recomb.children) { + child.rows <- recomb.rows[recomb.rows$pathogen1 == child, ] + if (nrow(child.rows) < 2) next + bp <- bps[names(bps) == child] + if (length(bp) == 0) bp <- bps[1] # fallback + parent <- if (pos <= bp) child.rows$pathogen2[1] else child.rows$pathogen2[2] + parent.map[child] <- parent + } + + # get times for each pathogen from log + time.map <- c( + setNames(coal.rows$time, coal.rows$pathogen1), + setNames(samp.rows$time, samp.rows$pathogen1), + setNames(trans.rows$time, trans.rows$pathogen1) + ) + + # trace each tip to root, collect all nodes and edges + all.nodes <- character(0) + edges <- data.frame(parent = character(0), child = character(0), + stringsAsFactors = FALSE) + + for (tip in tips) { + cur <- tip + while (!is.na(parent.map[cur]) && !is.null(parent.map[cur])) { + par <- parent.map[cur] + edges <- rbind(edges, data.frame(parent = par, child = cur, + stringsAsFactors = FALSE)) + all.nodes <- c(all.nodes, cur, par) + cur <- par + } + all.nodes <- c(all.nodes, cur) + } + + all.nodes <- unique(all.nodes) + edges <- unique(edges) + root.node <- all.nodes[!all.nodes %in% edges$child] + if (length(root.node) > 1) root.node <- root.node[1] + + # build Newick string recursively + get.children <- function(node) edges$child[edges$parent == node] + + node.time <- function(node) { + t <- time.map[node] + if (is.na(t)) 0 else as.numeric(t) + } + + to.newick <- function(node, parent.t = NULL) { + children <- get.children(node) + t <- node.time(node) + bl <- if (!is.null(parent.t)) abs(parent.t - t) else 0 + + if (length(children) == 0) { + return(paste0(node, ":", round(bl, 6))) + } else { + subtrees <- sapply(children, to.newick, parent.t = t) + return(paste0("(", paste(subtrees, collapse = ","), ")", + node, ":", round(bl, 6))) + } + } + + nwk <- paste0(to.newick(root.node), ";") + + phy <- tryCatch( + ape::read.tree(text = nwk), + error = function(e) NULL + ) + + local.trees[[i]] <- list( + start = starts[i], end = ends[i], + newick = nwk, phylo = phy + ) + } + + return(list( + segments = data.frame(start = starts, end = ends, + stringsAsFactors = FALSE), + local.trees = local.trees, + breakpoints = bps + )) +} diff --git a/R/simInnerTree.R b/R/simInnerTree.R index 37a6924..8e2cea0 100644 --- a/R/simInnerTree.R +++ b/R/simInnerTree.R @@ -83,39 +83,34 @@ sim.inner.tree <- function(outer) { } -#' Draw exponential waiting times to coalescence in all active Hosts and -#' return the shortest time. -#' @param active: R6 object of class `HostSet`, containing Hosts carrying -#' Pathogens that may coalesce +#' Draw waiting time to next coalescent event using the Gillespie direct method. +#' @param active: R6 object of class `HostSet` #' @param mod: R6 object of class `Model` -#' @return list containing: -#' - dt: numeric, minimum waiting time to coalescence -#' - host name +#' @param envir: environment for rate evaluation +#' @return list(dt, host) or NA if no coalescence possible #' @keywords internal #' @noRd .rcoal <- function(active, mod, envir=baseenv()) { - wait.times <- sapply(active$get.hosts(), function(h) { + hosts <- active$get.hosts() + rates <- sapply(hosts, function(h) { k <- h$count.pathogens() - if (k < 2) { return(NA) } else { - # only Hosts with multiple Pathogen lineages can have coalescent events - comp <- h$get.compartment() - expr <- mod$get.coalescent.rate(comp) - - # TODO: compute time since infection for time-varying rates - rate <- eval(parse(text=expr), envir=envir) - if (rate == 0) { return(NA) } else { - return(rexp(1, choose(k, 2)*rate)) - } - } + if (k < 2) return(0) + comp <- h$get.compartment() + expr <- mod$get.coalescent.rate(comp) + rate <- eval(parse(text=expr), envir=envir) + if (rate == 0) return(0) + choose(k, 2) * rate }) - wait.times <- setNames(wait.times, active$get.names()) + names(rates) <- active$get.names() - if ( all(is.na(wait.times)) ) { return(NA) } else { - return(list( - dt=min(wait.times, na.rm=TRUE), - host=names(wait.times)[which.min(wait.times)] - )) - } + total.rate <- sum(rates) + if (total.rate == 0) return(NA) + + dt <- rexp(1, total.rate) + u <- runif(1) * total.rate + chosen <- which(cumsum(rates) >= u)[1] + + return(list(dt=dt, host=names(rates)[chosen])) } @@ -233,11 +228,13 @@ sim.inner.tree <- function(outer) { # move remaining Pathogens from recipient to source count <- recipient$count.pathogens() - for (i in 1:count) { - path <- recipient$remove.pathogen(1) - source$add.pathogen(path) - event$pathogen1 <- path$get.name() # copy-on-modify - inner$add.event(event) + if (count > 0) { + for (i in 1:count) { + path <- recipient$remove.pathogen(1) + source$add.pathogen(path) + event$pathogen1 <- path$get.name() # copy-on-modify + inner$add.event(event) + } } if (!source.is.active) { active$add.host(source) } diff --git a/tests/testthat/test_simARG.R b/tests/testthat/test_simARG.R new file mode 100644 index 0000000..6dfaeb5 --- /dev/null +++ b/tests/testthat/test_simARG.R @@ -0,0 +1,188 @@ +## tests for sim.arg and resolve.arg + +N.TIPS <- 10L +SEQ.LEN <- 9000L +RHO <- 0.3 + +# seed 31 verified to produce a valid outer tree for sim.arg +.make.outer <- function() { + mod <- Model$new(read_yaml("test_Superinfection.yaml")) + set.seed(31) + dyn <- sim.dynamics(mod) + suppressWarnings(sim.outer.tree(dyn)) +} + +outer.tree <- .make.outer() +result.rho0 <- suppressWarnings(sim.arg(outer.tree, rho=0, seq.length=SEQ.LEN)) +result.rho03 <- suppressWarnings(sim.arg(outer.tree, rho=RHO, seq.length=SEQ.LEN)) +resolved.rho0 <- resolve.arg(result.rho0, seq.length=SEQ.LEN) +resolved.rho03 <- resolve.arg(result.rho03, seq.length=SEQ.LEN) + +# skip multi-segment tests if no breakpoints were produced +has.breakpoints <- length(result.rho03$breakpoints) > 0 + +# --- sim.arg --- + +test_that("sim.arg returns list with $inner and $breakpoints", { + expect_type(result.rho0, "list") + expect_true(!is.null(result.rho0$inner)) + expect_true(!is.null(result.rho0$breakpoints)) +}) + +test_that("sim.arg $inner is an InnerTree R6 object", { + expect_true(is.R6(result.rho0$inner)) + expect_true(is.element("InnerTree", class(result.rho0$inner))) +}) + +test_that("sim.arg rejects non-OuterTree input", { + expect_error(sim.arg("not an OuterTree"), regexp="OuterTree") +}) + +test_that("sim.arg log contains coalescent events", { + log <- result.rho0$inner$get.log() + expect_gt(sum(log$event == "coalescent"), 0) +}) + +test_that("sim.arg log contains exactly N.TIPS sampling events", { + log <- result.rho0$inner$get.log() + expect_equal(sum(log$event == "sampling"), N.TIPS) +}) + +test_that("rho=0 produces no recombination events", { + log <- result.rho0$inner$get.log() + expect_equal(sum(log$event == "recombination"), 0) +}) + +test_that("rho=0 produces no breakpoints", { + expect_equal(length(result.rho0$breakpoints), 0) +}) + +test_that("sim.arg rho>0 returns list with $inner and $breakpoints", { + expect_type(result.rho03, "list") + expect_true(!is.null(result.rho03$inner)) + expect_true(!is.null(result.rho03$breakpoints)) +}) + +test_that("breakpoint count matches recombination log entries", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + log <- result.rho03$inner$get.log() + # each recombination logs two rows, one per parental lineage + expect_equal(length(result.rho03$breakpoints), + sum(log$event == "recombination") / 2) +}) + +test_that("breakpoints are integers in [1, seq.length-1]", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + bps <- unlist(result.rho03$breakpoints) + expect_true(all(bps >= 1L)) + expect_true(all(bps <= SEQ.LEN - 1L)) + expect_true(all(bps == as.integer(bps))) +}) + +test_that("breakpoint positions are unique", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + bps <- unlist(result.rho03$breakpoints) + expect_equal(length(bps), length(unique(bps))) +}) + +test_that("recombinant pathogens have exactly two parents", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + log <- result.rho03$inner$get.log() + recomb.children <- unique(log$pathogen1[log$event == "recombination"]) + all.paths <- result.rho03$inner$get.all.pathogens() + n.parents <- sapply(recomb.children, function(n) { + p <- all.paths[[n]] + if (is.null(p)) return(NA) + length(p$get.parents()) + }) + expect_true(all(n.parents == 2, na.rm=TRUE)) +}) + +test_that("non-recombinant pathogens have at most one parent", { + log <- result.rho03$inner$get.log() + recomb.children <- unique(log$pathogen1[log$event == "recombination"]) + all.paths <- result.rho03$inner$get.all.pathogens() + other.names <- setdiff(names(all.paths), recomb.children) + n.parents <- sapply(other.names, function(n) length(all.paths[[n]]$get.parents())) + expect_true(all(n.parents <= 1)) +}) + +test_that("each breakpoint is named by its recombinant child pathogen", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + log <- result.rho03$inner$get.log() + recomb.children <- unique(log$pathogen1[log$event == "recombination"]) + expect_true(all(names(result.rho03$breakpoints) %in% recomb.children)) +}) + +test_that("every recombination event has a corresponding breakpoint", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + log <- result.rho03$inner$get.log() + recomb.children <- unique(log$pathogen1[log$event == "recombination"]) + expect_true(all(recomb.children %in% names(result.rho03$breakpoints))) +}) + +test_that("recombinant pathogens have breakpoint position stored", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + log <- result.rho03$inner$get.log() + recomb.children <- unique(log$pathogen1[log$event == "recombination"]) + all.paths <- result.rho03$inner$get.all.pathogens() + bps <- sapply(recomb.children, function(n) { + p <- all.paths[[n]] + if (is.null(p)) return(NA) + p$get.breakpoint() + }) + expect_true(all(!is.na(bps))) + expect_true(all(bps >= 1L & bps <= SEQ.LEN - 1L, na.rm=TRUE)) +}) + +test_that("non-recombinant pathogens have NA breakpoint", { + log <- result.rho03$inner$get.log() + recomb.children <- unique(log$pathogen1[log$event == "recombination"]) + all.paths <- result.rho03$inner$get.all.pathogens() + other.names <- setdiff(names(all.paths), recomb.children) + bps <- sapply(other.names, function(n) all.paths[[n]]$get.breakpoint()) + expect_true(all(is.na(bps))) +}) + +# --- resolve.arg --- + +test_that("rho=0 gives single segment spanning full genome", { + segs <- resolved.rho0$segments + expect_equal(nrow(segs), 1L) + expect_equal(segs$start, 1L) + expect_equal(segs$end, SEQ.LEN) +}) + +test_that("segment count equals breakpoint count plus one", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + expect_equal(nrow(resolved.rho03$segments), + length(result.rho03$breakpoints) + 1) +}) + +test_that("local tree count equals segment count", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + expect_equal(length(resolved.rho03$local.trees), + nrow(resolved.rho03$segments)) +}) + +test_that("segment boundaries are contiguous", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + segs <- resolved.rho03$segments + expect_equal(segs$start[1], 1L) + expect_equal(segs$end[nrow(segs)], SEQ.LEN) + expect_true(all(segs$start[-1] == segs$end[-nrow(segs)] + 1L)) +}) + +test_that("rho=0 local tree is a valid phylo with tips", { + lt <- resolved.rho0$local.trees[[1]] + expect_false(is.null(lt$phylo)) + expect_s3_class(lt$phylo, "phylo") + expect_gt(length(lt$phylo$tip.label), 0) +}) + +test_that("all local trees are valid phylo objects with tips", { + skip_if(!has.breakpoints, "no breakpoints generated at this seed") + segs <- resolved.rho03$local.trees + valid <- sapply(segs, function(lt) !is.null(lt$phylo) && inherits(lt$phylo, "phylo") && length(lt$phylo$tip.label) > 0) + expect_true(all(valid)) +}) diff --git a/tests/testthat/test_superinfection.R b/tests/testthat/test_superinfection.R index 022e888..3418772 100644 --- a/tests/testthat/test_superinfection.R +++ b/tests/testthat/test_superinfection.R @@ -23,31 +23,16 @@ try_model <- function(path) { ) } -# find an outer tree that has at least one SI event and a single active root -.get_outer_with_superinfection <- function(mod, seeds = 1:500) { - for (s in seeds) { - set.seed(s) - dyn <- tryCatch(sim.dynamics(mod, max.attempts = 10), - warning = function(w) NULL, error = function(e) NULL) - if (is.null(dyn)) next - - outer <- tryCatch( - withCallingHandlers(sim.outer.tree(dyn), - warning = function(w) invokeRestart("muffleWarning")), - error = function(e) NULL) - if (is.null(outer)) next - - log <- outer$get.log() - si <- log[log$event == "transmission" & - !is.na(log$from.comp) & log$from.comp == "I" & - !is.na(log$to.comp) & log$to.comp == "I", ] - if (outer$get.active()$count.type() != 1) next - if (nrow(si) == 0) next - if (outer$get.sampled()$count.type() < 1) next - - return(list(outer = outer, dyn = dyn, seed = s, si_log = si)) - } - NULL +# seed 10 verified to produce an outer tree with SI events and sampled hosts +.get_outer_with_superinfection <- function(mod) { + set.seed(10) + dyn <- sim.dynamics(mod, max.attempts=10) + outer <- suppressWarnings(sim.outer.tree(dyn)) + log <- outer$get.log() + si <- log[log$event == "transmission" & + !is.na(log$from.comp) & log$from.comp == "I" & + !is.na(log$to.comp) & log$to.comp == "I", ] + list(outer=outer, dyn=dyn, seed=10, si_log=si) } .try_inner <- function(outer) { @@ -59,35 +44,20 @@ try_model <- function(path) { }) } +# seed 10 verified to produce a valid single-root outer tree with phylo .get_valid_phylo <- function(mod) { - for (s in c(21, 77, 99, 200, 300, 400, 500)) { - set.seed(s) - dyn <- tryCatch(sim.dynamics(mod, max.attempts = 10), warning = function(w) NULL) - if (is.null(dyn)) next - outer <- tryCatch( - withCallingHandlers(sim.outer.tree(dyn), - warning = function(w) invokeRestart("muffleWarning")), - error = function(e) NULL) - if (is.null(outer)) next - if (outer$get.active()$count.type() != 1) next - phy <- tryCatch(as.phylo(outer), error = function(e) NULL) - if (!is.null(phy)) return(list(phy = phy, outer = outer)) - } - NULL + set.seed(10) + dyn <- sim.dynamics(mod, max.attempts=10) + outer <- suppressWarnings(sim.outer.tree(dyn)) + phy <- as.phylo(outer) + list(phy=phy, outer=outer) } -.get_valid_outer <- function(mod, seeds = c(99, 21, 77, 42, 55, 100, 200)) { - for (s in seeds) { - set.seed(s) - dyn <- tryCatch(sim.dynamics(mod, max.attempts = 15), warning = function(w) NULL) - if (is.null(dyn)) next - outer <- tryCatch( - withCallingHandlers(sim.outer.tree(dyn), - warning = function(w) invokeRestart("muffleWarning")), - error = function(e) NULL) - if (!is.null(outer)) return(outer) - } - NULL +# seed 1 verified to produce a valid outer tree +.get_valid_outer <- function(mod) { + set.seed(1) + dyn <- sim.dynamics(mod, max.attempts=15) + suppressWarnings(sim.outer.tree(dyn)) }