Priors and latent submodels
Turing submodels for the epidemiological processes and the surveillance parameters: the delays, the generation interval, the reproduction number walk, the case-fatality ratio, ascertainment, laboratory behaviour and the isolation pathway. Each is a named submodel so it can be swapped or fitted on its own.
Index
BVDOutbreakSize.background_cfr_modelBVDOutbreakSize.background_pooling_modelBVDOutbreakSize.background_split_modelBVDOutbreakSize.background_walk_modelBVDOutbreakSize.bed_capacity_walk_modelBVDOutbreakSize.censored_delay_modelBVDOutbreakSize.cfr_modelBVDOutbreakSize.confirmed_overdispersion_modelBVDOutbreakSize.death_ascertainment_modelBVDOutbreakSize.death_testing_fraction_modelBVDOutbreakSize.death_testing_scaling_modelBVDOutbreakSize.exponential_growth_modelBVDOutbreakSize.gamma_delay_modelBVDOutbreakSize.generation_interval_modelBVDOutbreakSize.genetic_seeding_modelBVDOutbreakSize.independent_ascertainment_modelBVDOutbreakSize.infection_modelBVDOutbreakSize.isolation_admission_modelBVDOutbreakSize.isolation_severity_modelBVDOutbreakSize.lab_delay_modelBVDOutbreakSize.nejm_onset_to_sampleBVDOutbreakSize.onset_incidence_modelBVDOutbreakSize.onset_to_death_modelBVDOutbreakSize.patch_capacity_share_modelBVDOutbreakSize.patch_infection_modelBVDOutbreakSize.patch_rt_modelBVDOutbreakSize.pooled_ascertainment_modelBVDOutbreakSize.pooled_dispersion_modelBVDOutbreakSize.province_export_pressure_modelBVDOutbreakSize.recovery_probability_modelBVDOutbreakSize.rt_walk_modelBVDOutbreakSize.severity_enrichment_modelBVDOutbreakSize.specimen_intensity_modelBVDOutbreakSize.surveillance_dispersion_modelBVDOutbreakSize.test_positivity_modelBVDOutbreakSize.test_sensitivity_modelBVDOutbreakSize.test_specificity_modelBVDOutbreakSize.traveller_volume_model
Reference
BVDOutbreakSize.background_cfr_model Method
background_cfr_model(
;
cfr_prior
) -> DynamicPPL.Model{typeof(background_cfr_model), (), (:cfr_prior,), (), Tuple{}, Tuple{Distributions.Beta{Float64}}, DynamicPPL.DefaultContext, false}Background case-fatality ratio cfr_bg for the non-BVD suspected-death background (deaths_model). The per-day background suspected deaths are cfr_bg · λ_bg_v, a background CFR applied to the per-day non-BVD suspected-case rate (test_positivity_model). A non-BVD suspected case (other severe febrile or haemorrhagic illness that meets the suspect definition) carries its own fatality risk, and cfr_bg is the share of that background pool reported as a suspected death.
Tying the death background to the case background rather than giving deaths a free rate of their own removes the degeneracy that keeps a free λ_bg_death switched off. The case background is pinned by the laboratory positivity link, so scaling it by cfr_bg gives the death background a level and time profile without a second free rate competing with outbreak size. The default Beta(2, 18) (mean ≈ 0.10, 90% ≈ 0.02–0.23) is weakly informative and sits well below the BVD CFR (Beta(6.6, 13.4), mean ≈ 0.33), since non-BVD suspect illness is less lethal. Pass cfr_prior to override. Returns (; cfr_bg).
BVDOutbreakSize.background_pooling_model Method
background_pooling_model(
;
pooling_prior
) -> DynamicPPL.Model{typeof(background_pooling_model), (), (:pooling_prior,), (), Tuple{}, Tuple{Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}}, DynamicPPL.DefaultContext, false}Shared pooling SD σ_bg for the non-BVD background walk (background_walk_model). Sampled once at the composer level and passed to the suspected-case background, whose level the suspected-death background inherits, so one time-variation scale serves both streams. The prior is a half-normal of scale 0.3.
The background is degenerate with outbreak size, so the prior still regularises rather than freeing the scale. A wide random effect would let individual windows absorb arbitrary suspected counts and re-open the posterior mode in which the background explains the majority of suspected cases, and regularising σ_bg toward zero keeps the time variation a perturbation of the informative scalar baselines.
The scale was 0.1, which the data have outgrown. With the daily new-suspect series carried to the cut-off the posterior sits at 0.17 to 0.22, about twice that scale, and the walk mixes badly against a prior pulling the other way. At 0.3 the same posterior sits below the scale, so the data set the time variation rather than the prior. Returns (; σ_bg).
BVDOutbreakSize.background_split_model Method
background_split_model(
n_patches::Integer;
populations,
pooling_sd_prior,
offset_prior,
basis
) -> AnyPartially pooled split of the non-BVD suspected-case background across the patches. The national background walk bg_daily (reported_cases_model) counts suspects who are not BVD cases and carries no province, so the patch model needs a share of it per patch to build a per-patch suspect pipeline for the laboratory and isolation streams. Each share is the patch's population share moved by a pooled log deviation,
normalised to sum to one. Q is the sum-to-zero basis (sum_to_zero_basis), so the n_patches - 1 free deviations leave the simplex no redundant direction and every patch has the same prior, with no reference patch. τ_bg → 0 recovers the population split. The per-province analysed-specimen composition in bvd_joint identifies the shares, since the background dominates the specimens analysed where positivity is low.
With one patch the whole background belongs to it and nothing is sampled.
Returns (; w, pooling_sd).
BVDOutbreakSize.background_walk_model Method
background_walk_model(
n::Integer,
σ_rw::Real;
onset,
onset_ramp,
week,
baseline_prior,
centred,
cutoff
) -> AnyNon-BVD background rate as a smooth weekly lognormal random walk over the surveillance window. The log-rate follows a non-centred random walk on weekly knots and is linearly interpolated to the daily grid, the same parameterisation as the reproduction-number walk (rt_walk_model). The background is a slow drift, so a knot per week carries the time variation with far fewer innovations than a daily walk. The series is gated to zero before the surveillance onset, since the non-BVD background does not exist before surveillance began, and ramps in over the first onset_ramp days of the window. With knot values \log\lambda and knot days d,
σ_rw is the per-knot innovation SD on the log scale, passed in and shared across the suspected-case and suspected-death streams via background_pooling_model. Its regularising prior keeps the background a slow drift, which holds down the background/outbreak-size degeneracy and keeps the series smooth, so a death background scaled from it carries no steps.
The default is the centred form, each knot step drawn directly at Normal(0, σ_rw). The daily new-suspect series runs to the cut-off, so the walk is strongly informed, and the non-centred form steps = σ_rw .* z funnels, with z diverging as σ_rw → 0 and stretching NUTS trajectories. That funnel is what broke the joint fit when the series resumed. The worst R-hat went to 1.6 with 5 bulk effective samples, against 1.05 and 65 on the vintage before.
The funnel shows in the draws. Divergences concentrate at the small end of σ_rw, at a median of 0.147 against 0.185 over all draws, and the correlation between σ_rw and its own innovations doubles, mean absolute 0.159 to 0.323. The walk fails along its whole length rather than at its newly informed tail. R-hat runs 1.11 to 1.60 across the knots, against 1.00 to 1.01 before, and the weakest mixing sits in the middle knots as much as the last. Pass centred = false for the non-centred form, the better choice when the walk is weakly informed and prior-dominated. Both forms carry the same prior, a cumulative sum of Normal(0, σ_rw) steps, so only the sampled coordinates differ. pooled_dispersion_model carries the same switch for the same reason.
Knots run only over the surveillance window [onset, n], so the number of innovations is small. onset ≤ 1 runs it over the whole grid. Pass week to change the knot spacing.
λ_mu ~ truncated(Normal(0, 20); lower = 0) is the scale the walk multiplies, not the level over the window. The log-deviation is pinned to zero on the first knot, so λ_mu anchors the window's start and the innovations carry the series from there. The half-normal shrinks that anchor toward zero, which stops the background out-explaining the outbreak signal, and the scale is wide enough not to truncate the anchor the suspected-case data support. Pass baseline_prior to override.
Returns (; λ, λ_mu, σ_bg) with λ the length-n daily series (zero before onset).
With a cutoff before the grid end the fitted knots end at cutoff and the walk continues past it on knots a week apart, with future steps drawn at the same scale.
BVDOutbreakSize.bed_capacity_walk_model Method
bed_capacity_walk_model(
n::Integer;
start,
week,
baseline_prior,
innovation_prior,
cutoff
) -> AnyTime-varying isolation/treatment-bed capacity over the daily grid, a multiplicative random walk: the supply-limited occupancy stream (treatment_flow_model) uses C(t) as the ceiling the latent bed demand saturates against on each day. Capacity is not fixed (beds are being added: SitRep 030 records mattress and bed deliveries and new treatment centres opening), so the walk tracks growth that a single scalar capacity cannot.
The walk is a centred cumulative log-deviation from a baseline bed count C0 on weekly knots, linearly interpolated to the daily grid. With knot values \log C and knot days d, C(t) = C0 · exp(\text{interp}(\text{cumsum}(\text{steps}))), each step drawn at the sampled innovation SD σ_cap from a half-normal, keeping capacity a gentle drift rather than per-day jumps. Knots need far fewer innovations than a daily walk, so the walk stays low-dimensional.
Centred, unlike the reproduction-number and background walks. The two forms are the same distribution: a standard half-normal scaled by σ_cap is a half-normal at σ_cap. They differ only in the geometry NUTS explores. Non-centring pays off when the prior dominates the walk, and it costs when the data pin it, since the sampled scale and the standard offsets then have to move together. Here the data pin it. On the 16 September 2026 joint fit the innovations had lost about 90% of their prior variance, and σ_cap sat at 0.11 against a prior mean of 0.04, past the prior 95th percentile and with about half its spread. That is the regime the centred form suits. The baseline carries the same weakly-informative LogNormal(log 450, 0.42) prior as the scalar model (median 450 beds, ≈0.44 CV), so C0 is sampled on the log scale and the whole capacity log C(t) = log C0 + walk is fully log-scale. The implied-capacity series the isolation submodel fits pins C(t) on the days a rate is published.
Knots run only from start, the first day with occupancy or capacity data, and capacity is flat at C0 before it. Off-window capacity carries no likelihood, so walking it there would add unidentified innovations. Pass start = 1 for knots over the whole grid, or week to change the knot spacing. A single national capacity cannot represent local saturation, one province full while another has slack. Pass baseline_prior / innovation_prior to override. Returns (; C, C0, σ_cap) with C a length-n vector.
With a cutoff before the grid end the fitted knots end at cutoff and the walk continues past it on knots a week apart, with future steps drawn at the same scale.
BVDOutbreakSize.censored_delay_model Method
censored_delay_model(nmax::Integer; mean_prior, sd_prior)Generic delay submodel parameterised by mean and SD, discretised to a daily PMF over lags 0 … nmax by double interval censoring of a moment-matched LogNormal (see lognormal_meansd and discretise_censored). The LogNormal CDF differentiates cleanly under Mooncake, so this is the AD-safe discretisation route for every delay in the renewal convolutions. The mean and SD carry weakly-informative priors, so the delay is estimated rather than fixed. Returns (; pmf, dist, mean, sd).
BVDOutbreakSize.cfr_model Method
cfr_model(
;
cfr_prior
) -> DynamicPPL.Model{typeof(cfr_model), (), (:cfr_prior,), (), Tuple{}, Tuple{Distributions.Beta{Float64}}, DynamicPPL.DefaultContext, false}Case-fatality ratio prior. Default Beta(6.6, 13.4) has mean ≈ 0.33, matching the CDC summary for past BVD outbreaks. Used by the deaths and deaths-among-exports streams.
BVDOutbreakSize.confirmed_overdispersion_model Method
confirmed_overdispersion_model(
;
overdispersion_prior
) -> DynamicPPL.Model{typeof(confirmed_overdispersion_model), (), (:overdispersion_prior,), (), Tuple{}, Tuple{Distributions.Beta{Float64}}, DynamicPPL.DefaultContext, false}Confirmed-positives overdispersion prior. The confirmed positives in each laboratory window are scored as an overdispersed BetaBinomial of the observed analysed denominator (see safe_betabinomial and confirmed_cases_model). The per-window positivity p_pos is a smooth curve that does not capture the day-to-day laboratory batching and within-window positivity heterogeneity the confirmed counts carry, and a plain Binomial on denominators of several hundred specimens gives predictive intervals far too tight. The intra-window correlation ρ ∈ (0, 1) inflates the window variance to n·p·(1 − p)·(1 + (n − 1)·ρ), with ρ → 0 recovering the Binomial. The default Beta(1, 24) (mean ≈ 0.04, 90% ≈ 0.002–0.12) is weakly informative and shrinks toward the Binomial when the data support it. One scalar is identified across the laboratory windows, so the confirmed positives themselves set the spread. Returns (; ρ).
BVDOutbreakSize.death_ascertainment_model Method
death_ascertainment_model(
;
ascertainment_prior
) -> DynamicPPL.Model{typeof(death_ascertainment_model), (), (:ascertainment_prior,), (), Tuple{}, Tuple{Distributions.Normal{Float64}}, DynamicPPL.DefaultContext, false}Ascertainment of the suspected-death stream (deaths_model), the fraction p_death of true BVD deaths that enter the INSP suspected-death count by the cut-off. The suspected-death definition is symptomatic-then-deceased, so a fatal BVD infection only counts once the death is reported, and not every BVD death is captured. The expected BVD suspected deaths are p_death · CFR of the onset-to-death-convolved infections, the death analogue of the suspected-case ascertainment p_drc (pooled_ascertainment_model).
The default Normal(logit(0.9), 0.5) on the logit scale is informative and centred on a high ascertainment, since a death is a salient event in an Ebola response and is reported more reliably than a living suspected case (p_drc ≈ 0.75). The SD 0.5 gives a 90% prior interval of roughly 0.80–0.95. p_death is weakly identified on its own, trading off with the CFR for the suspected-death level, so it leans on this prior. The export-death stream and the CFR prior pin the CFR separately. Pass ascertainment_prior to override. Returns (; p_death, logit_p_death).
BVDOutbreakSize.death_testing_fraction_model Method
death_testing_fraction_model(
;
fraction_prior
) -> DynamicPPL.Model{typeof(death_testing_fraction_model), (), (:fraction_prior,), (), Tuple{}, Tuple{Distributions.Beta{Float64}}, DynamicPPL.DefaultContext, false}Death testing fraction τ_death, the fallback for the death-only composer (confirmed_deaths_only_model), which has no case stream to set the death testing volume from. It thins the suspected deaths to a death "analysed" volume at the case testing rate, drawing τ_death from the same prior as the case testing fraction (Beta(5, 2), mean ≈ 0.71). The full joint instead scales the modelled case analysed volume (see confirmed_deaths_model and death_testing_scaling_model) and does not draw this submodel. Pass fraction_prior to override. Returns (; τ_death).
BVDOutbreakSize.death_testing_scaling_model Method
death_testing_scaling_model(
;
scaling_prior
) -> DynamicPPL.Model{typeof(death_testing_scaling_model), (), (:scaling_prior,), (), Tuple{}, Tuple{Distributions.LogNormal{Float64}}, DynamicPPL.DefaultContext, false}Death testing-intensity scaling for the confirmed-death volume in the joint (confirmed_deaths_model). The death analysed volume is the modelled case analysed volume carried at the per-day suspected death-to-case ratio, times this scaling. That ratio already carries the suspect-pool severity and the suspected-death level, so the scaling is the per-suspect testing-intensity difference between deaths and living suspects alone. No death-testing data grounds it, so it is a tight log-normal centred on one (LogNormal(0, 0.25), median 1, 90% ≈ 0.66–1.51). Deaths are tested at the case intensity unless the confirmed-death counts pull the scaling off one. Pass scaling_prior to override. Returns (; scaling).
BVDOutbreakSize.exponential_growth_model Method
exponential_growth_model(
g::AbstractVector;
r_prior,
m_prior
) -> AnyMolecular-clock growth-and-size prior for the renewal cryptic phase. Samples the exponential growth rate r (the primary epidemiological assumption, placed on the genetic doubling time) and the generation count m, then exposes
as deterministics, with G the mean generation interval. T = m·G is the cryptic-phase duration (origin → renewal start). m counts the transmission generations during the cryptic phase, and a single import grows over them to a daily incidence C_T at the renewal start. The composer (infection_model) adds the observation span τ_obs = n − renewal_start to get the total outbreak age T_total = m·G + τ_obs, which carries the genetic seeding bound (genetic_seeding_model).
The growth rate carries the prior r ~ LogNormal(log(log2 / M_PRIOR_DOUBLING_DAYS), 0.30), with median doubling time (11.7 d) matching the BEAST X estimate (mbalaplacide2026, exponential growth model, 95% HPD 6.8–17.5). The log-SD 0.30 is a quarter wider than the ≈0.24 that HPD implies, because the HPD is conditional on a single-rate coalescent, which the field epidemiology contradicts (kupferschmidt2026). The earlier ten-genome reanalysis the BEAST X rate supersedes put the doubling time at 15.2–24.5 d (cuomodannenburg2026). The induced doubling-time prior is LogNormal(log 11.7, 0.30), 6.5–21.1 d at 95%, which contains the HPD and reaches into that earlier range. The first reproduction number is derived forward from this r and our generation interval through Euler–Lotka (R0 = r_to_R0(r, g) in infection_model), so the cryptic exponential phase and the established renewal share one growth source.
m ~ truncated(Normal(2.75, 1.2); lower = 0) counts the transmission generations between the index infection and the renewal start, so the origin sits T = m · G days back and the cryptic phase grows one infection per day there to C_T = exp(r · T) per day at the renewal start.
The centre puts the origin in mid-February 2026, 2.75 generation intervals before the renewal start. The 90% prior origin runs mid-January to mid-March, so the traced 25 January 2026 index death (kupferschmidt2026) sits at about the 87th percentile rather than at the centre. It is the earliest chain field work reached, which bounds the origin rather than dating it. The genetic TMRCA (mbalaplacide2026) is a lower bound consistent with an origin that early. The 99th percentile seed is about 150 infections per day, against a fitted outbreak of order ten thousand in total.
In the renewal, C_T is the prior seed at the renewal start, which the renewal recursion grows forward under R_t. Pass m_prior to override. Returns (; τ, r, m, T, C_T, G).
BVDOutbreakSize.gamma_delay_model Method
gamma_delay_model(nmax::Integer; alpha_prior, theta_prior)Natural-parameter Gamma delay submodel. Samples a Gamma shape α and scale θ directly from the priors, builds Gamma(α, θ), and discretises to a daily PMF over lags 0 … nmax by double interval censoring (discretise_censored), keeping the lag-0 bin, since an onset-to-event delay can be same-day unlike the generation interval. This carries a line-list delay reanalysis through on its natural parameters with the reported posterior uncertainty. Returns (; pmf, dist, mean, sd, alpha, theta).
BVDOutbreakSize.generation_interval_model Method
generation_interval_model(
nmax::Integer;
mean_prior,
sd_prior
) -> AnyGeneration-interval submodel, on the Gamma mean and SD. The source is the Ebola virus disease serial interval as a generation-time proxy (fitted gamma, mean 15.3 d, SD 9.3 d, n = 92 pairs; WHO Ebola Response Team 2014, NEJM, Table 2). The source gives no interval on either, so each prior SD is the sampling standard error of that estimate from 92 pairs: 9.3 / √92 ≈ 0.97 d for the mean, and ≈ 1.0 d for the SD (the normal approximation for a sample SD, with the Gamma's excess kurtosis 6/α). The priors are gi_mean ~ Normal⁺(15.3, 0.97), 13.4–17.2 d at 95%, and gi_sd ~ Normal⁺(9.3, 1.0), 7.3–11.3 d, drawn independently. The Gamma shape α = (mean/sd)² and scale θ = sd²/mean follow from them.
Discretised through the same double-interval-censoring route as the other delays (discretise_censored). The lag-0 bin is dropped and the remainder renormalised, left-truncating the generation interval at one day so an infectee is infected strictly after its infector. Returns (; g, gi_mean, gi_sd, gi_alpha, gi_theta).
BVDOutbreakSize.genetic_seeding_model Method
genetic_seeding_model(
T::Real,
tmrca_days::Union{Missing, Real};
tmrca_days_sd
) -> AnyOne-sided molecular-clock seeding bound on the outbreak age T (see infection_model). The TMRCA is treated as a right-censored, noisy reading of the seeding time, so deeper or wider sampling only pushes it older. The likelihood contributes P(read ≥ tmrca_days). tmrca_days = missing makes the submodel a no-op.
BVDOutbreakSize.independent_ascertainment_model Method
independent_ascertainment_model(
;
drc_prior,
uganda_prior
) -> DynamicPPL.Model{typeof(independent_ascertainment_model), (), (:drc_prior, :uganda_prior), (), Tuple{}, Tuple{Distributions.Normal{Float64}, Distributions.Normal{Float64}}, DynamicPPL.DefaultContext, false}Independent ascertainment fractions for the DRC and Uganda surveillance systems. The two countries run different systems, DRC passive community surveillance and Uganda point-of-entry or hospital detection, so each ascertainment fraction has its own logit-scale prior with no shared parameter. An alternative to the composer-default pooled_ascertainment_model for sensitivity analyses that do not share strength between the two systems.
Both fractions default to a logit-Normal prior centred on a reporting fraction of 0.75 with SD 0.6 (95% support roughly 0.48–0.91), reflecting the active case-finding and contact tracing of a declared Ebola response rather than baseline passive surveillance. Pass drc_prior / uganda_prior to set the two systems' priors separately.
BVDOutbreakSize.infection_model Method
infection_model(
n::Integer;
breakpoint,
rt_start,
rt_walk_start,
rt,
gi,
growth,
gi_nmax,
population,
forecast
) -> AnyGenerating infection process for the two-phase renewal seeding. Samples the generation interval and the cryptic exponential growth rate r (the prior sits on r, the molecular-clock growth, in exponential_growth_model), derives the established reproduction number R0 (= the first R_t) forward from that r and the generation interval through Euler–Lotka (R0 = r_to_R0(r, g)), and passes log R0 as the walk base to the reproduction-number submodel. The cryptic phase and the established renewal therefore share one growth source.
The renewal runs only over the observation window [renewal_start, cut-off], where renewal_start is the genetic-TMRCA grid day rt_start (the day the reproduction-number walk starts, before which R_t is held flat). The cryptic exponential phase from the origin to the renewal start is analytic and off the renewal grid except for the days needed as recursion history. The seed at the renewal start is the daily incidence the cryptic phase reaches, C_T = e^{r·m·G} (seed_at_renewal_start), where m counts the transmission generations during the cryptic phase. The magnitude is referenced to the origin, so a larger r raises both the seed and the derived R0 and the two compound. A cut-off-referenced seed C_T e^{−r·τ_obs} would instead put r into the seed and the renewal growth in opposing directions, opening a flat ridge along which R0 slides to 1. The grid days 1 … renewal_start are filled smoothly by the cryptic exponential curve at rate r ending at C_T (seed_infections), giving the recursion a full generation interval of differentiable history. The renewal recursion (renewal_infections) then grows the trajectory over renewal_start+1 … n under the time-varying R_t, depleting a pool of population. The default is the summed 2019 INS resident population of the seven affected provinces (PROVINCE_SOURCE_POPULATIONS).
The total outbreak age is T = m·G + τ_obs (cryptic duration plus the observation span τ_obs = n − renewal_start). The genetic seeding bound is applied to this total T at the composer. The renewal start sits a small lead after the genetic TMRCA day, past the TMRCA uncertainty where sustained transmission is confident, so τ_obs < tmrca_days and the censored bound tmrca ~ censored(Normal(T, sd); upper = tmrca_days) stays informative. It pulls the origin to sit at or before the MRCA, so the cryptic duration m·G cannot be too short.
The realised cut-off size is C_T = cumulative[n]. The breakpoint is forwarded to the reproduction-number submodel. Returns (; infections, cumulative, Rt, g, seed_at_renewal_start, population, m, τ, R0, r0, r, doubling_time_initial, T, C_T, C_T_prior, doubling_time, seeding_age), where r/doubling_time are the current growth derived from the cut-off reproduction number net of depletion (adjusted_rt) through forward Euler–Lotka (so r is sign-consistent with that R_T by construction), r0 the cryptic rate implied by R0, and seeding_age is diagnostic only.
With forecast a ForecastHorizon the walk and the renewal run past the cut-off n and infections, cumulative and Rt cover the horizon. Every quantity named for the cut-off is still read at day n.
BVDOutbreakSize.isolation_admission_model Method
isolation_admission_model(
;
p_prior
) -> DynamicPPL.Model{typeof(isolation_admission_model), (), (:p_prior,), (), Tuple{}, Tuple{Distributions.Beta{Float64}}, DynamicPPL.DefaultContext, false}Base treatment-admission probability for the isolation-occupancy stream (treatment_flow_model). Samples p_iso, the fraction of ascertained suspected cases that are admitted to and retained in an isolation/treatment bed at the base (non-BVD rule-out) intensity, so the modelled bed occupancy is p_iso times the survival-convolution of the admission inflow. BVD suspects are admitted at a higher rate skewed up from this base by a severity log-odds (isolation_severity_model), since triage admits the sicker patients and BVD presents more severely.
The default Beta(2, 2) is weakly informative on (0, 1) with mean ½ and no mass piled at the bounds. p_iso is partially confounded with the length-of-stay mean for the occupancy level (Little's law, mean occupancy ≈ p_iso · admissions · (E[LOS] + 1)), so the length-of-stay prior carries the duration and p_iso absorbs the admission/retention fraction. The length-of-stay also sets the lag and smoothing of occupancy relative to the inflow, which the daily occupancy series identifies. Pass p_prior to override. Returns (; p_iso).
BVDOutbreakSize.isolation_severity_model Method
isolation_severity_model(
;
logodds_prior
) -> DynamicPPL.Model{typeof(isolation_severity_model), (), (:logodds_prior,), (), Tuple{}, Tuple{Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}}, DynamicPPL.DefaultContext, false}Severity skew for isolation admission (treatment_flow_model). Samples δ_iso ≥ 0, the log-odds by which a BVD suspect is more likely to be admitted to and retained in an isolation bed than a non-BVD rule-out at the same base intensity p_iso (isolation_admission_model), so the BVD admission probability is logistic(logit(p_iso) + δ_iso). Admission cannot condition on the unobserved BVD status of a suspect. The skew instead represents the net effect of severity-based triage, where the sicker patients are isolated and BVD presents more severely, enriching BVD among the admitted. The non-negative truncation keeps a BVD suspect at least as likely to be admitted as a rule-out.
The isolation stream observes only total occupancy, so the skew is weakly identified and the half-normal truncated(Normal(0, 0.75); lower = 0) carries most of the weight. δ_iso = 0 recovers a shared admission rate. Pass logodds_prior to override. Returns (; δ_iso).
BVDOutbreakSize.lab_delay_model Function
lab_delay_model(
;
...
) -> DynamicPPL.Model{typeof(lab_delay_model), (:nmax,), (:mean_prior, :sd_prior), (), Tuple{Int64}, Tuple{Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}, Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}}, DynamicPPL.DefaultContext, false}
lab_delay_model(nmax::Integer; mean_prior, sd_prior) -> AnyReport-to-laboratory-confirmation (lab-turnaround) delay submodel. The delay from a suspected case being reported to its specimen being laboratory confirmed, discretised to a daily PMF over lags 0 … nmax by censored_delay_model so it convolves cleanly onto the renewal onsets. The mean and SD carry weakly-informative priors centred on a short turnaround with a heavy right tail allowing for specimen shipment to a confirmatory lab. No per-sample outbreak data grounds this delay, so the likelihood does not identify the turnaround mean or SD. The priors are kept tight around the documented turnaround belief (mean ≈ 4.5 d, SD ≈ 4 d), since a wide prior on an unidentified nuisance delay only makes the sampler wander it, dragging the confirmation PMFs convolved from it. Returns (; pmf, dist, mean, sd).
BVDOutbreakSize.nejm_onset_to_sample Method
nejm_onset_to_sample(
;
mean,
mean_se,
median,
median_se
) -> @NamedTuple{mean_obs::Float64, mean_se::Float64, median_obs::Float64, median_se::Float64}Onset-to-sample prior configuration from the NEJM DRC 2026 BVD cohort (Akilimali et al., 2026). The confirmed-positive onset-to-sample interval (N = 129) was estimated as a continuous Gamma through the epidist marginal model (Abbott et al., 2024), correcting for double interval censoring and right truncation, chosen over lognormal and Weibull by LOOIC. The cohort reports a continuous mean of 7.4 d (95% CrI 5.3–13.5) and median of 4.8 d (95% CrI 3.46–7.84).
The confirmed onset→report→receipt convolution is grounded on its mean and median. The mean is the sum of the two legs' means and the variance the sum of their variances. The median follows by gamma_median_wh. Each is fitted to the reported value as a Normal observation whose SD is the reported 95% CrI half-width over 1.96 (mean_se, median_se), so the cohort's uncertainty enters directly and the constraint is soft. Returns a NamedTuple (; mean_obs, mean_se, median_obs, median_se) for the onset_to_sample argument of bvd_joint.
BVDOutbreakSize.onset_incidence_model Method
onset_incidence_model(
infections::AbstractVector;
incubation,
incubation_nmax
) -> AnyOnset-incidence submodel. Convolves the renewal infections with the sampled incubation-period PMF to get daily symptom-onset incidence, computed once per draw and reused by every downstream observation stream. The incubation delay submodel is injected. The incubation period cannot be fitted from the BDBV line list (no exposure dates), so it follows the MacNeil et al. (2010) Bundibugyo estimate from the 2007 Uganda outbreak, mean 6.3 d (95% CI 5.2-7.3, n = 24). The mean prior Normal(6.3, 0.54) reproduces that 95% CI (SD = CI half-width / 1.96). MacNeil give no interval on the spread, so the SD prior is a weakly-informative choice centred on the CV-implied spread (≈ 3.5 d). Returns (; onsets, incubation_pmf, incubation_mean, incubation_sd).
BVDOutbreakSize.onset_to_death_model Method
onset_to_death_model(
nmax::Integer;
oa_alpha_prior,
oa_theta_prior,
ad_alpha_prior,
ad_theta_prior
)Onset-to-death delay as the convolution of two natural-parameter Gamma atomic delays, onset→admission (oa) and admission→death (ad), each sampled through gamma_delay_model and combined by convolving their PMFs. This matches the companion line-list reanalysis, which fits the atomic components rather than onset→death directly, so each atomic delay keeps its own Gamma shape and scale prior with the reanalysis's reported uncertainty. The convolved PMF is truncated back to lags 0 … nmax and renormalised. Returns (; pmf, mean, sd, oa_mean, ad_mean).
BVDOutbreakSize.patch_capacity_share_model Method
patch_capacity_share_model(
admissions::AbstractMatrix{<:Real};
admission_floor,
pooling_sd_prior,
offset_prior,
basis
) -> AnyPer-patch shares of the national isolation-bed capacity, for the province bed and occupancy splits in treatment_flow_model. Each patch's capacity on day t is the national walk C(t) times a share centred on the patch's modelled cumulative admissions to that day,
normalised over the patches each day. Q is the sum-to-zero basis (sum_to_zero_basis), so the deviations sum to zero and every patch has the same prior, with no reference patch. A shift shared by every patch cancels in the normalisation, so the constraint removes only that direction. A_p(t) sums the patch's latent admissions into isolation, BVD and background, over days 1, …, t. It is the admissions matrix (n_patches × n) of daily admissions, accumulated here.
Beds are allocated in response to cases, so the centre follows where the model sends patients. The share moves over time through the centre alone, at no extra parameter cost, and the deviations δ_p are static. The floor a_0 (admission_floor, one admission by default) keeps a patch with no admissions yet from being given no capacity, so early shares sit near an equal split.
The pooling scale τ_cap is the typical log-ratio between a patch's share of beds and its share of admissions to date, before the deviations are centred. Its half-normal prior with scale 1 puts a two-fold departure well inside the prior and a seven-fold one near its edge. The centre already carries the large gap between beds and population, so the deviations left for the pool are modest.
The cumulative admissions differ from the stock that splits the bed demand in treatment_flow_model. The stock counts the patients still in a bed and falls as they leave. Cumulative admissions never fall, as the national walk never does, and they remember where the response has been sent since the start.
Returns (; s, pooling_sd), with s an (n_patches × n) matrix whose columns sum to one.
BVDOutbreakSize.patch_infection_model Method
patch_infection_model(
n::Integer,
n_patches::Integer;
breakpoint,
rt_start,
rt_walk_start,
rt,
gi,
growth,
gi_nmax,
importation_kernel,
importation_epsilon_prior,
importation_sd_prior,
importation_effect_prior,
seed_fraction_prior,
basis,
incubation,
incubation_nmax,
populations,
forecast
) -> AnyMulti-patch latent infection process. Runs a renewal equation per spatial patch (Ituri, Nord-Kivu, Sud-Kivu) with a shared generation interval, a shared incubation period, and an optional between-patch importation kernel.
Structure
For patch p on day t:
with g_s the shared generation-interval PMF (sampled once, the biology of transmission does not depend on province), R_{p,t} from patch_rt_model, K the importation kernel, and ε the importation intensity. Each patch depletes its own pool of populations[p] residents, as in patch_infections. The default is each patch's 2019 INS resident population (PROVINCE_POPULATIONS), or their sum for a single patch.
Importation
The default importation_kernel is the gravity kernel of province_importation_kernel, a fixed weighting by destination population, so the provinces are coupled and the intensity ε is sampled. There is no mobility or origin-destination data for this outbreak, so the kernel is a structural assumption, and ε is weakly identified against the secondary-patch seeds, since both raise a secondary province's early incidence. Read ε as the scale of coupling the data will tolerate rather than as a measured flow.
Passing an all-zero kernel uncouples the provinces. ε is then not sampled, since against a zero kernel it would be a dimension the likelihood never touches, and each secondary patch is explained by its own seed and its own R_t.
Seeding
The outbreak is assumed to have begun in Ituri, so the primary patch carries the whole cryptic seed, growing at the sampled molecular-clock rate r over the cryptic window to reach seed_at_renewal_start(C_T) at the renewal start. The secondary patches start empty and are seeded by importation from it, so a province's arrival is a consequence of the kernel and of ε rather than a parameter. The first-appearance dates carry real information about how fast the outbreak spread between provinces, and a free seed fraction per province would absorb exactly that, since both a larger seed and a stronger coupling raise a secondary province's early incidence.
An all-zero kernel leaves a secondary patch no route to infections at all, so the uncoupled path keeps the sampled fractions (seed_fraction_prior, a LogNormal on the fraction of the primary seed). They partition the national cryptic seed rather than adding to it, so the growth submodel's C_T stays the national daily incidence at the renewal start and comparable across any patch count.
Returns
The per-patch state, plus the national aggregates the observation models and the headline summaries consume: infections_total, cumulative_total, and C_T (the national cut-off cumulative). R_T, r, T and doubling_time mirror infection_model so a patch chain carries the same headline quantities as a single-patch one. R_T is the incidence-weighted aggregate reproduction number at the cut-off, obtained by inverting the renewal equation on the summed infections (implied_national_Rt_at) on the cut-off day alone. importation_matrix is the daily infections each province received from the others, which is what the imports figure on the analysis page draws.
With forecast a ForecastHorizon the reproduction numbers, the importation intensities and the renewal run past the cut-off n, and every daily matrix covers the horizon. The cut-off quantities stay at day n.
BVDOutbreakSize.patch_rt_model Method
patch_rt_model(
n::Integer,
n_patches::Integer,
log_R0_base::Real;
breakpoint,
week,
rt_start,
rt_walk_start,
rt,
region_sd_prior,
region_drift_sd_prior,
region_halflife_prior,
region_offset_prior,
basis,
forecast
) -> AnyReproduction numbers for the spatial patches (PROVINCE_NAMES): a common national trend plus per-patch deviations that are free to vary in space and in time, drawn from a multivariate-normal AR(1) process with a learned cross-patch correlation.
for the innovation η_k of the deviations at each of the same weekly knots as the national walk, linearly interpolated to the daily grid.
A common trend, not independent walks
μ(t) is the national weekly-knot walk (rt_walk_model), kept intact, so the national streams see exactly the Rt process the headline model fits and it is the target the provinces pool toward. The provinces are not equally observed: Ituri carries most of the laboratory positives and the pooled other patch few. Shrinking toward a common trend lets the sparse patches borrow strength from Ituri and deviate only where the data insist. With μ(t) present the deviation covariance Σ is still free, so the cross-patch correlation is learned rather than assumed.
Sum-to-zero, not a reference patch
The deviations sum to zero at every knot, so no province is privileged. Fixing δ_1 ≡ 0 would also identify the model, but it forces the primary patch to have no idiosyncratic deviation at all.
A sum-to-zero vector over n patches has n - 1 free directions, so the deviations are built on them directly. Q is the fixed orthonormal basis of that subspace (sum_to_zero_basis) and each knot's innovation is c Q A z with z ~ N(0, I_{n-1}). A is a lower-triangular Bartlett factor (bartlett_factor): its diagonal bartlett_diag[j] ~ Chi(ν - j + 1) and its strictly lower entries bartlett_lower ~ N(0, 1), so A Aᵀ ~ Wishart(ν, I), with ν = n - 1. A sets only the shape: c = σ_drift √((n - 1) / tr(A Aᵀ)), so the innovation covariance c² Q A Aᵀ Qᵀ has trace σ_drift² (n - 1) and σ_drift ~ region_drift_sd_prior sets its size. Together they are a full covariance of the sum-to-zero vector with its n (n - 1) / 2 free parameters, and σ_drift → 0 is reachable. The Wishart prior is invariant under rotations of the basis, so the implied prior is the same for every patch and every pair of patches whatever order the patches come in. The level at the first knot is σ_level Q A z_level √((n - 1) / tr(A Aᵀ)), sharing the drift's shape at its own scale. With the draws z_k as the columns of Z, every knot's innovation comes from the one product c Q A Z.
The per-patch innovation sds σ_δ and their n × n correlation Ω are derived from the loading matrix (sum_to_zero_moments). The correlations of a sum-to-zero vector are constrained: its entries cannot all be positively correlated, and with equal sds each patch's correlations with the others average -1 / (n - 1). With three patches the three sds determine the three correlations exactly, so whether the correlation is needed is the same question as whether the sds differ. With more patches the correlation carries information the sds do not.
What the data can and cannot identify here
The composition of the confirmed cases identifies the contrast between provinces. The pooled other patch carries little signal. Expect Ω to be largely prior-driven and that patch's Rt to be pinned mostly by the deviation prior rather than by data, which is why Σ is given a proper shrinkage prior rather than a flat one.
σ_drift → 0, and with it every σ_δ, recovers a common Rt shape shared by every province, a fixed ratio between them. It is a special case of this model rather than an assumption baked into it. σ_δ is therefore the headline spatial diagnostic, and a posterior pushed away from zero is evidence that provincial Rt trajectories are separating, which is what a response concentrated on the Ituri epicentre would produce.
Mean reversion, not a random walk
The deviations mean-revert to zero rather than random-walk. A random walk has no mean, so a province that happens to sit above the national trend at the last vintage is projected to stay above it for ever with the gap as likely to widen as to close. That matters here because the per-province vintages stop well before the cut-off. The knots therefore follow
with h the sampled half-life of a provincial divergence in days, so a province with persistent divergence can still show one. The innovations sum to zero at every knot and φ is one shared scalar, so the sum-to-zero constraint survives exactly. A half-life far longer than the window recovers the random walk.
Returns the Rt matrix (n_patches × n), the national trend, the full deviation trajectory δ_patch (n_patches × n), the per-patch innovation sds σ_δ, their correlation matrix Ω and the loading matrix drift_factor (n_patches × (n_patches - 1)) that maps standard-normal draws to one knot's innovations.
With forecast a ForecastHorizon the national walk and the deviations run past the cut-off n on knots a week apart, the deviations with fresh standard-normal draws z_drift_future through the same loading, and Rt_matrix covers the horizon.
BVDOutbreakSize.pooled_ascertainment_model Method
pooled_ascertainment_model(
;
mu_prior,
tau_prior
) -> DynamicPPL.Model{typeof(pooled_ascertainment_model), (), (:mu_prior, :tau_prior), (), Tuple{}, Tuple{Distributions.Normal{Float64}, Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}}, DynamicPPL.DefaultContext, false}Partially pooled ascertainment fractions for the DRC and Uganda surveillance systems, sampled in non-centred form to avoid the funnel geometry. Both logit-scale fractions share a hyperprior with mean μ and pooling strength τ. Used by reported_cases_model, exports_model and exports_deaths_model. This is the composer default. The shared mean defaults to a reporting fraction of 0.75 (logit scale), reflecting the active case-finding of a declared Ebola response. A lower ascertainment inflates the inferred infections (and so the outbreak size C_T) for the same observed counts.
BVDOutbreakSize.pooled_dispersion_model Method
pooled_dispersion_model(
n_streams::Integer;
mean_prior,
sd_prior,
centred
) -> AnyPartially-pooled negative-binomial dispersions for the n_streams passive-surveillance count streams in the joint model (suspected cases, suspected deaths, confirmed cases and confirmed deaths). Each stream draws its own dispersion from a shared population, so heterogeneous streams (a handful of deaths against hundreds of suspects against a daily laboratory volume) do not share one global k that the dominant stream pulls around, while the sparse streams still borrow strength through the common hyper-parameters rather than going noisy on a fully independent draw.
The pooling is on the log(1/sqrt(k)) scale, with a population mean μ_log, a pooling SD τ, and per-stream deviations, and k_s = 1 / inv_sqrt_k_s^2. The default is the centred form, log(1/sqrt(k))_s ~ Normal(μ_log, τ) drawn directly. The passive-surveillance streams are data-rich, so each stream's dispersion is strongly informed and the non-centred form inv_sqrt_k_s = exp(μ_log + τ z_s) funnels, with z_s = (log_isk_s − μ_log)/τ diverging as τ → 0 and stretching NUTS trajectories. On the joint, centring removes that funnel: worst dispersion bulk-ESS 102 → 156, divergences 5 → 2, and about 10% faster wall-clock at a 150×2 fit. Pass centred = false for the non-centred form, the better choice when the streams are data-poor and prior-dominated. The population mean is centred on the shared 1/sqrt(k) prior of surveillance_dispersion_model (exp(μ_log) near 0.6), and the half-normal τ keeps the per-stream dispersions close unless the data pull them apart. τ = 0 collapses every stream to the population value, the shared-k model. Returns (; k, inv_sqrt_k, k_pop, μ_log, τ) with k a length-n_streams vector.
BVDOutbreakSize.province_export_pressure_model Method
province_export_pressure_model(
n_patches::Integer;
location_prior,
pooling_sd_prior,
offset_prior
) -> AnyRelative export propensity of each province into Uganda, partially pooled.
Ituri is the reference, at one, because the traveller volume and the source population exports_model carries are Ituri's, the point-of-entry counts having been collected there. Every other province is measured against it, as the chance that one of its infections is detected crossing into Uganda relative to one of Ituri's.
The secondary weights are drawn from a common log-normal whose location and spread are both sampled, so they pool toward each other rather than toward a fixed number, and tau -> 0 makes them one shared weight. The default location prior has a median of 15% of Ituri's propensity with a 90% interval of roughly 3% to 80%.
What the data can say
Very little, and that is the point of reporting it. Four events reach these streams over the whole window, three exported cases and one exported death, and the weights enter only through the summed exporting infections. Expect the posterior to track the prior. Read it as what the model assumes rather than as an estimate, and read the difference it makes to the provincial incidence split rather than the weight itself.
Returns (; weights, pooling_sd, location), with weights[1] = 1.
BVDOutbreakSize.recovery_probability_model Method
recovery_probability_model(CFR::Real; offset_prior) -> AnyRecovery probability for the recovered-among-confirmed stream (recovered_model). The fraction of confirmed cases whose outcome is recovery rather than death is the confirmed-case survival fraction, the complement of the case-fatality ratio, so it is grounded on the model's CFR rather than estimated from scratch. The confirmed cases are a slightly different population from the one the CFR is defined over (they have been laboratory-confirmed and brought into care), so the survival fraction is the complement of the CFR adjusted on the log-odds scale by a sampled offset,
with δ_rec ~ Normal(0, 0.5) centred at zero, so the default recovery fraction is exactly 1 − CFR. The offset keeps p_recover in (0, 1) without a hard clamp. p_recover is partially confounded with the confirmation-to-recovery delay for the count of recoveries observed by the cut-off, since a long delay right-censors recoveries that have not yet resolved, so the delay carries the timing and p_recover the eventual survival fraction. Pass offset_prior to override. Returns (; p_recover, recovery_offset).
BVDOutbreakSize.rt_walk_model Method
rt_walk_model(
n::Integer,
log_R0_base::Real;
week,
breakpoint,
rt_start,
ramp,
sigma_prior,
effect_prior,
forecast
) -> AnyWeekly piecewise-linear log-scale reproduction number over n days, with a smooth intervention ramp. Knots sit at weekly spacing (knot_days) and follow a Gaussian random walk in non-centred cumulative-sum form, with standard-normal innovations scaled by sigma_rw and accumulated, avoiding the funnel geometry of the centred recursion. Daily log-R_t is the linear interpolation between knots (interpolate_knots). An intervention at breakpoint (e.g. the first WHO situation report) adds a sampled effect intervention_effect shaped by a logistic ramp (sigmoid_ramp) of scale ramp (default 21 days, roughly the time a response takes to bite), so transmission changes gradually rather than instantly. breakpoint = missing drops the term. Rt = exp.(log_Rt).
The walk base log_R0 is not sampled here. It is passed in, derived forward from the sampled growth rate r and the generation interval through Euler–Lotka (R0 = r_to_R0(r, g) in infection_model), so the prior sits on the growth rate instead (see exponential_growth_model). That single growth source pins both the established reproduction number and, through the renewal seeding, the cryptic exponential phase. The grid days before the renewal start are filled by the analytic cryptic exponential and are unused by the walk, which clamps to R0 before its first knot.
The random-walk step SD prior is a half-normal SD 0.1, so the weekly log-R_t is unlikely to change by more than about 20% (two SD ≈ 0.2) from one week to the next. The walk starts at rt_start, so every knot sits in the observed window rather than drifting over the unobserved pre-report stretch.
The intervention effect is constrained non-positive (truncated(Normal(0, 0.4); upper = 0)). A declared WHO response (case finding, isolation, vaccination) can only reduce transmission or leave it unchanged. The half-normal admits anything from no effect (mode) to a substantial decline.
With forecast a ForecastHorizon the walk runs past n on knots a week apart (future_knot_days), with standard-normal innovations z_future scaled by the same sigma_rw, and the ramp carries on. Rt then covers the horizon too.
Returns (; Rt, log_R, days, sigma_rw, log_R0, intervention_effect).
BVDOutbreakSize.severity_enrichment_model Method
severity_enrichment_model(
;
logodds_prior,
decay_prior
) -> DynamicPPL.Model{typeof(severity_enrichment_model), (), (:logodds_prior, :decay_prior), (), Tuple{}, Tuple{Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}, Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}}, DynamicPPL.DefaultContext, false}Severity-enrichment prior for the confirmed positivity in confirmed_cases_model. The per-window tested BVD share is the suspect-pool composition φ_v = (p_drc · BVD)_v / ((p_drc · BVD)_v + λ_bg_v) over each laboratory window, upsampled by a severity enrichment that decays as testing widens:
with c_v the cumulative analysed volume at window v, the testing clock. The lab over-tests BVD early, since severe cases are triaged first and are more likely BVD, and the enrichment δ₀·e^{−c/decay} relaxes toward zero as testing widens, at which point the tested share equals the pool composition. This ties positivity to the background λ_bg, so the confirmed and positivity data identify the non-BVD background rather than it being absorbed by a free per-window random effect.
δ₀ is the early severity log-odds enrichment of BVD, lower-truncated at 0 because severity triage upsamples BVD, never down. The default truncated(Normal(1.5, 0.75); lower = 0) is moderate and bounded, since even severity-triaged testing cannot be near-pure BVD (other haemorrhagic or severe febrile illness is also triaged). For a pool composition φ ≈ 0.4 the early tested share is logistic(logit(0.4) + 1.5) ≈ 0.75. decay_scale is the relaxation timescale on the analysed-volume clock. Pass logodds_prior / decay_prior to override. Used by confirmed_cases_model in composition mode. Returns (; δ0, decay_scale).
BVDOutbreakSize.specimen_intensity_model Method
specimen_intensity_model(
;
intensity_prior
) -> DynamicPPL.Model{typeof(specimen_intensity_model), (), (:intensity_prior,), (), Tuple{}, Tuple{Distributions.LogNormal{Float64}}, DynamicPPL.DefaultContext, false}Specimens analysed per suspect sampled.
confirmed_cases_model scales the laboratory volume by this factor as well as by τ_test. τ_test is a probability and the receipt kernel conserves mass, so it alone bounds the modelled analysed volume below the modelled suspect inflow. Specimens are not persons: a suspect can yield several through repeat exclusion testing, and swabbed community deaths and screened contacts enter the laboratory denominator without being counted as suspects reported, so the ratio can exceed one.
κ ~ LogNormal(0, 0.25) has median 1 and a 90% range of about 0.66 to 1.51, matching death_testing_scaling_model.
BVDOutbreakSize.surveillance_dispersion_model Method
surveillance_dispersion_model(
;
inv_sqrt_k_prior
) -> DynamicPPL.Model{typeof(surveillance_dispersion_model), (), (:inv_sqrt_k_prior,), (), Tuple{}, Tuple{Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}}, DynamicPPL.DefaultContext, false}Shared negative-binomial dispersion k for the passive-surveillance streams (suspected deaths, reported cases and confirmed cases). Sampled on the 1/sqrt(k) scale with a weakly-informative half-normal prior following the Stan prior-choice recommendations. Returns (; k, inv_sqrt_k).
BVDOutbreakSize.test_positivity_model Method
test_positivity_model(
;
lambda_prior,
fraction_tested_prior
) -> DynamicPPL.Model{typeof(test_positivity_model), (), (:lambda_prior, :fraction_tested_prior), (), Tuple{}, Tuple{Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Nothing}, Distributions.Beta{Float64}}, DynamicPPL.DefaultContext, false}Test-positivity machinery shared by the suspected- and confirmed-case streams. Samples
λ_bg— the per-day non-BVD background suspected-case rate, on a half-normal scale. Drives the suspected/confirmed contrast: suspected cases mix the BVD onset-to-report signal with this additive background, while the laboratory pipeline only confirms the BVD share.τ_test— the fraction of suspected cases that are sampled and routed to the laboratory pipeline.
The default λ_bg prior is a half-normal truncated(Normal(0, 1.0); lower = 0). Its total contribution to the expected suspected-case count over the grid is λ_bg · T, with T the seeding-to-cut-off span. The prior is informative because λ_bg is degenerate with outbreak size (the per-vintage reported mean mixes the p_drc-scaled BVD increment with λ_bg · Δt), and a diffuse prior lets the background absorb arbitrarily many suspected cases and opens a second posterior mode in which it explains the majority of them. With SD 1.0 the median background is ≈ 0.67/day and the 95% prior bound ≈ 2.0/day, a modest minority of the ≈ 1077 suspected cases observed by the last stable suspected-case vintage while still admitting a genuine non-BVD signal. Pass lambda_prior to override. τ_test defaults to Beta(5, 2) (mean ≈ 0.71).
The derived per-suspected positivity is exposed inside reported_cases_model. The per-test positivity inside confirmed_cases_model. Returns (; λ_bg, τ_test).
BVDOutbreakSize.test_sensitivity_model Method
test_sensitivity_model(
;
sensitivity_prior
) -> DynamicPPL.Model{typeof(test_sensitivity_model), (), (:sensitivity_prior,), (), Tuple{}, Tuple{Distributions.Beta{Float64}}, DynamicPPL.DefaultContext, false}Confirmation-process sensitivity prior. Beta(38, 2) centres near a mean of 0.95 with a tight spread. Confirmation runs on the altona RealStar Filovirus Screen RT-PCR (Rieger et al., 2016), which detects Bundibugyo virus at 11–67 RNA copies per reaction. The Zaire-specific GeneXpert Ebola assay does not reliably detect Bundibugyo (Cepheid, 2020; Pinsky et al., 2015; Semper et al., 2016). A single assay draw is sensitive to about 0.85, but a suspect is confirmed or ruled out through repeat control tests rather than one PCR, so the effective process sensitivity is higher (two controls give about 0.98). Under the severe-first backlog the first vintage's analysed batch is near-pure BVD (q ≈ 1), so the v1 positivity ≈ s identifies the sensitivity from the early data. Returns (; s_test).
BVDOutbreakSize.test_specificity_model Method
test_specificity_model(
;
specificity_prior
) -> DynamicPPL.Model{typeof(test_specificity_model), (), (:specificity_prior,), (), Tuple{}, Tuple{Distributions.Beta{Float64}}, DynamicPPL.DefaultContext, false}PCR specificity prior for the Ebola assay. Beta(60, 2) has mean ≈ 0.97 and 95% interval ≈ 0.91–0.998, a high-but-imperfect specificity reflecting that a small fraction of non-BVD specimens test positive (cross-reaction, contamination, low-level false positives). Used by the composition-linked confirmed-case positivity so the tested-positive probability is p = s · q + (1 − spec)(1 − q) with q the tested BVD share. The false-positive term (1 − spec)(1 − q) makes the confirmed counts respond to the non-BVD share 1 − q, so the laboratory data identify the background λ_bg rather than only the BVD signal. Returns (; spec).
BVDOutbreakSize.traveller_volume_model Method
traveller_volume_model(
;
mean,
sd
) -> DynamicPPL.Model{typeof(traveller_volume_model), (), (:mean, :sd), (), Tuple{}, Tuple{Int64, Int64}, DynamicPPL.DefaultContext, false}Prior on the mean daily traveller volume from the source area to Uganda. Default centred on ITURI_DAILY_TRAVEL with SD ITURI_DAILY_TRAVEL_SD, truncated at zero. Sets the per-capita travel rate for the exports stream.