Skip to content

API reference

Transmission models

EpiBranch.BranchingProcess Type
julia
struct BranchingProcess{W<:Tuple, P, L} <: TransmissionModel

Stochastic branching process transmission model.

Each infected case independently generates a random number of secondary cases — its offspring — and the outbreak is the branching tree that grows from the seeds. The first positional argument is that offspring distribution; a negative binomial is the usual choice, its overdispersion standing in for superspreading. The second positional argument is the contact interval (generation-time distribution); with the default single window and no natural-history states it is the generation interval, exactly as before.

The process is a pure transmission kernel: the modelling layers (progression, interventions, attributes, observation) are attached with a ModelSpec, not on the constructor.

Examples

julia
BranchingProcess(NegBin(2.5, 0.16), LogNormal(1.6, 0.5))
BranchingProcess(NegBin(0.8, 0.5))  # no timing, pure chain statistics
BranchingProcess(M, R_j -> NegBin(R_j, 0.16), LogNormal(1.6, 0.5))  # multi-type

# attach a policy via a ModelSpec
ModelSpec(BranchingProcess(NegBin(2.5, 0.16), LogNormal(1.6, 0.5));
    interventions = [Isolation(onset_to_isolation_delay = Exponential(2.0))])

Transmission is a tuple of Infectiousness windows; the convenience constructors above build a single default window with from = :infection and no censoring.


Fields

  • infectiousness::Tuple

  • population_size::Any

  • n_types::Int64

  • type_labels::Any

EpiBranch.Infectiousness Type
julia
struct Infectiousness{O, F, U, K}
julia
Infectiousness(offspring; from = :infection, until = (), kernel = NoGenerationTime())

A transmission window on a case's timeline: a source of offspring contacts that becomes active at the from state, times each contact by kernel measured from from, and is ended by the earliest of the until states.

  • from names a state (:infection, the default, or a state a transition wrote, e.g. :infectious, :died). The window contributes contacts only once that state has been reached.

  • until is a tuple of state names whose earliest occurrence censors the window: (:recovered, :died) for community spread, (:buried,) for a funeral window. Each name s resolves to the infector's Symbol(s, :_time). Empty by default (no censoring). Isolation censoring comes from the Isolation intervention, not from a state here.

  • kernel is the contact interval, measured from from: a Distribution, a Function (ind) -> Distribution, or NoGenerationTime() (contacts land at the from time).

  • offspring is the window's own draw: a Distribution (single-type), a Function (rng, ind[, state]) -> Int / -> Vector{Int} (multi-type), or any spec draw_offspring accepts.

Several windows on one BranchingProcess (community, funeral) each carry their own offspring, timing, and censoring.


Fields

  • offspring::Any

  • from::Any

  • until::Any

  • kernel::Any

EpiBranch.HomogeneousProcess Type
julia
struct HomogeneousProcess{T<:Real} <: TransmissionModel
julia
HomogeneousProcess(; transmission_rate, population_size,
                   from = nothing, until = (:recovered, :died, :isolated))

A closed population of population_size individuals that mix homogeneously: every infectious person exerts the same force of infection on every susceptible. It is simulated by the Sellke threshold construction, which reproduces the exact stochastic SIR final-size law. Transmission is the per-infective rate transmission_rate (β, so β/N to each susceptible).

The process is a pure transmission kernel. The natural history is a progression of Transitions attached with a ModelSpec, exactly as for BranchingProcess: a latent period is a transition to :infectious, an infectious period a terminal removal transition (to :recovered, say). from is the state the infectious window opens at; left as nothing it is derived from the progression (:infectious when a latent period produces it, otherwise :infection). until names the removal states that close the window.

How detailed the course of infection is is up to you: with just a recovery transition this is an SIR model; adding a latent transition makes it SEIR, and onset, hospitalisation and death come from further transitions in the progression and appear in the line list. The model holds to two assumptions: a closed population, and one infection per person.

Interventions attach through the infectious window. An Isolation intervention removes a case from transmission at its isolation time, shortening the window and curtailing spread; an intervention whose effect is a per-contact competing risk (contact tracing, leaky vaccination) has no representation on the continuous-time path and is reported with a warning rather than applied. Control expressed as a removal Transition in the progression always applies.

The pool is always simulated to extinction over its fixed population, so the simulate termination controls (max_cases, max_generations, max_time, stopping_rules) do not apply; only n_initial (and condition) are used. simulate warns if you set one. This holds for the structure-driven models generally.

Example

julia
using EpiBranch, Distributions
model = ModelSpec(
    HomogeneousProcess(; transmission_rate = 2.0, population_size = 3000);
    progression = [Transition(:recovered; from = :infection, rate = 1.0, terminal = true)])
state = simulate(model; n_initial = 5)

Fields

  • population_size::Int64

  • transmission_rate::Real

  • from::Union{Nothing, Symbol}

  • until::Tuple

EpiNetwork.NetworkProcess Type
julia
NetworkProcess(adjacency, kernel; from = nothing,
               until = (:recovered, :died, :isolated), external_hazard = 0.0,
               obs_end = Inf)

