Skip to content

Interventions

Interventions in EpiBranch.jl are modelled through a competing risks framework. Potential contacts are generated by the branching process. Each contact's fate is determined by competing risks: when would transmission occur (generation time) vs when is the parent isolated (intervention time)?

There is a connection to survival analysis. The generation time CDF is the survival function of remaining potential transmission, truncated by isolation.

Without interventions

First, let's see the baseline — a supercritical outbreak with no interventions:

julia
using EpiBranch
using Distributions
using StableRNGs

model = BranchingProcess(Poisson(3.0), Exponential(5.0))
clinical = clinical_presentation(incubation_period = LogNormal(1.5, 0.5))

rng = StableRNG(42)
results_baseline = simulate_batch(model, 200;
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("Containment (no interventions): $(round(containment_probability(results_baseline), digits=3))")
Containment (no interventions): 0.04

With R = 3.0, most outbreaks are not contained. Interventions are needed.

Built-in interventions

Isolation

Symptomatic, test-positive individuals are isolated after a delay from symptom onset using Isolation. Clinical state on individuals is required, set by clinical_presentation or Disease:

julia
iso = Isolation(delay = Exponential(2.0))

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("Containment (isolation): $(round(containment_probability(results), digits=3))")
Containment (isolation): 0.15

Effectiveness depends on how quickly isolation happens relative to the generation time. Faster isolation truncates more of the infectious period:

julia
for d in [0.5, 2.0, 10.0]
    let iso = Isolation(delay = Exponential(d)),
        rng = StableRNG(42)
        results = simulate_batch(model, 200;
            interventions = [iso],
            attributes = clinical,
            sim_opts = SimOpts(max_cases = 500),
            rng = rng,
        )
        println("Delay ~ Exp($d): containment = $(round(containment_probability(results), digits=3))")
    end
end
Delay ~ Exp(0.5): containment = 0.25
Delay ~ Exp(2.0): containment = 0.15
Delay ~ Exp(10.0): containment = 0.11

Leaky isolation

With post_isolation_transmission > 0, isolated individuals still transmit at a reduced rate (e.g. household contacts):

julia
iso_leaky = Isolation(delay = Exponential(2.0), post_isolation_transmission = 0.3)

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso_leaky],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("Leaky isolation: $(round(containment_probability(results), digits=3))")
Leaky isolation: 0.165

Contact tracing

Contacts of isolated cases are identified using ContactTracing. With quarantine, traced contacts are isolated before symptom onset:

julia
iso = Isolation(delay = Exponential(2.0))
ct = ContactTracing(probability = 0.7, delay = Exponential(1.0), quarantine_on_trace = true)

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso, ct],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("Isolation + tracing: $(round(containment_probability(results), digits=3))")
Isolation + tracing: 0.29

Asymptomatic cases and test sensitivity

Asymptomatic cases escape symptom-based surveillance. The asymptomatic fraction is set via Disease. Imperfect testing is a property of isolation — symptomatic cases are missed with probability 1 - test_sensitivity:

julia
disease_hard = Disease(
    incubation_period = LogNormal(1.5, 0.5),
    prob_asymptomatic = 0.3,
)
iso_imperfect = Isolation(delay = Exponential(2.0), test_sensitivity = 0.8)

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso_imperfect, ct],
    attributes = disease_hard,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("30% asymptomatic, 80% test sensitivity: $(round(containment_probability(results), digits=3))")
30% asymptomatic, 80% test sensitivity: 0.145

Ring vaccination

Traced contacts are vaccinated using RingVaccination, reducing their susceptibility. This is applied after contact tracing has identified contacts, so ContactTracing must be in the intervention stack.

The vaccine can be leaky (everyone's susceptibility reduced) or all-or-nothing (a fraction are fully protected):

julia
iso = Isolation(delay = Exponential(2.0))
ct = ContactTracing(probability = 0.7, delay = Exponential(1.0))
rv = RingVaccination(efficacy = 0.8, mode = :leaky)

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso, ct, rv],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("Iso + tracing + ring vaccination: $(round(containment_probability(results), digits=3))")
Iso + tracing + ring vaccination: 0.29

