Skip to content

Line lists and contacts

linelist gives you a DataFrame with one row per infected case. The core columns (id, parent_id, generation, chain_id, date_infection) are always there; anything else on the individual — typed fields or state dict entries — shows up as a column too. Keys ending in _time become date columns, so :onset_time becomes date_onset.

To get a new column, write the field during the simulation. Whatever ends up on state ends up in the DataFrame.

Line list

A simulation state is converted to a DataFrame with one row per infected case using linelist:

julia
using EpiBranch
using Distributions
using DataFrames
using Dates
using StableRNGs

attrs = clinical_presentation(incubation_period = LogNormal(1.5, 0.5))

progression = [
    Reporting(delay = Exponential(3.0)),
    Hospitalisation(delay = Exponential(5.0), probability = 0.2),
    Death(delay = Exponential(14.0), probability = 0.05),
    Recovery(delay = Exponential(14.0)),
]

model = ModelSpec(BranchingProcess(NegBin(1.5, 0.5), LogNormal(1.6, 0.5));
    progression = progression, attributes = attrs)

rng = StableRNG(42)
state = simulate(model; condition = 50:200, max_cases = 200, rng = rng)

ll = linelist(state; reference_date = Date(2024, 1, 1))
first(ll, 5)
5×16 DataFrame
Rowidparent_idgenerationchain_iddate_infectiondate_admissiondate_death_candidatedate_onsetdate_outcomedate_recovery_candidatedate_reportingadmittedasymptomaticincubation_periodoutcomereported
Int64Int64Int64Int64DateDate?Date?Date?Date?Date?Date?BoolBoolFloat64String?Bool
110012024-01-01missingmissing2024-01-062024-01-102024-01-102024-01-12falsefalse5.96391recoveredtrue
221112024-01-02missingmissing2024-01-042024-01-132024-01-132024-01-04falsefalse1.86305recoveredtrue
331112024-01-09missingmissing2024-01-112024-01-272024-01-272024-01-24falsefalse2.51621recoveredtrue
441112024-01-102024-01-15missing2024-01-152024-02-012024-02-012024-01-21truefalse4.74611recoveredtrue
551112024-01-052024-01-14missing2024-01-112024-01-112024-01-112024-01-21truefalse5.58373recoveredtrue

Columns appear only when the relevant state keys are set. Drop the Hospitalisation transition and date_admission disappears from the output. Drop clinical_presentation and date_onset, date_reporting, date_admission, date_outcome and outcome all disappear — the transitions can't anchor on a missing onset.

Demographics

Demographics are an attribute, set at simulation time via the demographics builder. They appear in the line list as age and sex columns:

julia
attrs_demo = [
    clinical_presentation(incubation_period = LogNormal(1.5, 0.5)),
    demographics(age_distribution = Normal(40, 15), prob_female = 0.55),
]

model = ModelSpec(BranchingProcess(NegBin(1.5, 0.5), LogNormal(1.6, 0.5));
    progression = progression, attributes = attrs_demo)

rng = StableRNG(42)
state = simulate(model; condition = 50:200, max_cases = 200, rng = rng)

ll = linelist(state; reference_date = Date(2024, 1, 1))
println("Age range: $(minimum(ll.age)) - $(maximum(ll.age))")
println("Female: $(round(count(==("female"), ll.sex) / nrow(ll) * 100, digits=1))%")
Age range: 7 - 78
Female: 54.0%

Age-stratified risks

Age-conditional case fatality risk is expressed as a closure on the Death transition's probability, reading ind.state[:age]:

julia
attrs_demo = [
    clinical_presentation(incubation_period = LogNormal(1.5, 0.5)),
    demographics(age_distribution = Uniform(0, 90)),
]

cfr_by_age = ind -> begin
    age = ind.state[:age]
    age <= 14 ? 0.001 : age <= 64 ? 0.01 : 0.15
end

age_stratified = [
    Death(delay = Exponential(14.0),
        probability = (rng, ind) -> cfr_by_age(ind)),
    Recovery(delay = Exponential(14.0)),
]

model = ModelSpec(BranchingProcess(NegBin(1.5, 0.5), LogNormal(1.6, 0.5));
    progression = age_stratified, attributes = attrs_demo)