Rate-based transmission over a fixed contact network. adjacency[i] lists the graph neighbours of node i (1-based); the graph is the population. kernel is the contact interval — the one required input — a continuous Distributions.jl distribution shared by every edge, a callable (infector, susceptible) -> Distribution for covariate models, or a per-edge vector of distributions parallel to adjacency (kernel[i][k] for node i's k-th listed neighbour). The kernel times each infectious contact from the infector's from state.

The process is a pure transmission kernel. The natural history is a progression of EpiBranch Transitions attached with a ModelSpec, exactly as for HouseholdProcess: a latent period is Transition(:infectious; from = :infection, delay = …), an infectious period a terminal removal transition, and onset, testing and the rest are further transitions the line list reads. from is the state the kernel times contacts from; left as nothing it is derived from the progression (:infectious when a latent period produces it, otherwise :infection); until names the removal states that close the window. Adding a Transition(:isolated, …) closes the window early and so cuts onward transmission.

external_hazard is the community force of infection — a scalar for a constant hazard or a calendar-time distribution — and obs_end bounds the window [0, obs_end] over which those community introductions emerge.

Example

julia
using EpiNetwork, EpiBranch, Distributions
adjacency = [[2, 5], [1, 3], [2, 4], [3, 5], [4, 1]]   # ring of 5
model = ModelSpec(NetworkProcess(adjacency, Exponential(2.0));
    progression = [Transition(:recovered; from = :infection, delay = 6.0, terminal = true)])
EpiHouseholds.HouseholdProcess Type
julia
HouseholdProcess(sizes, kernel; from = nothing, until = (:recovered, :died, :isolated),
                 external_hazard = 0.0, obs_end = Inf)

Household-structured transmission. sizes gives the size of each household (so sum(sizes) individuals in length(sizes) households) and kernel is the within-household contact interval — the one required input — any continuous Distributions.jl distribution on the positive reals, or a callable (infector, susceptible) -> Distribution for covariate models. The kernel times each infectious contact from the infector's from state.

The process is a pure transmission kernel. The natural history is a progression of EpiBranch Transitions attached with a ModelSpec: a latent period is Transition(:infectious; from = :infection, delay = …), an infectious period a terminal removal transition, and onset, testing and the rest are further transitions the line list reads. from is the state the kernel times contacts from; left as nothing it is derived from the progression (:infectious when a latent period produces it, otherwise :infection). until names the removal states that close the infectious window.

external_hazard is the community force of infection — a scalar for a constant hazard or a calendar-time distribution for a time-varying one — and obs_end bounds the window [0, obs_end] over which those community introductions emerge.

Interventions attach through the infectious window. An Isolation intervention removes a case from transmission at its isolation time, shortening the window and cutting secondary cases; an intervention whose effect is a per-contact competing risk (contact tracing, leaky vaccination) has no representation on the continuous-time path and is reported with a warning rather than applied. Non-pharmaceutical control expressed as a removal Transition in the progression always applies.

Example

julia
using EpiHouseholds, EpiBranch, Distributions
model = ModelSpec(HouseholdProcess([3, 4, 2], Weibull(1.5, 3.0));
    progression = [Transition(:recovered; from = :infection, delay = 6.0, terminal = true)])
EpiBranch.ModelSpec Type
julia
ModelSpec(process; progression, interventions, attributes, observation)

Compose a transmission process (the pure kernel) with the modelling layers that force and observe it: the within-host progression, the interventions, the per-individual attributes, and the observation model. Each keyword defaults to the value already on process, so ModelSpec(process) wraps it faithfully and the keywords override layer by layer.

simulate(spec) runs it; loglikelihood(data, spec) scores observed data against it. The observations themselves stay outside the spec, as the likelihood argument.

EpiBranch.single_type_offspring Function
julia
single_type_offspring(model::TransmissionModel) -> Any
julia
single_type_offspring(model::TransmissionModel)

Extract the offspring specification from a single-type transmission model. Returns whatever the model stores in offspring (typically a Distribution, but can also be any type for which chain_size_distribution is defined — e.g. ClusterMixed).

This is the canonical extension point for custom transmission models: analytical helpers (extinction_probability, epidemic_probability, probability_contain, proportion_transmission, chain_size_distribution) all route through it. A new <: TransmissionModel type that defines a method here gets all of those helpers for free.

Throws for multi-type (function-based) offspring, which the single-type accessor cannot sensibly return.

EpiBranch.draw_offspring Function
julia
draw_offspring(
    rng::Random.AbstractRNG,
    offspring::Distributions.Distribution,
    individual,
    state::SimulationState
) -> Any

Single-type offspring draw.

julia
draw_offspring(
    rng::Random.AbstractRNG,
    offspring::Function,
    individual,
    state::SimulationState
) -> Any

Function-based offspring draw. The function may be called as (rng, individual) or (rng, individual, state); the latter form lets the offspring rule read population-level state (e.g. cumulative cases for time- or policy-dependent caps).

julia
draw_offspring(
    rng::Random.AbstractRNG,
    offspring::ClusterMixed,
    individual,
    state::SimulationState
) -> Any
julia
draw_offspring(rng, offspring::ClusterMixed, individual, state)

Draw offspring under a cluster-mixed specification. Samples θ ~ mixing once per chain, caches it on the index case, and looks it up via parent_id for every descendant so all members of a chain share θ.

Household likelihood

EpiHouseholds.household_infections Function
julia
household_infections(state, model::ModelSpec) -> HouseholdInfections

Read the infection layer out of a simulated state: each member's household, infection time, infectiousness onset (the infectious-window from state) and removal (the earliest of its until states), and index status. The infectious window is read from the same composed progression the simulation used, so the simulate → loglikelihood round trip is exact. A bare HouseholdProcess is accepted too (its window opens at :infection).

EpiHouseholds.HouseholdInfections Type
julia
HouseholdInfections(household_of, infection_time, infectious_time, removal_time, is_index)

The infection layer of a household outbreak: per individual, their household, infection time (NaN if never infected), infectiousness onset and removal (the infectious-window endpoints; removal_time = Inf when not yet removed, i.e. right-censored), and whether they were introduced from outside the household.

These are the latent quantities the contact process is a density over — read out of a simulate round-trip with household_infections, or augmented in inference. Onsets, tests and other observables are not here: they are the progression's outputs, conditioned separately.

EpiHouseholds.pairwise_surv_loglik Function
julia
pairwise_surv_loglik(kernel, data::PairwiseSurvivalData) -> Float64

Marginal pairwise survival log-likelihood on counting-process rows. Each susceptible contributes the log of its summed hazard over its event rows (its possible infectors), minus the cumulative hazard every row accrues over its at-risk interval:

julia
ll = Σ_susceptible log Σ_{event rows} hazard(stop)
 Σ_rows [cumhazard(stop)  cumhazard(start)]

Right-censoring is built in: a susceptible that never had an event contributes only the escaped cumulative hazard. kernel is a Distributions.jl distribution shared by every row, or a callable r -> Distribution for covariates. The result is differentiable in the kernel's parameters, so it drops into Optim or Turing's @addlogprob!.

julia
pairwise_surv_loglik(kernel, data::HouseholdInfections; external_hazard = 0.0) -> Float64

The household contact-process log-density for a kernel and (latent) infection layer data — the inference-friendly form. In a household @model the kernel and external_hazard carry the parameters being fit while data is the augmented infection layer, so neither a HouseholdProcess nor the household structure is rebuilt per evaluation. Differentiable in the kernel's parameters, so it drops into Turing's @addlogprob!. With external_hazard set a community term explains index cases over data.obs_end; otherwise they are conditioned on.

julia
pairwise_surv_loglik(kernel, data::HouseholdInfections, layout::HouseholdPairsLayout;
                     external_hazard = 0.0) -> Real

Inference fast path: evaluate the contact-process log-density on the rows captured in layout. The augmented data.infection_time / data.infectious_time / data.removal_time are read on the fly, so the caller can re-use the same layout across gradient evaluations.

Matches the dynamic-path result up to row order. external mode must be consistent with layout.external; mismatching the two raises.

EpiHouseholds.PairwiseSurvivalData Type
julia
PairwiseSurvivalData(sus, start, stop, event)

Counting-process rows for pairwise_surv_loglik. Row r is an ordered at-risk interval (start[r], stop[r]] for susceptible sus[r], with event[r] true if an infectious contact occurred at stop[r]. Several rows share a susceptible — its possible infectors. The form is space- and order-agnostic: it knows only at-risk intervals and events, never an infection order.

EpiHouseholds.HouseholdPairsLayout Type
julia
HouseholdPairsLayout

Static row structure for pairwise_surv_loglik under a fixed household population. Each row is one ordered (susceptible, infector) pair that the contact process scores; the constructor enumerates the same rows the dynamic _survival_rows would, but only once.

Build it with compile_household_pairs.

EpiHouseholds.compile_household_pairs Function
julia
compile_household_pairs(household_of, is_index, infected; external=false)
compile_household_pairs(data::HouseholdInfections; external=false)

Pre-compute the structural pair list for the inference fast path. infected is the static at-risk mask — true iff the host appears in the posterior as an infected case (its infection_time will be augmented). The single-arg form reads the mask off data as .!isnan.(data.infection_time).

With external=true an additional row per susceptible carries the community hazard term; otherwise index cases are conditioned on and contribute only as infectors.

Types

EpiBranch.Individual Type
julia
mutable struct Individual{T<:Real}

A single contact in the transmission tree (infected or not).

Core fields (used by the engine):

  • id, parent_id, generation, chain_id — identity and position in the tree. id is the 1-based index into state.individuals; this invariant is relied on for O(1) parent lookups.

  • infection_time::T — time at which this contact was exposed.

  • susceptibility::T[0, 1] — per-contact probability of being infected given exposure. Applied as a built-in competing risk by the engine during infection resolution.

  • infectiousness::T[0, 1] — multiplicative modifier on this individual's onward transmission, applied when they become a parent.

The timing and hazard fields share a real element type T (Float64 by default). Under automatic differentiation the whole run carries a dual type, so a gradient flows through the timing parameters; ids and counts stay Int.

  • secondary_case_ids::Vector{Int} — ids of contacts (infected and uninfected) generated by this individual.

state::Dict{Symbol, Any} holds everything else: clinical state (:onset_time, :asymptomatic), demographics (:age, :sex), intervention state (:isolated, :traced, :quarantined, :vaccinated, :test_positive, :type), the :infected flag, and any user-defined fields.

Setting fields at simulation time

Pass an attributes argument to simulate. It runs on every new individual at creation. EpiBranch provides builders for clinical presentation, demographics, and transmission traits, layered by passing a list applied in order:

julia
attributes = [
    clinical_presentation(incubation_period = LogNormal(1.6, 0.5)),
    demographics(age_distribution = Uniform(0, 90)),
    transmission_traits(susceptibility = 0.3, infectiousness = 0.9),
]

For fields without a dedicated builder, write your own closure (rng, ind) -> ... and include it in the list with the others.

See also clinical_presentation, demographics, transmission_traits.


Fields

  • id::Int64

  • parent_id::Int64

  • generation::Int64

  • chain_id::Int64

  • infection_time::Real

  • susceptibility::Real

  • infectiousness::Real

  • secondary_case_ids::Vector{Int64}

  • state::Dict{Symbol, Any}

EpiBranch.SimulationState Type
julia
mutable struct SimulationState{T<:Real, R<:Random.AbstractRNG, P, A}

State of a running or completed simulation.

transitions is the per-run vector of clinical transitions (set by simulate). It is held on the state so individual-creation paths can apply transitions without threading a parameter through every signature.


Fields

  • individuals::Array{Individual{T}, 1} where T<:Real

  • active_ids::Vector{Int64}

  • current_generation::Int64

  • rng::Random.AbstractRNG

  • cumulative_cases::Int64

  • extinct::Bool

  • population_size::Any

  • max_infection_time::Real

  • attributes::Any

  • transitions::Vector{AbstractClinicalTransition}

EpiBranch.SimOpts Type
julia
struct SimOpts

Options controlling simulation termination and setup. Contains only simulation control parameters — clinical and demographic properties are set via attributes functions, AbstractClinicalTransitions, and interventions.

Termination is controlled by stopping_rules, a vector of AbstractStoppingRule; the simulation stops at the first step for which any rule returns true. The keyword constructor accepts ergonomic shortcuts (max_cases, max_generations, max_time) that build the corresponding rules and prepend Extinction:

julia
SimOpts(max_cases = 500)               # [Extinction(), MaxCases(500)]
SimOpts(max_generations = 20, max_time = 90.0)
SimOpts(stopping_rules = [MaxCases(1000), MyCustomRule()])

For finer control or custom rules, pass stopping_rules directly.


Fields

  • n_initial::Int64

  • stopping_rules::Vector{AbstractStoppingRule}

EpiBranch.AbstractStoppingRule Type
julia
abstract type AbstractStoppingRule
julia
AbstractStoppingRule

A rule that decides whether the simulation should terminate at the current step. Subtypes implement should_stop(rule, state) returning Bool; the engine stops when any rule returns true. The default implementation returns false, so user-defined rules only need to override the truthy cases.

Built-in rules:

  • Extinction — stop when no active individuals remain (always included in SimOpts unless explicitly overridden).

  • MaxCases — stop when cumulative cases reach a cap.

  • MaxGenerations — stop after a maximum number of generations.

  • MaxTime — stop when the maximum infection time crosses a threshold.

User extensions are a single method:

julia
struct MaxChainLength <: AbstractStoppingRule
    n::Int
end
should_stop(r::MaxChainLength, state) =
    maximum(ind.generation for ind in state.individuals; init = 0) >= r.n

Fields

EpiBranch.Extinction Type
julia
struct Extinction <: AbstractStoppingRule

Stop when the simulation has gone extinct (no active individuals).


Fields

EpiBranch.MaxCases Type
julia
struct MaxCases <: AbstractStoppingRule

Stop when state.cumulative_cases >= n.


Fields

  • n::Int64
EpiBranch.MaxGenerations Type
julia
struct MaxGenerations <: AbstractStoppingRule

Stop when state.current_generation >= n.


Fields

  • n::Int64
EpiBranch.MaxTime Type
julia
struct MaxTime <: AbstractStoppingRule

Stop when state.max_infection_time >= t.


Fields

  • t::Float64
EpiBranch.should_stop Function
julia
should_stop(
    _::AbstractStoppingRule,
    _::SimulationState
) -> Bool
julia
should_stop(rule::AbstractStoppingRule, state::SimulationState) -> Bool

Whether this rule wants the simulation to terminate given the current state. Default: false.

Simulation

EpiBranch.simulate Function
julia
simulate(
    model::TransmissionModel;
    n_initial,
    max_cases,
    max_generations,
    max_time,
    stopping_rules,
    rng,
    condition,
    max_attempts
) -> Any
julia
simulate(model::TransmissionModel;
         max_cases=10_000, max_generations=100, max_time=nothing,
         n_initial=1, stopping_rules=nothing,
         rng=Random.default_rng(), condition=nothing, max_attempts=10_000)

Run a single outbreak simulation.

model is a transmission process (a pure kernel) or a ModelSpec composing that process with modelling layers. The progression, interventions, attributes and observation come from the ModelSpec; a bare process runs with the empty defaults. simulate itself takes only execution controls.

Termination is set by max_cases, max_generations, and max_time (any of which may be nothing to drop that limit); the run always stops on extinction. For finer control pass a stopping_rules vector of AbstractStoppingRule. n_initial is the number of seed cases.

The case's clinical timeline — the AbstractClinicalTransitions a case moves through (latent, onset, severity, death/recovery, burial) — is the progression composed onto the process with a ModelSpec. Each transition's probability and delay accept constants or (rng, ind) -> value functions, so age- or risk-conditional rates and delays are configured per-transition.

If condition is provided (a UnitRange{Int}), simulations are repeated until one produces an outbreak whose cumulative cases fall within the range, up to max_attempts.

julia
simulate(
    model::TransmissionModel,
    n::Int64;
    n_initial,
    max_cases,
    max_generations,
    max_time,
    stopping_rules,
    rng,
    parallel
) -> Vector
julia
simulate(model, n::Int; parallel=false, kwargs...)

Run n independent outbreak simulations. Returns a Vector{SimulationState}. Takes the same termination keywords as the single-run method.

When parallel=true, simulations are distributed across available threads using independent RNG streams derived from the provided rng. Use julia --threads N to enable multi-threading.

EpiBranch.generate_offspring Function
julia
generate_offspring(model, parent, state) -> count

The offspring-driven transmission seam: how many contacts parent makes this generation, as a single count (single-type) or a count per type (multi-type). The engine calls it once per active parent, then creates that many fresh contacts and assigns each an infection time from the model's generation_time — so the model builds no Individuals, assigns no timing, and takes no interventions argument.

This is the path for tree-like models (a branching process), where every contact is fresh. Structure-driven models whose contacts are existing nodes (a contact network, say) define contacts_of and override collect_exposures instead.

EpiBranch.contacts_of Function
julia
contacts_of(
    model::TransmissionModel,
    parent,
    state::SimulationState
)
julia
contacts_of(model, node, state) -> iterable of (contact, infection_time)

The structure-driven transmission seam: the contacts an infectious node reaches this generation, as (contact, infection_time) pairs. Used by models whose contacts are existing nodes a plain count cannot name — a network returns the node's graph neighbours (a graph with loops); households would return household members. Pair it with gather_by_target so a node reached by several neighbours in one generation resolves once.

Offspring-driven models (a branching process, where every contact is fresh) use generate_offspring instead and never define contacts_of: they return a count and the engine creates and times the contacts. Either way the shared engine (collect_exposures, _advance_generation!) handles interventions, competing-risks resolution, clinical transitions, and bookkeeping.

No in-package model is structure-driven, so the abstract fallback states the contract rather than failing with a bare MethodError. A structure- driven extension (see the EpiNetwork subpackage) defines a method on its own model type.

EpiBranch.collect_exposures Function
julia
collect_exposures(
    model::TransmissionModel,
    state::SimulationState
) -> Tuple{Array{Individual{T}, 1} where T<:Real, Array{Vector{var"#s177"}, 1} where var"#s177"<:Tuple{Int64, Real}, Union{SubArray{Any, 1, P, Tuple{UnitRange{Int64}}, true} where P<:(Array{Individual{T}, 1} where T<:Real), SubArray{E, 1, P, Tuple{UnitRange{Int64}}, true} where {E<:Individual, P<:(Array{Individual{T}, 1} where T<:Real)}}, BitVector}
julia
collect_exposures(model, state) -> (targets, edges, minted, is_new)

This generation's candidate exposures, grouped by target, gathered by walking contacts_of for each active node. Returns parallel vectors: distinct targets, the edges ((parent_id, infection_time)) reaching each, the contacts minted this generation, and is_new flagging which targets were created this step.

The default serves offspring-driven models: it asks each active parent for an offspring count via generate_offspring, then creates and times that many fresh contacts itself. Every contact is its own target — no two parents share one — so is_new is all true. Models whose contacts are existing nodes that can be shared across parents in a generation (networks, households, clustering) override this with gather_by_target, which walks contacts_of and deduplicates so a node reached several times resolves once.

EpiBranch.gather_by_target Function
julia
gather_by_target(
    model::TransmissionModel,
    state::SimulationState
) -> Tuple{Array{Individual{T}, 1} where T<:Real, Array{Vector{var"#s177"}, 1} where var"#s177"<:Tuple{Int64, Real}, Union{SubArray{Any, 1, P, Tuple{UnitRange{Int64}}, true} where P<:(Array{Individual{T}, 1} where T<:Real), SubArray{E, 1, P, Tuple{UnitRange{Int64}}, true} where {E<:Individual, P<:(Array{Individual{T}, 1} where T<:Real)}}, Vector{Bool}}
julia
gather_by_target(model, state) -> (targets, edges, minted, is_new)

A collect_exposures implementation for models whose contacts can be shared across parents within a generation (networks, households, clustering). Exposures are deduplicated by id, so a node reached by several infectious neighbours in one generation collects all its incoming edges and resolves once. Freshly created contacts (id past the generation's starting count) are each their own target, so a model that mixes new and existing contacts — e.g. a clustering dial — works too. The dedup map is only touched for shared nodes.

EpiBranch.model_generation_time Function
julia
model_generation_time(model::TransmissionModel) -> Any
julia
model_generation_time(model)

The generation-time spec the default collect_exposures reads when timing the contacts of an offspring-driven model. Defaults to the model's generation_time field; a model that times its contacts differently (no such field, or a per-window kernel) overrides this instead of being forced to carry a generation_time field.

EpiBranch.transmission_risks Function
julia
transmission_risks(_::TransmissionModel) -> Tuple{}
julia
transmission_risks(model) -> iterable of risk sources

Risk sources the model contributes to competing-risks resolution, on the same competing_risk surface as the built-ins and interventions. A structure-driven model whose transmission probability is a property of the edge — a metapopulation's coupling, say — returns a source here so that probability is a competing risk on every potential contact (the contact is still produced and seen by apply_post_transmission!), rather than a filter that drops contacts before they reach the engine. Defaults to none, so offspring-driven models are unaffected.

EpiBranch.make_contact! Function
julia
make_contact!(
    state::SimulationState,
    parent::Individual,
    infection_time::Real;
    type_idx
) -> Individual
julia
make_contact!(state, parent, infection_time; type_idx = NoTypeLabels())

Create a new contact of parent at infection_time, add it to state.individuals, and register it in parent.secondary_case_ids. Returns the new Individual. Attributes are applied at creation; intervention state is initialised by the engine after the generation's exposures are collected, not here.

This is an engine primitive. The default offspring-driven path calls it for each contact a model's generate_offspring count asks for, so those models never touch it. A structure-driven model that mints fresh nodes inside its contacts_of may call it directly, returning each contact with its infection time:

julia
function contacts_of(m::MyModel, parent, state)
    map(1:rand(state.rng, m.offspring)) do _
        t = parent.infection_time + rand(state.rng, m.generation_time)
        (make_contact!(state, parent, t), t)
    end
end

The engine handles every other side effect: resolve_individual! on each parent before collection, initialise_individual! and apply_post_transmission! on the new contacts after, competing-risks resolution that sets :infected, clinical transitions, and per-step bookkeeping (cumulative_cases, active_ids, max_infection_time, …).

EpiBranch.susceptible_fraction Function
julia
susceptible_fraction(
    state::SimulationState{<:Any, <:Any, NoPopulation}
) -> Float64
susceptible_fraction(
    state::SimulationState{<:Any, <:Any, NoPopulation},
    extra_infected::Int64
) -> Float64
julia
susceptible_fraction(state::SimulationState, extra_infected::Int = 0) -> Float64

Fraction of the population still susceptible at this point in the simulation. Dispatched on the population_size type carried by SimulationState{<:Any, <:Any, P}, so downstream packages can introduce structured populations (households, contact networks, age-stratified pools) by defining a new population-size type and adding a method here.

extra_infected accounts for contacts already infected within the current step but not yet registered in state.cumulative_cases.

Built-in methods:

  • NoPopulation — unbounded, always 1.0.

  • Int — single global pool of that size; depletion is global.

Building a model's starting population

A transmission model defines initialise_state to set up its starting population. These helpers carry the shared work, so a model never touches the SimulationState constructor or the engine's bookkeeping directly.

EpiBranch.initialise_state Function
julia
initialise_state(
    model::TransmissionModel,
    sim_opts::SimOpts,
    interventions,
    transitions,
    attributes,
    rng::Random.AbstractRNG
) -> SimulationState
julia
initialise_state(model, sim_opts, interventions, transitions, attributes, rng) -> SimulationState

Build the starting SimulationState for model: its population and its index cases, before the engine steps any generations. The default offspring-driven method mints sim_opts.n_initial index cases and seeds them. A structure-driven model (a fixed, depleting population such as a network or households) defines its own method, typically by building the population with new_state and add_individuals! and infecting the index cases with seed!.

EpiBranch.new_state Function
julia
new_state(
    model::TransmissionModel,
    transitions,
    attributes,
    rng::Random.AbstractRNG
) -> SimulationState
julia
new_state(model, transitions, attributes, rng) -> SimulationState

An empty SimulationState for model: no individuals yet, generation 0, carrying the model's population size, the run's attributes, and the clinical transitions. The starting point a model's initialise_state builds on with add_individuals! and seed!.

EpiBranch.add_individuals! Function
julia
add_individuals!(
    state::SimulationState,
    n::Integer,
    interventions;
    n_types,
    setup
) -> Union{Vector{Any}, Vector{<:Individual}}
julia
add_individuals!(state, n, interventions; n_types = 1, setup = (ind, i) -> nothing)

Create n individuals, append them to state, and return them. For each, setup(ind, i) runs first (to stamp model-specific state such as a node or household id), then a random type is assigned for multi-type models, then each intervention's initialise_individual! runs. Used by a model's initialise_state to build its population.

EpiBranch.seed! Function
julia
seed!(
    state::SimulationState,
    ids,
    interventions,
    transitions
) -> SimulationState
julia
seed!(state, ids, interventions, transitions) -> state

Mark the individuals with the given ids as infected index cases: set :infected, derive :onset_time from any incubation period, validate that the interventions' and transitions' required fields are present (on the first id, before any transition closure runs, so a missing field surfaces as the engine's friendly message), and record the run's initial bookkeeping (cumulative_cases, active_ids, extinct). Used by a model's initialise_state after add_individuals!.

Interventions

EpiBranch.AbstractIntervention Type
julia
abstract type AbstractIntervention

Base type for all interventions. Subtypes implement one or more of: initialise_individual!, resolve_individual!, apply_post_transmission!, competing_risk.

To support time-based scheduling (Scheduled(iv; start_time=...)), also implement intervention_time and reset!. The default implementations return -Inf and a no-op respectively, which is correct for interventions whose effect time is always considered "now".

Tree-shaping interventions (a hard cap on offspring per parent, gathering-size limits, etc.) are expressed by passing a state-aware function-form offspring distribution to BranchingProcess — not via the intervention protocol. See the Extending guide for an example.


Fields

EpiBranch.Isolation Type
julia
struct Isolation{E<:IsolationEligibility, D<:Distributions.Distribution, S} <: AbstractIntervention

Isolate cases after a delay from symptom onset.

The structural gate (who can be isolated) is given by eligibility, an IsolationEligibility trait. The default SymptomaticOnly reproduces the previous behaviour (symptomatic cases only).

test_sensitivity is the probability that an eligible individual tests positive and so reaches isolation; it accepts a Real, a Distribution, or a function (rng, ind) -> Real (sampled once per individual at init time, stored as :test_positive).

onset_to_isolation_delay is a Distribution from which the time from symptom onset to self-reported isolation is drawn per individual.

post_isolation_transmission ∈ [0, 1] sets the residual transmission probability after isolation. The competing risk's block_probability is 1 - post_isolation_transmission.

Initialises: :isolated, :isolation_time, :test_positive.


Fields

  • eligibility::IsolationEligibility

  • onset_to_isolation_delay::Distributions.Distribution

  • test_sensitivity::Any

  • post_isolation_transmission::Float64

EpiBranch.IsolationEligibility Type
julia
abstract type IsolationEligibility
julia
IsolationEligibility

Trait deciding whether an individual is eligible to be isolated based on the structural gate (e.g. symptomatic vs all-cases). Implementations override is_eligible_for_isolation(elig, individual, state). Whether eligibility actually leads to isolation also depends on the intervention's test_sensitivity.


Fields

EpiBranch.SymptomaticOnly Type
julia
struct SymptomaticOnly <: IsolationEligibility

Symptomatic cases only. Reproduces the original Isolation gate.


Fields

EpiBranch.AllCases Type
julia
struct AllCases <: IsolationEligibility

Every case is eligible, including asymptomatic ones (mass-testing scenarios).


Fields

EpiBranch.is_eligible_for_isolation Function
julia
is_eligible_for_isolation(
    _::IsolationEligibility,
    individual,
    state
) -> Bool
julia
is_eligible_for_isolation(eligibility, individual, state) -> Bool
EpiBranch.ContactTracing Type
julia
struct ContactTracing{E<:TraceEligibility, F<:TraceRate, D<:TraceDelay, A<:TraceAction} <: AbstractIntervention

Trace contacts based on when the infector becomes eligible for tracing.

Eligibility policies

Atomic predicates on the infector:

  • OnSymptomOnset() — infector is symptomatic

  • OnLabConfirmation() — infector has tested positive

  • OnIsolation() — infector has been isolated

  • TraceEveryone() / TraceNobody() — trace all / none

Combine them with the boolean operators &, |, !:

julia
OnSymptomOnset() | OnLabConfirmation()    # suspected or confirmed
OnSymptomOnset() & !OnIsolation()         # symptomatic, not yet isolated

Examples

The terse positional form takes an eligibility policy, a trace probability, a delay distribution, and (optionally) an action:

julia
# Trace on symptoms (no wait for confirmation)
ContactTracing(OnSymptomOnset(), 0.8, Exponential(1.0))

# Standard protocol (wait for lab confirmation)
ContactTracing(OnLabConfirmation(), 0.6, Exponential(2.0))

# Belt and braces (trace suspected OR confirmed)
ContactTracing(OnSymptomOnset() | OnLabConfirmation(), 0.7, Exponential(1.5))

The keyword form keeps the original default (SymptomaticParent, i.e. symptomatic and isolated) and is convenient when only the probability and delay vary:

julia
ContactTracing(probability = 0.7, isolation_to_trace_delay = Exponential(1.0))

Custom eligibility

Users can define custom eligibility types. Per-individual attributes live in infector.state (see clinical_presentation / demographics), so read them with get:

julia
struct SymptomaticOver65 <: TraceEligibility end

function is_eligible(::SymptomaticOver65, infector, contact, state)
    !is_asymptomatic(infector) && get(infector.state, :age, 0) >= 65
end

Ring depth

depth sets how many contact hops out the trace reaches (default 1, direct contacts only; must be at least 1). With depth = 2 the traced contacts of a case keep generating their own contacts, which are traced in turn: the contacts-of-contacts that a level-2 ring vaccination targets. Each infected, eligible case seeds a fresh ring of radius depth; uninfected ring members stay active for one more generation so the ring can grow past them (see keep_active), without infecting their contacts (the InfectiousSource default). Pair with RingVaccination to vaccinate the whole ring:

julia
[ContactTracing(OnSymptomOnset(), 0.8, Exponential(1.0); depth = 2),
 RingVaccination(efficacy = 0.9)]

Needs :asymptomatic, :onset_time from clinical_presentation() and optionally :isolated, :isolation_time, :test_positive depending on eligibility type. Sets :traced, :quarantined. With depth > 1 also sets :trace_time and :ring_remaining to carry the ring outward.


Fields

  • eligibility::TraceEligibility

  • trace_rate::TraceRate

  • isolation_to_trace_delay::TraceDelay

  • action::TraceAction

  • depth::Int64

EpiBranch.TraceEligibility Type
julia
abstract type TraceEligibility
julia
TraceEligibility

Trait deciding whether a infector → contact pair is eligible to be traced. Implementations override is_eligible(eligibility, infector, contact, state).


Fields

EpiBranch.OnSymptomOnset Type
julia
struct OnSymptomOnset <: TraceEligibility

Trace when the infector is symptomatic (clinical suspicion), timed from symptom onset, so tracing can start before lab confirmation, or without it. See trigger_time.


Fields

EpiBranch.OnLabConfirmation Type
julia
struct OnLabConfirmation <: TraceEligibility

Trace when the infector has tested positive (lab confirmation).


Fields

EpiBranch.OnIsolation Type
julia
struct OnIsolation <: TraceEligibility

Trace when the infector has been isolated.


Fields

EpiBranch.TraceEveryone Type
julia
struct TraceEveryone <: TraceEligibility

Trace every contact, regardless of infector status.


Fields

EpiBranch.TraceNobody Type
julia
struct TraceNobody <: TraceEligibility

Never trace any contacts.


Fields

EpiBranch.AlwaysEligible Type

Alias for TraceEveryone: every contact is eligible.

EpiBranch.SymptomaticParent Type
julia
struct SymptomaticParent <: TraceEligibility

Original default gate: infector symptomatic and isolated. Equivalent to OnSymptomOnset() & OnIsolation(); kept as a named type for backwards compatibility (it is the default eligibility).


Fields

EpiBranch.NoTracing Type

Alias for TraceNobody: no contact is eligible.

EpiBranch.AnyOf Type
julia
struct AnyOf{T<:Tuple} <: TraceEligibility

Eligible if any wrapped policy is. Build with |.


Fields

  • conditions::Tuple
EpiBranch.AllOf Type
julia
struct AllOf{T<:Tuple} <: TraceEligibility

Eligible only if all wrapped policies are. Build with &.


Fields

  • conditions::Tuple
EpiBranch.NoneOf Type
julia
struct NoneOf{T<:Tuple} <: TraceEligibility

Eligible only if none of the wrapped policies are. !policy is the single-policy shorthand. (Named NoneOf to parallel AnyOf/AllOf and to avoid colliding with DataFrames.Not.)


Fields

  • conditions::Tuple
EpiBranch.TraceRate Type
julia
abstract type TraceRate
julia
TraceRate

Trait deciding whether tracing happens for an eligible contact. Implementations override traces(rate, infector, contact, state, rng).


Fields

EpiBranch.ConstantRate Type
julia
struct ConstantRate <: TraceRate

Bernoulli with constant probability p.


Fields

  • p::Float64
EpiBranch.TraceDelay Type
julia
abstract type TraceDelay
julia
TraceDelay

Trait giving the delay between the infector's isolation and the contact being traced. Implementations override draw_trace_delay(delay, infector, contact, state, rng).


Fields

EpiBranch.ConstantDelay Type
julia
struct ConstantDelay{D<:Distributions.Distribution} <: TraceDelay

Delay drawn from a fixed distribution.


Fields

  • dist::Distributions.Distribution
EpiBranch.TraceAction Type
julia
abstract type TraceAction
julia
TraceAction

Trait describing what happens to a contact once tracing happens. Implementations override apply_trace!(action, contact, state, trace_time, rng).


Fields

EpiBranch.Quarantine Type
julia
struct Quarantine <: TraceAction

Quarantine the traced contact: set :traced, :quarantined, and isolate them at the trace time (or the earlier of the trace time and any pre-existing self-reporting isolation time).


Fields

EpiBranch.FlagOnly Type
julia
struct FlagOnly <: TraceAction

Flag the contact as traced without quarantining them. If the contact has a known onset time, record a :traced_isolation_time so Isolation can later pick the earlier of self-reporting and tracing.


Fields

EpiBranch.is_eligible Function
julia
is_eligible(
    _::TraceEligibility,
    infector,
    contact,
    state
) -> Bool
julia
is_eligible(eligibility, infector, contact, state) -> Bool
EpiBranch.trigger_time Function
julia
trigger_time(_::TraceEligibility, infector, state) -> Any
julia
trigger_time(eligibility, infector, state) -> Float64

The time the trace starts for infector under this eligibility policy; ContactTracing adds its delay to it. Defaults to the infector's isolation time (the historical default). OnSymptomOnset overrides this to onset time, so suspicion-based tracing starts at symptom onset instead of waiting for isolation or confirmation.

EpiBranch.traces Function
julia
traces(_::TraceRate, infector, contact, state, rng) -> Any
julia
traces(rate, infector, contact, state, rng) -> Bool
EpiBranch.draw_trace_delay Function
julia
draw_trace_delay(
    _::TraceDelay,
    infector,
    contact,
    state,
    rng
) -> Any
julia
draw_trace_delay(delay, infector, contact, state, rng) -> Float64
EpiBranch.apply_trace! Function
julia
apply_trace!(
    _::TraceAction,
    contact,
    state,
    trace_time,
    rng
)
julia
apply_trace!(action, contact, state, trace_time, rng)
EpiBranch.AbstractVaccination Type
julia
abstract type AbstractVaccination <: AbstractIntervention

Base type for vaccination interventions. A vaccination has an efficacy (per-exposure block probability once immunity is in place) and a delay_to_immunity (time between vaccination and protection).

Concrete subtypes differ only in eligibility — who gets vaccinated when. They share the competing_risk machinery: a vaccinated contact whose immunity has developed by their transmission time has their infection blocked with probability efficacy.

efficacy accepts a Real, a Distribution, or a function (rng, ind) -> Real. The function/distribution forms sample once per vaccinated individual at vaccination time and store the result on the contact; the competing risk reads the stored value.

mode is an AbstractEffectMode: LeakyMode (the default) reduces each exposure's success probability by efficacy, while AllOrNothingMode fully protects a fraction efficacy of vaccinated individuals and leaves the rest unaffected.

In a pure branching process the two modes are equivalent

Every contact in a branching process is a unique exposure, so per-exposure and per-individual semantics give the same per-contact infection probability. Switching between LeakyMode and AllOrNothingMode here will not change simulation results. The distinction only starts to matter once network models permit multiple exposures per individual (e.g. the planned EpiBranchHouseholds); the two modes are exposed now so that code written for the household model has the right vocabulary.

Multi-dose vaccination

A dose_label::Symbol (default :default) namespaces the per-contact state so multiple vaccinations can stack without colliding. Two MassVaccinations with dose_label = :prime and dose_label = :boost write to :vaccinated_prime / :vaccinated_boost and contribute independent competing risks. Each dose's protection is composed by the engine via the standard competing-risks product.

When dose_label = :default (single-dose, the common case), state is written to plain :vaccinated / :vaccination_time for backwards compatibility with the is_vaccinated accessor.


Fields

EpiBranch.RingVaccination Type
julia
struct RingVaccination{E, C, W, M<:AbstractEffectMode} <: AbstractVaccination

Vaccinate traced contacts. Applied to contacts that have been traced (:traced == true, set by ContactTracing).

For post-exposure prophylaxis (PEP, cf. pepbp), set delay_to_immunity = 0.0 (the default). For ring vaccination with a vaccine that takes time to confer protection, set delay_to_immunity to the appropriate delay.

coverage is the per-contact probability that a traced contact actually receives the vaccine, capturing programme reach (consent refusal, absence, exclusion criteria, logistical gaps). Defaults to 1.0. Accepts a Real, Distribution, or Function (rng, contact) -> Real for per-individual coverage (e.g. age-dependent).

eligibility_window skips vaccination when the time since the contact's exposure exceeds the window — typical of filovirus-type protocols where post-exposure vaccination beyond ~21 days is operationally pointless. Defaults to Inf (no window). Accepts a Real or Function (rng, contact) -> Real. Eligibility is checked at vaccination time, not at immunity-onset time, so a contact vaccinated near the window's end with a long delay_to_immunity is still recorded as vaccinated (whether immunity arrives before that contact's own transmission time is then decided by competing risks).

onward_efficacy is the per-exposure probability that a vaccinated parent's onward transmission is blocked once the parent's vaccine-induced immunity has developed — the post-exposure prophylaxis mechanism by which ring vaccination averts onward cases even for contacts who were already exposed at the time of vaccination. Defaults to 0.0 (no onward effect). efficacy (the susceptibility-side block applied when the contact is vaccinated) still applies independently; setting both to the same value gives a vaccine that acts symmetrically on susceptibility and infectiousness, setting only onward_efficacy gives a pure PEP effect.

Requires :traced (set by ContactTracing).

Per-contact state keys are :vaccinated, :vaccination_time, and :vaccine_efficacy for the default dose label. With a non-default dose_label, the keys carry the label as a suffix.


Fields

  • efficacy::Any

  • coverage::Any

  • delay_to_immunity::Float64

  • eligibility_window::Any

  • onward_efficacy::Float64

  • mode::AbstractEffectMode

  • dose_label::Symbol

EpiBranch.MassVaccination Type
julia
struct MassVaccination{E, T, M<:AbstractEffectMode} <: AbstractVaccination

Vaccinate the population on a rolling schedule, independent of contact tracing. Each contact draws an eligibility time when they are created; if that time is finite they are recorded as vaccinated. Whether vaccination actually blocks transmission for that contact is then decided by the competing-risks resolution, which checks whether the eligibility time plus delay_to_immunity falls before the contact's own transmission time.

eligibility_time accepts:

  • a Real: every contact becomes eligible at this absolute time.

  • a Distribution: each contact draws its eligibility time independently (e.g. Exponential(60.0) for a slow random rollout).

  • a Function (rng, ind) -> Real: per-individual rule; use for age-stratified rollout or any other state-dependent schedule. Return Inf for individuals who never become eligible.

efficacy accepts the same Real | Distribution | Function set, sampled once per vaccinated contact. Per-individual heterogeneous efficacy (e.g. age-dependent) is set via the function form.

Per-contact state keys are :vaccinated, :vaccination_time, and :vaccine_efficacy for the default dose label. With a non-default dose_label, the keys carry the label as a suffix — pass two MassVaccinations with different labels for a multi-dose rollout.

Examples

Whole population eligible on day 30:

julia
MassVaccination(efficacy = 0.85, eligibility_time = 30.0)

Per-individual rollout draws from a distribution:

julia
MassVaccination(efficacy = 0.85,
    eligibility_time = Exponential(60.0),
    delay_to_immunity = 14.0)

Age-stratified rollout (65+ on day 30, younger on day 90), with age-dependent efficacy:

julia
MassVaccination(
    efficacy = (rng, ind) -> ind.state[:age] >= 65 ? 0.7 : 0.9,
    eligibility_time = (rng, ind) -> ind.state[:age] >= 65 ? 30.0 : 90.0,
    delay_to_immunity = 14.0,
)

Prime-and-boost schedule (compose two instances with different labels):

julia
[
    MassVaccination(efficacy = 0.6,  eligibility_time = 30.0,
        delay_to_immunity = 14.0, dose_label = :prime),
    MassVaccination(efficacy = 0.9,  eligibility_time = 60.0,
        delay_to_immunity = 14.0, dose_label = :boost),
]

Fields

  • efficacy::Any

  • eligibility_time::Any

  • delay_to_immunity::Float64

  • mode::AbstractEffectMode

  • dose_label::Symbol

EpiBranch.AbstractEffectMode Type
julia
abstract type AbstractEffectMode

Effect mode for a vaccination: how efficacy translates into per-exposure infection probability.

Concrete subtypes:

  • LeakyMode: every exposure of a vaccinated individual is reduced by efficacy (per-exposure semantics).

  • AllOrNothingMode: a fraction efficacy of vaccinated individuals are fully protected for all exposures; the rest gain no protection (per-individual semantics).


Fields

EpiBranch.LeakyMode Type
julia
struct LeakyMode <: AbstractEffectMode

Per-exposure efficacy: each exposure's transmission is blocked independently with probability efficacy. Default mode.


Fields

EpiBranch.AllOrNothingMode Type
julia
struct AllOrNothingMode <: AbstractEffectMode

Per-individual efficacy: a fraction efficacy of vaccinated individuals are fully protected (susceptibility = 0); the rest gain no protection.


Fields

EpiBranch.Scheduled Type
julia
struct Scheduled{I<:AbstractIntervention, F} <: AbstractIntervention

Wrap an intervention so it only runs when a condition on the simulation state is met. Individuals are always initialised (so fields exist before the policy activates), but resolve_individual! and apply_post_transmission! are skipped while the condition returns false.

Time-based scheduling

Scheduled is the single entry point for time-based intervention scheduling. It enforces start times at two levels:

  • Population-level gateis_active(::Scheduled, state) skips resolve_individual! and apply_post_transmission! until the condition returns true.

  • Individual-level reset — after each per-individual hook fires, Scheduled checks whether the individual's intervention_time falls before start_time and, if so, calls reset! to undo the effect. This handles the case where the population gate has opened but a specific individual's sampled action time would still fall before the policy began (e.g. an isolation date computed from onset + delay that lands pre-policy).

Individual interventions therefore no longer carry a start_time field of their own — wrap them with Scheduled to schedule them in time.

Keyword constructor

Any combination of start_time, end_time, and start_after_cases is accepted. They are combined with &&:

julia
Scheduled(Isolation(onset_to_isolation_delay=Exponential(2.0)); start_time=14.0)
Scheduled(ContactTracing(probability=0.5, isolation_to_trace_delay=Exponential(1.0)); start_after_cases=50)
Scheduled(iso; start_time=10.0, end_time=30.0)

Predicate constructor

Pass any f(::SimulationState) -> Bool:

julia
Scheduled(iso, state -> state.current_generation >= 3)

The predicate form does not perform per-individual reset (there is no start_time to compare against). Use the keyword form when you need that behaviour.


Fields

  • intervention::AbstractIntervention

  • condition::Any

  • start_time::Float64

EpiBranch.Risk Type
julia
struct Risk{T, P}
julia
Risk(event_time, block_probability)

A competing risk contributed by an intervention against a single contact's transmission. The risk has fired by transmission time T if event_time <= T; when it has fired, transmission is blocked with probability block_probability. A contact is infected iff no intervention's risk blocks it.

Both fields accept either a Real or a function (rng, parent, contact, state) -> Real. The function form lets the event time or block probability depend on per-individual state, e.g. age-conditional vaccine efficacy.

Use event_time = -Inf (the default) for risks that are not time-tagged — pop_suscept, per-individual susceptibility, infectiousness, and the like.

Returned by competing_risk.


Fields

  • event_time::Any

  • block_probability::Any

EpiBranch.HostSusceptibility Type
julia
struct HostSusceptibility

Default risk source: the host's per-individual susceptibility, as a block probability 1 - susceptibility on the competing_risk surface.


Fields

EpiBranch.InfectorInfectiousness Type
julia
struct InfectorInfectiousness

Default risk source: the infector's infectiousness, as a block probability 1 - infectiousness on the competing_risk surface.


Fields

EpiBranch.InfectiousSource Type
julia
struct InfectiousSource

Default risk source: only an infected source transmits. An uninfected source blocks transmission entirely, so the engine can keep uninfected nodes active (e.g. for contact tracing depth, via keep_active) to grow their contacts without those contacts becoming infected. A no-op in the usual case where every active node is infected.


Fields

EpiBranch.initialise_individual! Function
julia
initialise_individual!(
    _::AbstractIntervention,
    individual,
    state
)

Set up intervention-specific fields on a newly created individual. Default: no-op.

julia
initialise_individual!(
    _::AbstractClinicalTransition,
    individual,
    state
)

Set up transition-specific fields on a newly created individual. Default: no-op.

EpiBranch.resolve_individual! Function
julia
resolve_individual!(
    _::AbstractIntervention,
    individual,
    state
)

Determine intervention state before transmission. Default: no-op.

julia
resolve_individual!(
    _::AbstractClinicalTransition,
    individual,
    state
)

Draw the transition's timing/probability and write its outcome to state. Default: no-op.

EpiBranch.apply_post_transmission! Function
julia
apply_post_transmission!(
    _::AbstractIntervention,
    state,
    new_contacts
)

Act on contacts after creation. All contacts are received. Default: no-op.

EpiBranch.keep_active Function
julia
keep_active(
    _::AbstractIntervention,
    state,
    targets,
    is_new
) -> Union{Tuple{}, Vector{Int64}}
julia
keep_active(intervention, state, targets, is_new) -> iterable of Int

The ids of this generation's contacts that should stay active into the next generation (keep generating contacts of their own), beyond the newly infected cases, which always do. Default: none.

The engine unions these into the next active set, so who keeps generating contacts is not a special built-in rule. An intervention that needs the engine to keep growing contacts from uninfected nodes, such as contact tracing reaching contacts-of-contacts, returns those nodes' ids here. Pair it with the InfectiousSource risk source (a default) so those uninfected nodes generate contacts without infecting them.

targets are this generation's contacts and is_new[i] flags which were freshly created. Return ids of nodes that are not already infected; the infected ones stay active anyway.

julia
keep_active(
    ct::ContactTracing,
    state,
    targets,
    is_new
) -> Union{Tuple{}, Vector{Int64}}

Keep uninfected ring members generating contacts so the ring can reach contacts-of-contacts. A traced contact with ring budget left stays active for one more generation; the InfectiousSource default keeps it from infecting those contacts. Infected cases stay active regardless, so only the uninfected fringe is returned. Empty for depth == 1 (direct contacts only).

EpiBranch.competing_risk Function
julia
competing_risk(
    _::AbstractIntervention,
    parent,
    contact,
    state
) -> Union{Nothing, Tuple{Risk, Risk{_A, Float64} where _A}, Risk}
julia
competing_risk(intervention, parent, contact, state)
    -> Union{Nothing, Risk, NTuple{N, Risk}}

Return the Risk(s) this intervention contributes against the parent → contact transmission, or nothing if the intervention does not gate this transmission. Default: nothing.

Most interventions gate transmission through a single mechanism and return one Risk. Interventions that gate it through more than one mechanism — e.g. ring vaccination's susceptibility reduction on the contact and its onward-infectiousness reduction on the parent — may return a tuple of risks instead; the engine applies each independently.

Resolution happens after apply_post_transmission! so that risks can read state that other interventions have written on the contact (e.g. :vaccination_time set by tracing-driven vaccination).

julia
competing_risk(
    iso::Isolation,
    parent,
    contact,
    state
) -> Union{Nothing, Risk{_A, Float64} where _A}

Isolation blocks the parent → contact transmission when the parent's isolation time is earlier than the contact's transmission time. Residual transmission is governed by post_isolation_transmission: block_probability = 1 - post_isolation_transmission.

EpiBranch.intervention_time Function
julia
intervention_time(
    _::AbstractIntervention,
    _::Individual
) -> Any
julia
intervention_time(intervention, individual)

Time at which this intervention's effect occurs for an individual. Used by Scheduled to enforce start_time: if the intervention time is earlier than Scheduled's start_time, the effect is undone via reset!.

Default: -Inf (effect always applies).

EpiBranch.reset! Function
julia
reset!(_::AbstractIntervention, _::Individual)
julia
reset!(intervention, individual)

Undo the effect of an intervention on an individual. Called by Scheduled when intervention_time falls before start_time.

Default: no-op.

EpiBranch.is_active Function
julia
is_active(
    _::AbstractIntervention,
    _::SimulationState
) -> Any

Whether an intervention is currently active given the simulation state. Default: always.

Natural history (progression)

EpiBranch.AbstractClinicalTransition Type

Base type for clinical-state transitions. Subtypes implement initialise_individual! (set default state on a new case) and resolve_individual! (draw the transition's timing and probability).

A transition writes its outcome to one or more keys on individual.state, under names it owns. Other transitions and the line-list projection read from these keys. Transitions are composable: stack them in a vector and the engine applies them in order at case-creation time, after attributes and interventions have run.

Terminal transitions — those that end the case — declare themselves by returning true from is_terminal and implement terminal_event. After all transitions resolve for an individual, the engine collects every terminal candidate (across every transition that declared itself terminal) and assigns :outcome and :outcome_time from the earliest. Death and Recovery are the built-in pair, but the framework is open: a user-defined LostToFollowUp, MovedAway, or disease-specific terminal state plugs in by adding the same two methods and dropping the struct into the transitions vector. Competing-risks arbitration handles the rest.

See also AbstractIntervention — transitions are the clinical analogue: where interventions are policy applied to a case, transitions are biology happening to a case.

The abstract type itself is declared in src/types.jl to allow SimulationState to hold a typed transitions vector; the interface methods live here.

EpiBranch.Transition Type
julia
struct Transition{D, P, F} <: AbstractClinicalTransition
julia
Transition(state; from = :infection, delay =, rate =, probability = 1.0, terminal = false)

A timed transition in a case's natural history: the case reaches state a delay after it reached from, with probability probability. On each individual it writes state => true and Symbol(state, :_time) => from_time + delay. When the transition does not happen — the probability gate fails, or the from state was never reached — the flag stays false and the time stays Inf.

from names an earlier state whose time this one is measured from: :infection (the default) resolves to the individual's infection_time; any other symbol s resolves to Symbol(s, :_time) in ind.state (so from = :onset reads :onset_time). A Function (ind) -> Real is also accepted, for anchors held as fields on the Individual. If the from state's time is not finite (the upstream state was never reached, or an asymptomatic case has a NaN onset), the transition is skipped.

The timing is given as exactly one of delay or rate. delay is a fixed Real, a Distribution, or an (rng, ind) -> Real, resolved per individual. rate = r is the compartmental alternative: an exponential (Markovian) transition with hazard r, i.e. delay = Exponential(1 / r) (mean 1 / r). probability is a Real or (rng, ind) -> Real, resolved per individual.

terminal = true marks the transition as ending the case: it joins the competing-terminal arbitration, where :outcome and :outcome_time take the earliest terminal that happened. This generalises Reporting, Death and the rest, which are this transition with a fixed state and bespoke key names.

Examples

julia
# latent period: infection → onset of infectiousness
Transition(:infectious, from = :infection, delay = LogNormal(1.0, 0.4))

# infectious period as a recovery rate (exponential, mean 1/γ)
Transition(:recovered, from = :infectious, rate = 1 / 6, terminal = true)

# severity branch, then death from the severe state
Transition(:severe, from = :onset,  delay = Gamma(2, 2), probability = 0.3)
Transition(:died,   from = :severe, delay = Gamma(2, 3), probability = 0.6, terminal = true)

Fields

  • state::Symbol

  • time_key::Symbol

  • delay::Any

  • probability::Any

  • from::Any

  • terminal::Bool

EpiBranch.Reporting Type
julia
struct Reporting{D, P, F} <: AbstractClinicalTransition

Cases are reported with probability probability after a delay drawn per case, measured from from. from defaults to :onset_time, but can be any Symbol (looked up in ind.state, e.g. :test_time, :admission_time) or a Function (ind) -> Real (use this to anchor on fields like ind.infection_time that live on the Individual rather than ind.state). If the anchor is not finite, the transition is skipped — either the from state was never reached (its :*_time key still at its Inf default), or the anchor is NaN, most commonly an asymptomatic case under clinical_presentation whose :onset_time is NaN.

probability is a Real or a Function (rng, ind) -> Real for per-individual heterogeneity (e.g. risk-group-specific detection). delay is a Distribution or a Function (rng, ind) -> Real.

Initialises: :reported = false, :reporting_time = Inf.

When from = :onset_time (the default) the simulation start-up validator requires :onset_time to be set by attributes. For other anchors the requirement is dropped — typically the anchor key is set by an upstream transition rather than by an attributes function, so the validator can't catch it; instead it'll be non-finite at resolve time and the transition skips, which is the correct behaviour.


Fields

  • delay::Any

  • probability::Any

  • from::Any

EpiBranch.Hospitalisation Type
julia
struct Hospitalisation{D, P, F} <: AbstractClinicalTransition

Cases are admitted to hospital with probability probability after a delay drawn per case, measured from from. from defaults to :onset_time but accepts any Symbol (state-dict key) or Function (ind) -> Real — see Reporting for the anchor semantics. If the anchor is not finite, the case is skipped.

Both probability and delay accept the heterogeneity shapes shared across transitions: Real/Distribution for constants, Function (rng, ind) -> Real for per-individual rules.

For prerequisite-gated admission (e.g. admit only cases that have been reported, tested, contact-traced, vaccinated, or that satisfy any other predicate on ind.state), express the gate inside the probability function — return 0.0 when the gate is closed:

julia
Hospitalisation(
    delay = LogNormal(2.0, 0.5),
    probability = (rng, ind) -> get(ind.state, :reported, false) ? 0.2 : 0.0
)

The same idiom covers any composite condition; no per-prerequisite field is needed.

Initialises: :admitted = false, :admission_time = Inf.


Fields

  • delay::Any

  • probability::Any

  • from::Any

EpiBranch.Death Type
julia
struct Death{D, P, F} <: AbstractClinicalTransition

Terminal transition: the case dies. When death is drawn, a candidate death time is produced by adding a sample from delay to the value of from. from defaults to :onset_time but accepts any Symbol or Function (ind) -> Real — see Reporting for the anchor semantics.

probability is required (no default) and accepts a Real, a Distribution, or a Function (rng, ind) -> Real. The probability is too pathogen-specific for a sensible default — pass an explicit value, even if it is 0.0. Use the function form for age- or risk-conditional rates:

julia
Death(delay = LogNormal(2.5, 0.4),
      probability = (rng, ind) -> ind.state[:age] >= 80 ? 0.3 : 0.02)

delay accepts a Distribution or Function (rng, ind) -> Real, making time-to-death heterogeneity available the same way.

Initialises :death_candidate_time = Inf.

Death and Recovery compose as competing terminal events, resolved by earliest candidate time. probability is the probability death enters that race, so the realised fraction dying equals it only when death's candidate time reliably precedes any competing recovery/removal — otherwise the realised case-fatality is lower (with equal delays and a competing Recovery, roughly halved). For an exact CFR, gate Recovery's probability as 1 - CFR, or make death's delay dominate the competing one.


Fields

  • delay::Any

  • probability::Any

  • from::Any

EpiBranch.Recovery Type
julia
struct Recovery{D, F} <: AbstractClinicalTransition

Terminal transition: the case recovers. A candidate recovery time is drawn from delay and added to the value of from. from defaults to :onset_time but accepts any Symbol (state-dict key) or Function (ind) -> Real — see Reporting for the anchor semantics. If the anchor is not finite, no recovery candidate is produced.

delay is a Distribution or a Function (rng, ind) -> Real for per-individual heterogeneity (e.g. age-conditional recovery delay).

Initialises :recovery_candidate_time = Inf.

Recovery and Death compose as competing terminal events: whichever has the earliest candidate time becomes the case's :outcome. Other user-defined terminal transitions (with is_terminal = true and a terminal_event method) participate in the same arbitration.


Fields

  • delay::Any

  • from::Any

EpiBranch.is_terminal Function
julia
is_terminal(_::AbstractClinicalTransition) -> Bool

Whether this transition is terminal (i.e. ends the case). Default: false.

EpiBranch.terminal_event Function
julia
terminal_event(
    _::AbstractClinicalTransition,
    individual
) -> Union{Nothing, Tuple{Any, Symbol}}
julia
terminal_event(transition, individual) -> Union{Nothing, Tuple{Float64, Symbol}}

For terminal transitions, return (time, label) if this transition would end the case (e.g. (11.3, :died)), or nothing if it does not fire for this case. Called after all resolve_individual!s have run. The engine takes the earliest terminal candidate across all transitions and writes :outcome (Symbol) and :outcome_time (Float64) to the individual's state.

Non-terminal transitions never see this method called.

EpiBranch.resolve_transitions! Function
julia
resolve_transitions!(state::SimulationState, individual)
julia
resolve_transitions!(state, individual)

Resolve individual's clinical natural history: run every transition on state.transitions (the model's progression) against the individual — first each transition's initialise_individual!, then each resolve_individual! — and arbitrate the terminal outcome. This stamps the timeline keys downstream code reads (:infectious_time, :onset_time, :outcome/:outcome_time, and anything else a transition writes) onto individual.state.

Part of the public extension API. The built-in engine calls this for every new case; a structure-driven model that runs its own simulation loop (rather than the generation-based engine) calls it itself, once per case, after the case's attributes and intervention state are set. The transitions come from the model's progression, placed on the state when it is built with new_state.

State accessors

EpiBranch.onset_time Function
julia
onset_time(ind::Individual{T}) -> Any

Symptom onset time (NaN if asymptomatic or not set); a dual under AD.

EpiBranch.incubation_period Function
julia
incubation_period(ind::Individual) -> Any

Incubation period: time from infection to symptom onset (Float64, NaN if asymptomatic or onset is not set). Useful inside a generation_time function that links an individual's generation time to their own incubation period.

EpiBranch.is_isolated Function
julia
is_isolated(ind::Individual) -> Bool

Whether the individual is isolated.

EpiBranch.isolation_time Function
julia
isolation_time(ind::Individual{T}) -> Any

Time of isolation (Inf if not isolated); a dual under AD.

EpiBranch.is_traced Function
julia
is_traced(ind::Individual) -> Bool

Whether the individual was traced via contact tracing.

EpiBranch.is_quarantined Function
julia
is_quarantined(ind::Individual) -> Bool

Whether the individual is quarantined.

EpiBranch.is_vaccinated Function
julia
is_vaccinated(ind::Individual; dose_label) -> Bool

Whether the individual is vaccinated under the given dose_label. The default label reads the plain :vaccinated key; a non-default label reads the namespaced key an AbstractVaccination with that dose_label writes.

EpiBranch.is_asymptomatic Function
julia
is_asymptomatic(ind::Individual) -> Bool

Whether the individual is asymptomatic.

EpiBranch.is_test_positive Function
julia
is_test_positive(ind::Individual) -> Bool

Whether the individual tested positive.

EpiBranch.is_infected Function
julia
is_infected(ind::Individual) -> Bool

Whether the individual was successfully infected (vs contact only).

EpiBranch.individual_type Function
julia
individual_type(ind::Individual) -> Int64

Type index for multi-type branching processes (default 1).

EpiBranch.set_isolated! Function
julia
set_isolated!(ind::Individual, time::Real) -> Real

Mark an individual as isolated at the given time (any Real, so an AD dual isolation time flows through).

Output

EpiBranch.linelist Function
julia
linelist(state::SimulationState; reference_date) -> Any
julia
linelist(state::SimulationState; reference_date=Date(2020, 1, 1))

Return a DataFrame with one row per infected case. The core columns (id, parent_id, generation, chain_id, date_infection) are always present; any other typed field or state entry becomes a column too. Keys ending in _time are converted to dates using reference_date, so :onset_time ends up as date_onset.

To add a column, write the field during the simulation. linelist reads whatever is on state.

EpiBranch.contacts Function
julia
contacts(
    state::SimulationState;
    reference_date
) -> DataFrames.DataFrame
julia
contacts(state::SimulationState; reference_date=Date(2020, 1, 1))

Return a DataFrame with one row per contact event (infected and non-infected), with columns from, to, infected, generation, infection_time, date_infection.

EpiBranch.chain_statistics Function
julia
chain_statistics(
    state::SimulationState
) -> DataFrames.DataFrame
julia
chain_statistics(state::SimulationState)

Compute chain size and length for each transmission chain. Only infected individuals are counted. Returns a DataFrame with columns: chain_id, size, length.

size is the number of cases in the chain. length is the number of generations of onward transmission — the maximum generation index reached, so a lone seed that never transmits has length = 0 and size = 1. This is the same 0-based convention as ChainLengths; note it is offset by one from epichains, whose chain_length counts generations inclusively (1 for a single-case chain).

julia
chain_statistics(
    states::Vector{<:SimulationState}
) -> DataFrames.DataFrame
julia
chain_statistics(states::Vector{<:SimulationState})

Compute chain statistics across multiple simulations. A DataFrame with columns sim_id, chain_id, size, length is returned.

EpiBranch.compute_trace_level! Function
julia
compute_trace_level!(
    state::SimulationState
) -> SimulationState
julia
compute_trace_level!(state::SimulationState) -> state

Post-run enrichment: walk each individual's :traced_by back to the index case and stamp :trace_level (distance from the index case) onto its state. The ring anchor — the seeded, eligible case a trace started from — is level 0; its directly traced contacts are 1, contacts-of-contacts 2, and so on. Cases that were never traced (and never anchored a realised ring) are left without a :trace_level.

:trace_level then flows into linelist automatically, and any other state consumer can read it with get(ind.state, :trace_level, missing). The engine carries no level during simulation — this is a deliberate post-run step, so the cost stays out of the hot loop.

First-traced, not nearest

:traced_by records the first / earliest-exposure tracer, because the engine makes one trace attempt per node. On a tree (BranchingProcess) that is the unique parent, so the level is exact. On a cyclic NetworkProcess it is the depth along the first-traced path, which is not guaranteed to be the shortest distance to the nearest index — do not read it as one. (A true nearest-index distance would need the engine to record every successful tracer; see issue #150.)

julia
compute_trace_level!(
    states::AbstractVector{<:SimulationState}
) -> AbstractVector{<:SimulationState}
julia
compute_trace_level!(states::AbstractVector{<:SimulationState}) -> states

Apply compute_trace_level! to each state in turn (mirrors chain_statistics over a batch of runs).

EpiBranch.realised_generation_interval Function
julia
realised_generation_interval(
    ind::Individual,
    state::SimulationState
) -> Any
julia
realised_generation_interval(ind::Individual, state::SimulationState)

The realised forward generation interval for ind: the time from its infector's infection to its own, ind.infection_time - parent.infection_time. Index cases and individuals that were never infected return NaN.

The interval is shaped by the epidemic. Susceptible depletion and interventions decide which transmissions happen, so realised intervals are distributed differently from the intrinsic generation_time you give the model. This is the per-individual counterpart of onset_time.

EpiBranch.realised_generation_intervals Function
julia
realised_generation_intervals(
    state::SimulationState
) -> Vector{Float64}
julia
realised_generation_intervals(state::SimulationState)

The realised forward generation intervals of every infected non-index case in one simulation, as a Vector{Float64}. See realised_generation_interval.

For a model with a fixed generation_time, the intrinsic interval is that distribution itself. For a state-dependent generation_time, run the model without interventions and the realised intervals coincide with the intrinsic ones, since nothing blocks transmission.

julia
realised_generation_intervals(
    states::Vector{<:SimulationState}
) -> Vector{Float64}
julia
realised_generation_intervals(states::Vector{<:SimulationState})

The realised forward generation intervals across several simulations, flattened into one Vector{Float64}.

EpiBranch.containment_probability Function
julia
containment_probability(
    states::Vector{<:SimulationState};
    max_cases
) -> Float64
julia
containment_probability(states::Vector{<:SimulationState}; max_cases=nothing)

Fraction of simulations that went extinct (i.e. the outbreak was contained).

If max_cases is provided, simulations that hit the case cap are not considered extinct (they are assumed to have continued growing).

EpiBranch.is_extinct Function
julia
is_extinct(
    state::SimulationState;
    by_week,
    max_cases
) -> Bool
julia
is_extinct(state::SimulationState; by_week=nothing, max_cases=nothing)

Extinction classification for a single simulation with optional criteria.

  • No keyword args: returns state.extinct

  • by_week::Int: extinct if no case has its onset in that week

  • by_week::UnitRange{Int}: extinct if no case has its onset in that week range

  • max_cases::Int: outbreaks hitting this cap are not considered extinct

Weeks are 7-day blocks numbered from t = 0 (week 1 is days 0–6), binned on onset time with a fall-back to infection time for any case without a recorded onset — the same timing field as weekly_incidence.

EpiBranch.generation_R Function
julia
generation_R(state::SimulationState) -> DataFrames.DataFrame
julia
generation_R(state::SimulationState)

Realised per-generation offspring ratio: for each generation g, the number of cases in generation g+1 divided by the number of cases in generation g. A DataFrame with columns generation and offspring_ratio is returned.

This is not the time-varying effective reproduction number Rt typically estimated from an incidence time series — it is a generation-indexed average that only coincides with Rt under strong assumptions. Use it as a within-simulation diagnostic of how transmission is being reduced generation-by-generation (e.g. by depletion or by interventions), not as an Rt proxy.

EpiBranch.weekly_incidence Function
julia
weekly_incidence(
    state::SimulationState;
    by,
    reference_date
) -> DataFrames.DataFrame
julia
weekly_incidence(state::SimulationState; by=:onset,
                 reference_date::Date=Date(2020, 1, 1))

Compute weekly case counts from a single simulation. A DataFrame with columns week (Date) and cases (Int) is returned.

by selects the timing field to bin on:

  • :onset (default) — uses :onset_time from individual state, which is what a surveillance epicurve plots. Falls back to :infection_time for any case whose :onset_time is missing or NaN (e.g. asymptomatic cases under clinical_presentation).

  • :infection — uses infection_time directly. This is what was previously the only behaviour, but it is not directly observable in real surveillance and produces an epicurve that is shifted earlier by roughly one incubation period.

  • :reporting — uses :reporting_time (set by the Reporting transition or PerCaseObservation); cases without a reporting time are excluded.

Pass a Symbol from ind.state to bin on any other field (e.g. :admission_time).

Analytical

Helpers

EpiBranch.extinction_probability Function
julia
extinction_probability(
    R::Real,
    k::Real;
    tol,
    max_iter
) -> Any
julia
extinction_probability(R::Real, k::Real; tol=1e-10, max_iter=1000)

Compute the extinction probability of a branching process with Negative Binomial offspring distribution parameterised by mean R and dispersion k.

Fixed-point iteration on the probability generating function is used. For R ≤ 1, returns 1.0 (certain extinction).

julia
extinction_probability(
    d::Distributions.Poisson;
    tol,
    max_iter
) -> Any
julia
extinction_probability(d::Distribution; tol=1e-10, max_iter=1000)

Compute extinction probability for any discrete offspring distribution via fixed-point iteration on the PGF.

For Poisson(λ): the PGF exp(λ(s-1)) is used. For NegativeBinomial: R and k are extracted and the closed-form PGF is applied.

julia
extinction_probability(
    model::Union{TransmissionModel, ModelSpec};
    kwargs...
) -> Any
julia
extinction_probability(model::TransmissionModel; kwargs...)

Extinction probability for a single-type transmission model, extracted from the model's offspring specification via single_type_offspring. Works for BranchingProcess and wrappers that delegate that accessor (e.g. Observed).

EpiBranch.epidemic_probability Function
julia
epidemic_probability(R::Real, k::Real; kwargs...) -> Any
julia
epidemic_probability(R::Real, k::Real; kwargs...)

Probability that a single introduction leads to a major epidemic. Complement of extinction probability.

julia
epidemic_probability(
    d::Distributions.Distribution;
    kwargs...
) -> Any
julia
epidemic_probability(d::Distribution; kwargs...)

Probability of a major epidemic for a given offspring distribution.

julia
epidemic_probability(
    model::Union{TransmissionModel, ModelSpec};
    kwargs...
) -> Any
julia
epidemic_probability(model::TransmissionModel; kwargs...)

Epidemic probability for a single-type transmission model.

EpiBranch.probability_contain Function
julia
probability_contain(
    R::Real,
    k::Real;
    n_initial,
    ind_control,
    pop_control,
    tol,
    max_iter
) -> Any
julia
probability_contain(R, k; n_initial=1, ind_control=0.0, pop_control=0.0)

Probability that an outbreak is contained (goes extinct), accounting for individual-level and population-level control measures and multiple initial infections.

  • ind_control: probability each case is individually controlled (removed before transmitting), e.g. through case isolation

  • pop_control: population-level reduction in R, e.g. through social distancing. Effective R becomes (1 - pop_control) * R

  • n_initial: number of initial independent introductions

The containment probability for a single introduction is:

julia
q = ind_control + (1 - ind_control) * pgf(q)

where pgf is the PGF of the offspring distribution with effective R. For n_initial independent introductions, the probability is q^n_initial.

This is a port of probability_contain (and the probability_extinct self-consistency equation it builds on) in superspreading (Lambert et al., https://github.com/epiverse-trace/superspreading, MIT).

julia
probability_contain(
    d::Distributions.NegativeBinomial;
    kwargs...
) -> Any
julia
probability_contain(d::Distribution; n_initial=1, ind_control=0.0, pop_control=0.0)

Containment probability for a given offspring distribution.

julia
probability_contain(
    model::Union{TransmissionModel, ModelSpec};
    kwargs...
) -> Any
julia
probability_contain(model::TransmissionModel; kwargs...)

Containment probability for a single-type transmission model. Delegates through single_type_offspring, so wrappers such as Observed work too.

EpiBranch.proportion_transmission Function
julia
proportion_transmission(R::Real, k::Real; prop_cases) -> Any
julia
proportion_transmission(R::Real, k::Real; prop_cases::Real=0.2)

Compute the proportion of transmission caused by the most infectious fraction prop_cases of cases, under a Negative Binomial offspring distribution with mean R and dispersion k.

This is the "80/20 rule" metric for superspreading: with prop_cases=0.2, returns the proportion of all transmission events caused by the top 20% of transmitters.

The result depends only on the dispersion k; R is accepted for interface consistency but does not affect it, because the Lorenz curve of the underlying Gamma(k, R/k) is scale-invariant in the mean. Two calls with the same k and different R return the same value.

Computed via the regularised incomplete beta function.

julia
proportion_transmission(
    d::Distributions.NegativeBinomial;
    prop_cases
) -> Any
julia
proportion_transmission(model::BranchingProcess; prop_cases=0.2)

Proportion of transmission from the most infectious fraction of cases, extracted from the model's offspring distribution (must be NegativeBinomial).

EpiBranch.proportion_cluster_size Function
julia
proportion_cluster_size(
    R::Real,
    k::Real;
    cluster_size
) -> Any
julia
proportion_cluster_size(R, k; cluster_size=10)

Proportion of secondary cases that arise from transmission events where the infector caused at least cluster_size secondary cases.

This quantifies case concentration: with high overdispersion (low k), a large fraction of cases come from a few superspreading events.

Uses the tail expectation of the NegBin distribution: E[X | X ≥ c] × P(X ≥ c) / E[X]

julia
proportion_cluster_size(
    d::Distributions.NegativeBinomial;
    cluster_size
) -> Any
julia
proportion_cluster_size(d::NegativeBinomial; cluster_size=10)

Proportion of cases from large clusters for a NegBin offspring distribution.

julia
proportion_cluster_size(
    model::Union{TransmissionModel, ModelSpec};
    cluster_size
) -> Any
julia
proportion_cluster_size(model::BranchingProcess; cluster_size=10)

Proportion of cases from large clusters for a branching process model.

EpiBranch.heterogeneous_contact_R Function
julia
heterogeneous_contact_R(
    mean_contacts::Real,
    sd_contacts::Real,
    duration::Real,
    prob_transmission::Real
) -> NamedTuple{(:R, :R_net), <:Tuple{Any, Any}}
julia
heterogeneous_contact_R(mean_contacts, sd_contacts, duration, prob_transmission)

Compute the basic reproduction number adjusted for heterogeneous contact patterns in a network.

Returns a named tuple (R=..., R_net=...):

  • R: unadjusted, assuming homogeneous mixing (β × mean_contacts × duration)

  • R_net: network-adjusted, accounting for contact heterogeneity (β × duration × (mean + variance/mean))

The adjustment reflects that high-contact individuals both acquire and transmit more, amplifying R beyond what homogeneous mixing predicts.

This is a mean-field, configuration-model result: it depends only on the mean and variance of the contact (degree) distribution and assumes no clustering. It is an analytical summary, distinct from an explicit network simulation, which transmits over a graph that may have the clustering this formula assumes away. It is a direct port of calc_network_R in superspreading (Lambert et al., https://github.com/epiverse-trace/superspreading, MIT).

Chain-size distributions

chain_size_distribution(spec) is the dispatch entry point: given an offspring specification it returns the closed-form cluster-size PMF as a Distribution. Borel (chain sizes from Poisson offspring) is the one size law you also construct directly — it is a standard named distribution. The observation-side counterpart (ThinnedChainSize) lives with the observation models.

EpiBranch.chain_size_distribution Function
julia
chain_size_distribution(d::Distributions.Poisson) -> Borel
julia
chain_size_distribution(offspring::Poisson)

Analytical chain size distribution for Poisson offspring.

julia
chain_size_distribution(
    d::Distributions.NegativeBinomial
) -> EpiBranch.GammaBorel
julia
chain_size_distribution(offspring::NegativeBinomial)

Analytical chain size distribution for NegativeBinomial offspring.

julia
chain_size_distribution(
    o::ClusterMixed
) -> EpiBranch.PoissonGammaChainSize
julia
chain_size_distribution(o::ClusterMixed)

Return the chain size distribution for a cluster-mixed offspring. Uses the closed form when one is known (e.g. Poisson + Gamma returns PoissonGammaChainSize); otherwise returns ChainSizeMixture, which evaluates the PMF pointwise by numerical quadrature.

julia
chain_size_distribution(
    model::TransmissionModel;
    seeds,
    prob_concluded,
    kwargs...
) -> Any
julia
chain_size_distribution(model; seeds=nothing, prob_concluded=nothing, kwargs...)
chain_size_distribution(spec::ModelSpec; seeds=nothing, prob_concluded=nothing, kwargs...)

Distribution over observed chain (cluster) sizes under model, the primary entry point for Bayesian inference on chain-size data with Turing's ~:

julia
@model function fit(sizes)
    R ~ Gamma(2, 1)
    sizes ~ chain_size_distribution(BranchingProcess(NegBin(R, 0.5)))
end

With no keyword arguments, no interventions and a single-type offspring law, the analytical chain-size distribution (Borel / GammaBorel) is returned directly; otherwise a wrapper that scores via loglikelihood. Keyword arguments:

  • seeds: per-cluster number of index cases, for multi-seed clusters.

  • prob_concluded: per-cluster probability the cluster is finished (its observed size is its final size), for real-time data with ongoing clusters — see end_of_outbreak_probability. Defined only against the analytical law, so it is not supported alongside interventions.

  • n_sim, interventions, …: forwarded to the underlying simulation-based loglikelihood when the analytical fast path does not apply.

EpiBranch.Borel Type
julia
struct Borel{T<:Real} <: Distributions.Distribution{Distributions.Univariate, Distributions.Discrete}
julia
Borel(μ)

The Borel distribution with parameter μ > 0.

P(X = n) = (μn)^(n-1) * exp(-μn) / n! for n = 1, 2, ...

This is the chain size distribution for a Poisson(μ) branching process. For μ > 1 (supercritical) the PMF is still valid at each n, but its total mass is less than 1: chains are infinite with positive probability. We keep the PMF defined in the supercritical region so that integrating chain size PMFs over a mixing distribution that spans both sides of 1 works pointwise.


Fields

  • μ::Real

Dispatch outputs (not exported)

Returned by chain_size_distribution rather than constructed by name, so they are not exported. GammaBorel is the size law for NegativeBinomial offspring (individual-level Gamma-Poisson mixing); PoissonGammaChainSize is the size law for Poisson offspring with a chain-level Gamma rate.

EpiBranch.GammaBorel Type
julia
struct GammaBorel{T<:Real} <: Distributions.Distribution{Distributions.Univariate, Distributions.Discrete}
julia
GammaBorel(k, R)

Chain size distribution for a NegativeBinomial(k, R) branching process, derived via Lagrange inversion.

For R > 1 (supercritical) the PMF is still valid at each n, but its total mass is less than 1: chains are infinite with positive probability.


Fields

  • k::Real

  • R::Real

EpiBranch.PoissonGammaChainSize Type
julia
struct PoissonGammaChainSize{T<:Real} <: Distributions.Distribution{Distributions.Univariate, Distributions.Discrete}
julia
PoissonGammaChainSize(k, R)

Chain size distribution when the per-chain offspring distribution is Poisson(λ) with λ ~ Gamma(shape = k, mean = R). This corresponds to rate heterogeneity at the chain (cluster) level rather than the individual level, and matches the gborel likelihood in epichains.

Note: this is different from GammaBorel, which is the chain size distribution for NegativeBinomial offspring (Gamma-Poisson mixing at the individual level).


Fields

  • k::Real

  • R::Real

Inference

Data types

EpiBranch.OffspringCounts Type
julia
struct OffspringCounts

Observed secondary case counts – the number of individuals each case infected. Used with loglikelihood and fit.

Examples

julia
data = OffspringCounts([0, 1, 2, 0, 3, 1, 0])
loglikelihood(data, NegBin(0.8, 0.5))

Fields

  • data::Vector{Int64}
EpiBranch.ChainSizes Type
julia
struct ChainSizes

Observed transmission chain sizes (total number of cases per chain). Used with loglikelihood and fit.

Fields:

  • data::Vector{Int} — observed cluster sizes.

  • seeds::Vector{Int} — number of independent index cases per cluster (default 1).

By default every cluster is treated as concluded (final-size likelihood). For real-time data with still-active clusters, pass a per-cluster prob_concluded vector of "is finished" probabilities to loglikelihood; see the prob_concluded kwarg on loglikelihood(::ChainSizes, ::Distribution).

Examples

julia
# Standard case: all single-seed.
data = ChainSizes([1, 1, 3, 1, 5])

# Multi-seed clusters.
data = ChainSizes([3, 5, 10, 2]; seeds = [1, 2, 1, 1])

Fields

  • data::Vector{Int64}

  • seeds::Vector{Int64}

EpiBranch.ChainLengths Type
julia
struct ChainLengths

Observed transmission chain lengths (number of generations). Used with loglikelihood and fit.

Examples

julia
data = ChainLengths([0, 1, 0, 2, 1])
loglikelihood(data, Poisson(0.5))

Fields

  • data::Vector{Int64}

Distribution entry points

These return a Distribution (the analytical form where one exists, otherwise a wrapper around loglikelihood) you can put on the right-hand side of Turing's ~. The chain_size_distribution entry above lists its process-side (analytical) methods.

EpiBranch.chain_length_distribution Function
julia
chain_length_distribution(
    model::TransmissionModel;
    kwargs...
) -> EpiBranch._ChainLengthLaw{M, @NamedTuple{}} where M<:TransmissionModel
julia
chain_length_distribution(model; kwargs...)

Distribution over observed chain lengths under model. Use with Turing's ~:

julia
@model function fit(data)
    R ~ Beta(1, 1)
    data ~ chain_length_distribution(BranchingProcess(Poisson(R)))
end

kwargs are forwarded to the underlying loglikelihood(::ChainLengths, model) call (e.g. n_sim, interventions for the simulation-based path).

EpiBranch.offspring_distribution Function
julia
offspring_distribution(
    model::TransmissionModel
) -> Distributions.Distribution
julia
offspring_distribution(model)

Per-case offspring distribution of model. For a BranchingProcess this is the same Distribution you passed in as offspring.

julia
@model function fit(data)
    R ~ Beta(1, 1)
    data ~ offspring_distribution(BranchingProcess(Poisson(R)))
end

Observation models

An observation model is part of the process. Pass observation = … to a process constructor. It is added the same way an intervention is, by implementing two methods dispatched on the observation type: observe for the analytical likelihood and apply_observation! for simulation.

EpiBranch.ObservationModel Type
julia
abstract type ObservationModel

Abstract supertype for observation models. Subtypes describe how underlying transmission events generate observable data — per-case detection, reporting delays, aggregation, multi-stream surveillance, etc. Attached to a process as the observation forcing on its constructor; participates through observe and apply_observation!, dispatched on the observation type.


Fields

EpiBranch.NoObservation Type
julia
struct NoObservation <: ObservationModel

No observation model: latent cases are observed exactly.


Fields

EpiBranch.PerCaseObservation Type
julia
struct PerCaseObservation{P, D, F} <: ObservationModel
julia
PerCaseObservation(; detection_prob = 1.0, delay = Dirac(0.0),
                   from = :onset_time)

Independent per-case observation: each case is reported with probability detection_prob, and reports lag the anchor time given by from by an independent draw from delay. from defaults to :onset_time because real surveillance lags symptom onset, not infection. Set from = ind -> ind.infection_time to anchor on infection time instead.

If the anchor evaluates to NaN (e.g. an asymptomatic case under clinical_presentation), reporting falls back to the infection time so the report time is still well-defined.

detection_prob and delay both accept the standard Real | Distribution | Function trio:

  • a Real (or Distribution for delay) reproduces the original behaviour;

  • a Function (rng, ind) -> Real lets the value depend on per-individual state (e.g. age-conditional reporting probability).

Per-individual variation is honoured by the simulation path (apply_observation!). The closed-form analytical helpers — ThinnedChainSize, observe(distribution, ::PerCaseObservation) — require a scalar detection_prob and will throw when given a Distribution or Function; fall back to the simulation likelihood for per-individual reporting.

detection_prob = 1.0, delay = Dirac(0.0) ↔ no observation effect. detection_prob = 1.0, delay = D ↔ full reporting with delay D. detection_prob = ρ, delay = Dirac(0.0) ↔ binomial thinning, no delay.


Fields

  • detection_prob::Any

  • delay::Any

  • from::Any

EpiBranch.observe Function
julia
observe(base, _::NoObservation) -> Any
julia
observe(base_distribution, obs::ObservationModel)

Analytical side of the observation protocol: transform the latent base_distribution (e.g. a chain-size distribution) into the distribution of the observed quantity under obs, returning a Distribution. Because the result is itself a distribution, it slots into the same likelihood machinery as the latent law (see the design notes on why observation models return distributions). The default NoObservation returns the base unchanged; PerCaseObservation thins it with ThinnedChainSize.

EpiBranch.apply_observation! Function
julia
apply_observation!(_::NoObservation, state, rng) -> Any
julia
apply_observation!(obs::ObservationModel, state, rng)

Simulation side of the observation protocol: apply obs to a finished SimulationState in place (e.g. mark :reported cases and set :report_time). Called by simulate after the run. The default NoObservation leaves the latent cases untouched.

EpiBranch.ThinnedChainSize Type
julia
struct ThinnedChainSize{D<:Distributions.Distribution{Distributions.Univariate, Distributions.Discrete}} <: Distributions.Distribution{Distributions.Univariate, Distributions.Discrete}
julia
ThinnedChainSize(base, detection_prob)

Distribution of observed chain sizes when each case in a base chain is detected with probability detection_prob.

logpdf(d, obs) sums logpdf(base, n) + logpdf(Binomial(n, p), obs) over n >= obs until the tail is negligible. The computation only needs logpdf on the base, so this composes without specialised methods.


Fields

  • base::Distributions.Distribution{Distributions.Univariate, Distributions.Discrete}

  • detection_prob::Float64

Cluster-level heterogeneity

EpiBranch.ClusterMixed Type
julia
struct ClusterMixed{F, D<:Distributions.Distribution}
julia
ClusterMixed(build, mixing)

Offspring specification with cluster-level heterogeneity: each chain draws θ from mixing, and the offspring distribution within that chain is build(θ).

If build is a distribution family type (e.g. Poisson) and a closed form exists for the combination, dispatch uses it automatically. For everything else the likelihood falls back to numerical quadrature over mixing.

Examples

julia
# Poisson offspring with Gamma-distributed rate uses the closed-form
# PoissonGammaChainSize via dispatch.
o = ClusterMixed(Poisson, Gamma(2.0, 0.4))
loglikelihood(ChainSizes([1, 2, 1, 5]), o)

# NegBin offspring with Gamma-distributed R (fixed k) has no closed
# form and is evaluated by quadrature.
o = ClusterMixed(R -> NegBin(R, 0.5), Gamma(2.0, 0.3))
loglikelihood(ChainSizes([1, 1, 3, 2]), o)

Fields

  • build::Any

  • mixing::Distributions.Distribution

EpiBranch.ChainSizeMixture Type
julia
struct ChainSizeMixture{F, D<:Distributions.Distribution} <: Distributions.Distribution{Distributions.Univariate, Distributions.Discrete}
julia
ChainSizeMixture(build, mixing)

Chain size distribution defined by integrating the chain size PMF of build(θ) over mixing. logpdf(d, n) uses adaptive Gauss-Kronrod quadrature on the 0.001-0.999 quantile range of mixing.

This is the generic chain size distribution for a ClusterMixed offspring. When a closed form exists (e.g. PoissonGammaChainSize for Poisson + Gamma), chain_size_distribution dispatches to it directly instead.


Fields

  • build::Any

  • mixing::Distributions.Distribution

Likelihood and fitting

EpiBranch extends Distributions.loglikelihood with methods for each data wrapper:

julia
loglikelihood(OffspringCounts(data), Poisson(0.5))
loglikelihood(ChainSizes(data), NegBin(0.8, 0.5))
loglikelihood(ChainLengths(data), Poisson(0.5))
loglikelihood(ChainSizes(data), model)   # interventions/observation read from model

For maximum-likelihood estimation, pair the loglikelihood interface with Optim.jl, or use Turing's maximum_likelihood — the same model that feeds data ~ chain_size_distribution(model) works for both.

See the chains tutorial for examples.

Init functions

EpiBranch.clinical_presentation Function
julia
clinical_presentation(
;
    incubation_period,
    prob_asymptomatic
)
julia
clinical_presentation(; incubation_period, prob_asymptomatic = 0.0)

Return an attributes function that sets :onset_time and :asymptomatic on each individual.

For symptomatic cases, :onset_time = infection_time + rand(incubation_period). For asymptomatic cases (drawn with probability prob_asymptomatic), :onset_time = NaN and :asymptomatic = true. Required by Isolation and used by linelist to populate date_onset.

prob_asymptomatic accepts a Real, a Distribution, or a function (rng, ind) -> Real. Use the function form for age- or state-conditional asymptomatic fractions.

Examples

Symptomatic-only with a log-normal incubation period:

julia
attributes = clinical_presentation(incubation_period = LogNormal(1.6, 0.5))

With 30% asymptomatic:

julia
attributes = clinical_presentation(
    incubation_period = LogNormal(1.6, 0.5),
    prob_asymptomatic = 0.3,
)

Per-individual asymptomatic probability drawn from a Beta:

julia
attributes = clinical_presentation(
    incubation_period = LogNormal(1.6, 0.5),
    prob_asymptomatic = Beta(2, 8),
)

Age-conditional (children much more likely to be asymptomatic; list after demographics so :age is set first):

julia
attributes = [
    demographics(age_distribution = Uniform(0, 90)),
    clinical_presentation(
        incubation_period = LogNormal(1.6, 0.5),
        prob_asymptomatic = (rng, ind) -> ind.state[:age] < 18 ? 0.6 : 0.2,
    ),
]

See also demographics.

EpiBranch.demographics Function
julia
demographics(
;
    age_distribution,
    age_range,
    prob_female
) -> EpiBranch.var"#92#93"{NoAgeDistribution, Tuple{Int64, Int64}, Float64}
julia
demographics(; age_distribution=nothing, age_range=(0, 90), prob_female=0.5)

Return an attributes function. :age and :sex are set on each individual.

EpiBranch.transmission_traits Function
julia
transmission_traits(
;
    susceptibility,
    infectiousness
) -> EpiBranch.var"#96#97"{EpiBranch.var"#_trait_sampler##0#_trait_sampler##1"{Float64}, EpiBranch.var"#_trait_sampler##0#_trait_sampler##1"{Float64}}
julia
transmission_traits(; susceptibility = 1.0, infectiousness = 1.0)

Return an attributes function that sets susceptibility (per-contact probability of infection given exposure) and infectiousness (parent-side modifier on transmission) on each individual.

Each argument accepts:

  • a Real: assigned directly to every individual.

  • a Distribution: sampled per individual via rand(rng, dist).

  • a Function (rng, ind) -> value: called per individual; the returned value is assigned. Use this for attribute-dependent rules (e.g. age-conditional susceptibility) — place the builder after demographics in the attributes list so ind.state[:age] is set first.

Both default to 1.0 (no Bernoulli filtering in the transmission model).

Examples

Constant per-contact infection probability:

julia
attributes = transmission_traits(susceptibility = 0.3)

Per-individual heterogeneity:

julia
attributes = transmission_traits(
    susceptibility = Beta(2, 5),
    infectiousness = Beta(8, 2),
)

Age-conditional susceptibility (list after demographics so :age is set first):

julia
attributes = [
    demographics(age_distribution = Uniform(0, 90)),
    transmission_traits(
        susceptibility = (rng, ind) -> ind.state[:age] >= 65 ? 0.8 : 0.3,
    ),
]

The closure form (rng, ind) -> (ind.susceptibility = ...) as a list entry remains available as an escape hatch for cases this builder does not cover.

See also clinical_presentation, demographics.

Convenience constructors

EpiBranch.NegBin Function
julia
NegBin(R::Real, k::Real) -> Distributions.NegativeBinomial
julia
NegBin(R, k)

Convenience constructor for a Negative Binomial offspring distribution parameterised by mean reproduction number R and dispersion parameter k.

A NegativeBinomial from Distributions.jl is returned, with mean R and variance R + R²/k.

Note: NegativeBinomial(r, p) from Distributions.jl uses a different parameterisation (number of successes and success probability). Using it directly as an offspring distribution will produce silently wrong results. Always use NegBin(R, k) for epidemiological parameterisation.

EpiBranch.scale_distribution Function
julia
scale_distribution(
    d::Distributions.Poisson,
    factor::Real
) -> Distributions.Poisson
julia
scale_distribution(d, factor::Real)

Scale an offspring distribution's mean by factor, preserving its family and shape. Defined for the two offspring families the package scales: Poisson (returns Poisson(λ · factor)) and NegativeBinomial (same k, mean scaled). Any other family raises an ArgumentError naming it, rather than a bare MethodError.

EpiBranch.incubation_linked_generation_time Function
julia
incubation_linked_generation_time(
;
    presymptomatic_fraction,
    omega
) -> EpiBranch.var"#9#10"{Float64, Float64}
julia
incubation_linked_generation_time(; presymptomatic_fraction=0.3, omega=2.0)

Return a function suitable for the generation_time field of a BranchingProcess, in which each individual's generation time is linked to their own incubation period.

The returned function takes an Individual and produces a truncated skew-normal distribution SN(ξ, ω, α), where ξ is the individual's incubation period and α is chosen so that the fraction of generation times shorter than the incubation period equals presymptomatic_fraction. The inversion α = tan(π(0.5 − presymptomatic_fraction)) is exact for the untruncated skew-normal; after truncation to [0, ∞) the realised fraction is approximate, holding closely when the incubation period is large relative to omega and diverging for short incubation periods. This is the generation time model used in Hellewell et al. (2020). Individuals with no usable incubation period (for example asymptomatic cases) fall back to a 5-day centre.

Usage:

julia
model = BranchingProcess(
    NegBin(2.5, 0.16),
    incubation_linked_generation_time(presymptomatic_fraction=0.3)
)

Internals

These functions are not part of the public API but are documented for developers extending the package.

EpiBranch.get_generation_time Function
julia
get_generation_time(
    gt::Distributions.Distribution,
    individual
) -> Distributions.Distribution
julia
get_generation_time(gt, individual)

Return the generation time distribution for a specific individual.

For a Distribution, everyone shares the same distribution. For a Function, the engine calls it with the individual and uses the Distribution it returns, so the generation time can read anything in individual.state: the incubation period, or any per-individual quantity an attributes function has stored. That is how the generation time and symptom onset can come from one per-individual draw instead of two independent ones. Use incubation_period to read the incubation period inside such a function.

EpiBranch._advance_generation! Function
julia
_advance_generation!(
    model::TransmissionModel,
    state::SimulationState,
    interventions::Vector{<:AbstractIntervention}
)

Advance the simulation by one generation through the unified engine, as four phases: interventions act on the active infectives (_prepare_parents!), the exposure phase (collect_exposures) builds and times this generation's contacts, contact-level interventions act on the exposed (_intervene!), and the resolve phase (_resolve!) decides infection under competing risks and updates bookkeeping and clinical transitions. In the growing tree the build and time steps are fused in collect_exposures (a contact is minted with its infection time); they separate only in the fixed-population path, where contacts pre-exist.

EpiBranch._prepare_parents! Function
julia
_prepare_parents!(
    state::SimulationState,
    interventions::Vector{<:AbstractIntervention}
)

Phase 1 — interventions act on the active infectives before they transmit.

EpiBranch._intervene! Function
julia
_intervene!(
    state::SimulationState,
    interventions::Vector{<:AbstractIntervention},
    targets::Vector{<:Individual},
    edges::Vector{<:Vector{<:Tuple}},
    minted
)

Phase 3 — contact-level interventions act on this generation's exposures. Newly minted contacts get their intervention state initialised; each target is given a provisional parent (its earliest exposing edge) so contact-level interventions (tracing, ring vaccination) act on the exposed target before infection is resolved; then the interventions act.

EpiBranch._resolve! Function
julia
_resolve!(
    model::TransmissionModel,
    state::SimulationState,
    interventions::Vector{<:AbstractIntervention},
    targets::Vector{<:Individual},
    edges::Vector{<:Vector{<:Tuple}},
    is_new
)

Phase 4 — decide infection under competing risks and update bookkeeping. Exposure is not infection. Each contact exposed this generation is decided infected-or-not: the infector's infectiousness, the contact's susceptibility, any risks the model contributes and any interventions all act as competing risks on the same footing. A contact is infected if any of its exposing edges transmits; the earliest successful edge fixes the infection time.

EpiBranch._decide_infected Function
julia
_decide_infected(
    state::SimulationState,
    contact::Individual,
    model_risks,
    interventions,
    infected_so_far::Int64
) -> Bool

Decide whether contact is infected by its parent. The transmission risks act as competing hazards, and the first to block wins. An index case (no parent) is always infected. Otherwise finite-population depletion (susceptible_fraction) applies first, then each risk source in turn: the built-in host susceptibility and infector infectiousness, then any from the model's transmission_risks, then the interventions. Returns true when the contact is infected.

EpiBranch.population_size Function
julia
population_size(_::TransmissionModel) -> NoPopulation

Interface methods with defaults for any TransmissionModel.

EpiBranch._create_individual Function
julia
_create_individual(
    state::SimulationState,
    parent_id::Int64,
    chain_id::Int64,
    next_id::Int64,
    inf_time::Real
) -> Individual

Create a new Individual with attributes applied. :infected is left at false so that every freshly created contact carries a not-yet-decided flag — only the engine's competing-risks resolution may set it to true. Intervention initialise_individual! is called by the engine after exposures are collected, not here; this keeps contact creation (make_contact!, and any model's contacts_of) intervention-free.

EpiBranch.logsumexp Function
julia
logsumexp(x) -> Any
julia
logsumexp(x)

Numerically stable log-sum-exp. Any iterable is accepted (generators are collected first to allow two-pass computation).

EpiBranch.required_fields Function
julia
required_fields(
    _::AbstractClinicalTransition
) -> Vector{Symbol}

Fields a transition requires on individuals (set by attributes). Default: none.

julia
required_fields(_::AbstractIntervention) -> Any

Fields that an intervention requires on individuals. Default: none.

EpiBranch._validate_required_fields Function
julia
_validate_required_fields(individual, items)

Check that all required fields are present on an individual. Works for any iterable of items that define required_fields — used for both interventions and clinical transitions.

EpiBranch._column_order Function
julia
_column_order(ks) -> Vector{Symbol}

Sensible column ordering for the linelist DataFrame: core simulation columns first, then date columns (alphabetical), then the rest (alphabetical).

EpiBranch._chain_length_ll_negbin Function
julia
_chain_length_ll_negbin(
    data,
    offspring::Distributions.NegativeBinomial
) -> Any

Analytical chain length likelihood for NegBin offspring.

EpiBranch._borel_logpdf Function
julia
_borel_logpdf(μ, x::Integer) -> Any
_borel_logpdf(μ, x::Integer, s::Integer) -> Any

Log-PDF of the Borel distribution. Accepts any numeric type for μ (AD-compatible). With s > 1, this is the Borel-Tanner generalisation for the total chain size starting from s independent index cases: P(X = x | s, μ) = (s/x) * (xμ)^(x-s) * exp(-xμ) / (x-s)!.

The single-seed case (s = 1) is a port of the Borel chain-size log-likelihood (dborel / .pois_size_ll) in epichains (Azam, Funk & Finger, https://github.com/epiverse-trace/epichains, MIT).

EpiBranch._gammaborel_logpdf Function
julia
_gammaborel_logpdf(k, R, x::Integer) -> Any
_gammaborel_logpdf(k, R, x::Integer, s::Integer) -> Any

Log-PDF of the GammaBorel distribution. Accepts any numeric type for k, R (AD-compatible). With s > 1, this is the multi-seed generalisation: the PGF factors as T(z)^s and Lagrange inversion gives P(X = x | s) = (s/x) * C(kx + x - s - 1, x - s) * k^(kx) * (k+R)^(s - kx - x) * R^(x - s).

The single-seed case (s = 1) is a port of .nbinom_size_ll in epichains (Azam, Funk & Finger, https://github.com/epiverse-trace/epichains, MIT).

EpiBranch._empirical_ll Function
julia
_empirical_ll(
    observed,
    simulated;
    min_val,
    censored,
    cap
) -> Any

Empirical log-likelihood with Laplace smoothing and right-censoring.

When censored is provided, simulated values flagged as censored contribute to P(size >= cap) rather than P(size = cap). Observed values at or above cap are evaluated as P(size >= cap).