A delay between vaccination and protective immunity can be specified. If transmission occurs before immunity develops, there is no protection:

julia
rv_delayed = RingVaccination(efficacy = 0.9, delay_to_immunity = 7.0)

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso, ct, rv_delayed],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("With 7-day delay to immunity: $(round(containment_probability(results), digits=3))")
With 7-day delay to immunity: 0.29

We can also count the number of vaccine doses administered:

julia
rng = StableRNG(42)
state = simulate(model;
    condition = 50:200,
    interventions = [iso, ct, rv],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 200),
    rng = rng,
)
n_vaccinated = count(is_vaccinated, state.individuals)
n_infected = count(is_infected, state.individuals)
println("Vaccinated: $n_vaccinated, Infected: $n_infected")
Vaccinated: 245, Infected: 200

Post-exposure prophylaxis

For PEP (antivirals or antibiotics given to traced contacts), use RingVaccination with delay_to_immunity = 0.0 (the default):

julia
pep = RingVaccination(efficacy = 0.9)  # delay_to_immunity defaults to 0

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso, ct, pep],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("Iso + tracing + PEP: $(round(containment_probability(results), digits=3))")
Iso + tracing + PEP: 0.29

Effort tracking

Because all contacts are stored (infected and non-infected), intervention effort is fully trackable:

julia
rng = StableRNG(42)
state = simulate(model;
    condition = 50:200,
    interventions = [iso, ct],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 200),
    rng = rng,
)

total = length(state.individuals)
infected = count(is_infected, state.individuals)
traced = count(is_traced, state.individuals)
println("Contacts: $total, Infections: $infected, Traced: $traced")
println("Contacts per case: $(round(total / infected, digits=1))")
Contacts: 326, Infections: 200, Traced: 245
Contacts per case: 1.6

Time-dependent policies

In real outbreaks, interventions are not active from the start. Testing may begin on day 14, contact tracing may start once cumulative cases exceed a threshold.

The start_time field

Isolation and ContactTracing accept a start_time parameter. This filters on action time — an individual is only isolated if their computed isolation time falls after the policy start, regardless of when they were infected. This is a competing risk: the testing infrastructure must be available at the time the individual would be tested.

julia
# Testing starts on day 10
iso_delayed = Isolation(delay = Exponential(2.0), start_time = 10.0)

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso_delayed],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("Isolation from day 10: $(round(containment_probability(results), digits=3))")
Isolation from day 10: 0.08

Someone infected on day 8 with symptom onset on day 9 and delay 2 has isolation time = 11, which is after day 10, so they are isolated. Someone with isolation time = 9 is not isolated — testing was not yet available.

The Scheduled wrapper

Scheduled wraps any intervention with a population-level activation condition. When start_time is passed, it is automatically forwarded to the inner intervention's own start_time field:

julia
# Equivalent to the above — Scheduled forwards start_time
iso_scheduled = Scheduled(Isolation(delay = Exponential(2.0)); start_time = 10.0)
Scheduled{Isolation, EpiBranch.var"#29#30"{Float64}}(Isolation(Distributions.Exponential{Float64}(θ=2.0), 10.0, 0.0, 1.0), EpiBranch.var"#29#30"{Float64}(10.0))

Scheduled is most useful for conditions that cannot be expressed as a fixed time, such as case-count triggers:

julia
# Start contact tracing after 20 cumulative cases
iso = Isolation(delay = Exponential(2.0))
ct_triggered = Scheduled(
    ContactTracing(probability = 0.7, delay = Exponential(1.0));
    start_after_cases = 20,
)