rng = StableRNG(42)
state = simulate(model; condition = 100:500, max_cases = 500, rng = rng)

ll = linelist(state; reference_date = Date(2024, 1, 1))

for (lo, hi) in [(0, 14), (15, 64), (65, 90)]
    group = filter(r -> lo <= r.age <= hi, ll)
    n_died = count(==("died"), group.outcome)
    pct = nrow(group) > 0 ? round(n_died / nrow(group) * 100, digits=1) : 0.0
    println("Age $lo-$hi: $(nrow(group)) cases, $n_died deaths ($pct%)")
end
Age 0-14: 18 cases, 0 deaths (0.0%)
Age 15-64: 67 cases, 0 deaths (0.0%)
Age 65-90: 32 cases, 5 deaths (15.6%)

The same closure pattern covers risk groups, comorbidities, or any state field set by your attributes function. See the transitions tutorial for the full menu.

The whole population

linelist gives cases only by default. Pass infected_only = false to get every individual in the population, as needed for a test-negative design, an attack rate by covariate, or an exposed/unexposed comparison. It is most useful for a structure-driven model such as HomogeneousProcess, NetworkProcess or HouseholdProcess, whose population exists in full from the start:

julia
pool = ModelSpec(HomogeneousProcess(; transmission_rate = 0.6, population_size = 200);
    progression = [Transition(:recovered; from = :infection, delay = Exponential(5.0),
        terminal = true)],
    attributes = attrs)

pool_state = simulate(pool; n_initial = 2, rng = StableRNG(1))

pop = linelist(pool_state; reference_date = Date(2024, 1, 1), infected_only = false)
println("Population: $(nrow(pop)), infected: $(count(pop.infected))")
first(pop, 5)
5×14 DataFrame
Rowidparent_idgenerationchain_idinfecteddate_infectiondate_onsetdate_outcomedate_recoveredasymptomaticincubation_periodindexoutcomerecovered
Int64Int64Int64Int64BoolDate?Date?Date?Date?BoolFloat64Bool?String?Bool?
11121102true2024-01-172024-01-222024-01-182024-01-18false4.70786falserecoveredtrue
22002true2024-01-012024-01-032024-01-052024-01-05false2.93948truerecoveredtrue
331354125true2024-01-092024-01-122024-01-122024-01-12false3.9161falserecoveredtrue
44004falsemissingmissingmissingmissingfalse6.28259missingmissingmissing
55128112true2024-01-152024-01-202024-01-292024-01-29false5.07062falserecoveredtrue

On an offspring-driven model such as BranchingProcess the rows are the cases plus every contact they exposed who was not infected.

An uninfected row has missing for date_infection and for every date that follows from an infection: date_onset, reporting, admission and outcome dates, and any date from your own _time fields. The dates of events that happen to a person whether or not they are infected are kept:

  • date_trace, when the contact was traced;

  • date_vaccination and date_immunity;

  • date_isolation, when it is a quarantine on tracing. An isolation that Isolation derived from the contact's provisional onset is missing, and if it replaced an earlier quarantine the quarantine's date is shown.

Columns that are not dates, such as asymptomatic, traced or vaccinated, are reported as stored.

Contacts table

All contacts (infected and non-infected) are returned by contacts, with an infected flag:

julia
ct = contacts(state; reference_date = Date(2024, 1, 1))
println("Total: $(nrow(ct)), Infected: $(count(ct.infected)), Not infected: $(count(.!ct.infected))")
first(ct, 5)
5×6 DataFrame
Rowfromtoinfectedgenerationinfection_timedate_infection
Int64Int64BoolInt64Float64Date
112true19.244272024-01-10
213true19.00232024-01-10
314true16.131822024-01-07
415true14.354522024-01-05
526true212.00852024-01-13

Conditioned simulation

Generate outbreaks of a specific size range:

julia
plain = ModelSpec(BranchingProcess(NegBin(1.5, 0.5), LogNormal(1.6, 0.5)); attributes = attrs)

rng = StableRNG(42)
state = simulate(plain; condition = 100:150, max_cases = 200, rng = rng)
println("Outbreak size: $(state.cumulative_cases) (target: 100-150)")
Outbreak size: 104 (target: 100-150)