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:
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.04With 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:
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.15Effectiveness depends on how quickly isolation happens relative to the generation time. Faster isolation truncates more of the infectious period:
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
endDelay ~ Exp(0.5): containment = 0.25
Delay ~ Exp(2.0): containment = 0.15
Delay ~ Exp(10.0): containment = 0.11Leaky isolation
With post_isolation_transmission > 0, isolated individuals still transmit at a reduced rate (e.g. household contacts):
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.165Contact tracing
Contacts of isolated cases are identified using ContactTracing. With quarantine, traced contacts are isolated before symptom onset:
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.29Asymptomatic 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:
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.145Ring 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):
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.29A delay between vaccination and protective immunity can be specified. If transmission occurs before immunity develops, there is no protection:
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.29We can also count the number of vaccine doses administered:
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: 200Post-exposure prophylaxis
For PEP (antivirals or antibiotics given to traced contacts), use RingVaccination with delay_to_immunity = 0.0 (the default):
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.29Effort tracking
Because all contacts are stored (infected and non-infected), intervention effort is fully trackable:
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.6Time-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.
# 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.08Someone 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:
# 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:
# 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.19Conditions can be combined:
# 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:
# 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 beforestart_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:
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
endWriting 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 contactsresolve_individual!— determine state before transmissionapply_post_transmission!— act on contacts after creation
To support start_time scheduling, also implement:
start_time— return the intervention's policy start timeintervention_time— return the time at which the effect occurs for an individualreset!— undo the effect if it falls beforestart_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:
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# 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