rng = StableRNG(42)
results = simulate_batch(model, 200;
    interventions = [iso, ct_triggered],
    attributes = clinical,
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("Tracing after 20 cases: $(round(containment_probability(results), digits=3))")
Tracing after 20 cases: 0.19

Conditions can be combined:

julia
# Active only between day 5 and day 30
iso_window = Scheduled(Isolation(delay = Exponential(1.0));
    start_time = 5.0, end_time = 30.0)
Scheduled{Isolation, EpiBranch.var"#35#36"{Vector{Function}}}(Isolation(Distributions.Exponential{Float64}(θ=1.0), 5.0, 0.0, 1.0), EpiBranch.var"#35#36"{Vector{Function}}(Function[EpiBranch.var"#29#30"{Float64}(5.0), EpiBranch.var"#31#32"{Float64}(30.0)]))

For full flexibility, pass a predicate on SimulationState:

julia
# Start isolation from generation 3 onwards
iso_gen3 = Scheduled(
    Isolation(delay = Exponential(2.0)),
    state -> state.current_generation >= 3,
)
Scheduled{Isolation, Main.var"#2#3"}(Isolation(Distributions.Exponential{Float64}(θ=2.0), 0.0, 0.0, 1.0), Main.var"#2#3"())

How the framework enforces start_time

Each intervention can define two methods:

  • intervention_time — returns the time at which the effect occurs for an individual (e.g. isolation time, trace time)

  • reset! — undoes the effect if it falls before start_time

After each resolve_individual! and apply_post_transmission! call, the framework checks: if intervention_time < start_time, call reset!. This happens in one place for all interventions — individual interventions do not need to check start_time themselves. Here is the actual implementation:

julia
using CodeTracking
print(@code_string EpiBranch._enforce_start_time!(iso, state.individuals[1]))
function _enforce_start_time!(intervention, individual)
    t = start_time(intervention)
    t <= 0.0 && return nothing
    intervention_time(intervention, individual) < t && reset!(intervention, individual)
    return nothing
end

Writing a custom intervention

Custom interventions are defined as structs subtyping AbstractIntervention. One or more of the following methods should be implemented:

  • initialise_individual! — set up fields on new contacts

  • resolve_individual! — determine state before transmission

  • apply_post_transmission! — act on contacts after creation

To support start_time scheduling, also implement:

  • start_time — return the intervention's policy start time

  • intervention_time — return the time at which the effect occurs for an individual

  • reset! — undo the effect if it falls before start_time

Reference: the built-in Isolation intervention

Before writing your own, it helps to see a complete built-in example. The source code of Isolation is shown below via CodeTracking.jl, so it always reflects the current implementation:

julia
print(@code_string EpiBranch.resolve_individual!(iso, state.individuals[1], state))
function resolve_individual!(iso::Isolation, individual, state)
    is_isolated(individual) && return nothing
    is_asymptomatic(individual) && return nothing
    !is_test_positive(individual) && return nothing

    iso_delay = rand(state.rng, iso.delay)
    iso_time = onset_time(individual) + iso_delay

    # If contact tracing has already computed a traced isolation time,
    # take the earlier of self-reporting and tracing
    traced_time = get(individual.state, :traced_isolation_time, Inf)
    set_isolated!(individual, min(iso_time, traced_time))
    return nothing
end
julia
# A gathering limit that caps the number of contacts per individual
struct GatheringLimit <: AbstractIntervention
    max_contacts::Int
end

function EpiBranch.apply_post_transmission!(gl::GatheringLimit, state, new_contacts)
    # Count contacts per parent, mark excess as not infected
    parent_counts = Dict{Int, Int}()
    for c in new_contacts
        count = get(parent_counts, c.parent_id, 0) + 1
        parent_counts[c.parent_id] = count
        if count > gl.max_contacts
            c.state[:infected] = false
        end
    end
end

# Test it
gl = GatheringLimit(5)
rng = StableRNG(42)
results_gl = simulate_batch(
    BranchingProcess(NegBin(2.5, 0.16), Exponential(5.0)), 200;
    interventions = [gl],
    sim_opts = SimOpts(max_cases = 500),
    rng = rng,
)
println("With gathering limit (max 5): $(round(containment_probability(results_gl), digits=3))")
With gathering limit (max 5): 0.82