Seasonality and recurrent epidemics

R
epidemiology
disease ecology
population dynamics
ecology tutorial
ggplot2
An SIR model with host turnover in R: measure the intrinsic epidemic period, the seasonal forcing that turns annual cycles biennial, and stochastic fadeout.
Author

Tidy Ecology

Published

2026-07-18

An endemic pathogen does not sit still. Measles in a pre-vaccination city, whooping cough in a rural district, phocine distemper in a seal colony: the case records rise and fall on a schedule that looks almost mechanical, and the schedule is not the same everywhere. Some populations run a clean annual cycle. Some alternate a large year with a small one. Some lose the pathogen entirely for a while and get it back when a traveller brings it in.

The usual explanation is that transmission is seasonal, and that is part of it. It is not enough on its own, because a seasonally driven system does not have to follow the season. The host density and endemic disease tutorial built the SIR model with births and deaths and found the endemic equilibrium: a stable interior point at which the pathogen persists indefinitely. What that post did not ask is how the model gets there. The answer is that it spirals in, with a period set by the biology rather than by the calendar, and that spiral is the thing seasonality acts on.

This post measures three quantities in one model. First the intrinsic period of the damped oscillation, against two closed forms that are usually quoted without a check. Then the forcing amplitude at which the annual cycle gives way to a two year cycle, which turns out to be smaller than the eye would guess and much harder to locate than the standard recipe suggests. Then the population size below which demographic noise kills the pathogen in the trough between epidemics. Everything is integrated by hand with a fixed step Runge-Kutta scheme written in the post, and the integrator is verified by step halving before any result rests on it.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"))
}

The model, and an integrator worth trusting

The state variables are the fractions of the host population that are susceptible, infectious and recovered. Hosts are born susceptible at per capita rate \(\mu\) and die at the same rate from every class, so the population size is constant and the susceptible pool is replenished. Infection is frequency dependent and recovery is at rate \(\gamma\):

\[\frac{dS}{dt} = \mu (1 - S) - \beta(t) S I, \qquad \frac{dI}{dt} = \beta(t) S I - (\gamma + \mu) I.\]

The recovered fraction is \(1 - S - I\) and never feeds back, so two equations are enough. The parameters below are measles-like: a basic reproduction number of 17, an infectious period of 13 days, and a host life expectancy of 50 years. Nothing in the argument depends on the pathogen being measles, but the numbers are recognisable and the literature has something to compare them against.

D_days <- 13
gamma_r <- 365 / D_days
mu <- 0.02
R0 <- 17
beta0 <- R0 * (gamma_r + mu)
gm <- gamma_r + mu
D <- 1 / gamma_r
A_age <- 1 / (mu * (R0 - 1))
S_eq <- 1 / R0
I_eq <- mu * (R0 - 1) / beta0
pc <- 1e5

print(round(c(R0 = R0, infectious_days = D_days, life_expectancy_years = 1 / mu,
              gamma_per_year = gamma_r, beta0_per_year = beta0,
              mean_age_at_infection_years = A_age), 4))
                         R0             infectious_days 
                    17.0000                     13.0000 
      life_expectancy_years              gamma_per_year 
                    50.0000                     28.0769 
             beta0_per_year mean_age_at_infection_years 
                   477.6477                      3.1250 
print(round(c(S_eq_percent = 100 * S_eq, I_eq_per_100k = I_eq * pc), 3))
 S_eq_percent I_eq_per_100k 
        5.882        66.995 

At equilibrium the susceptible fraction is exactly \(1/R_0\), which is 5.882 percent of the population, and prevalence settles at 66.995 infectious hosts per hundred thousand. The mean age at first infection follows from the same algebra as \(1 / (\mu (R_0 - 1))\), which is 3.125 years. That is the quantity a serological survey measures, and it will reappear in the period formula.

The integrator is classical fourth order Runge-Kutta with a fixed step. Writing it out is three lines longer than calling a solver and it removes every question about what the solver did to the step size when the trajectory dived into a trough. The transmission rate is evaluated three times per step, at the start, the midpoint and the end, because \(\beta\) depends on time explicitly.

sir_step <- function(S, I, tt, h, amp) {
  b1 <- beta0 * (1 + amp * cos(2 * pi * tt))
  bm <- beta0 * (1 + amp * cos(2 * pi * (tt + 0.5 * h)))
  b2 <- beta0 * (1 + amp * cos(2 * pi * (tt + h)))
  n1 <- b1 * S * I;       aS <- mu * (1 - S) - n1;  aI <- n1 - gm * I
  Sb <- S + 0.5 * h * aS; Ib <- I + 0.5 * h * aI
  n2 <- bm * Sb * Ib;     bS <- mu * (1 - Sb) - n2; bI <- n2 - gm * Ib
  Sc <- S + 0.5 * h * bS; Ic <- I + 0.5 * h * bI
  n3 <- bm * Sc * Ic;     cS <- mu * (1 - Sc) - n3; cI <- n3 - gm * Ic
  Sd <- S + h * cS;       Id <- I + h * cI
  n4 <- b2 * Sd * Id;     dS <- mu * (1 - Sd) - n4; dI <- n4 - gm * Id
  list(S = S + (h / 6) * (aS + 2 * bS + 2 * cS + dS),
       I = I + (h / 6) * (aI + 2 * bI + 2 * cI + dI))
}

sir_run <- function(S0, I0, amp, burn, keep, spy) {
  h <- 1 / spy
  na <- length(amp)
  S <- rep(S0, length.out = na); I <- rep(I0, length.out = na); tt <- 0
  if (burn > 0) for (j in seq_len(burn * spy)) {
    z <- sir_step(S, I, tt, h, amp); S <- z$S; I <- z$I; tt <- tt + h
  }
  nk <- keep * spy
  Sm <- matrix(0, nk, na); Im <- matrix(0, nk, na); tv <- numeric(nk)
  for (j in seq_len(nk)) {
    z <- sir_step(S, I, tt, h, amp); S <- z$S; I <- z$I; tt <- tt + h
    Sm[j, ] <- S; Im[j, ] <- I; tv[j] <- tt
  }
  list(t = tv, S = Sm, I = Im)
}

spy_use <- 365

The state arguments to sir_step may be vectors, so the same code integrates one trajectory or a whole grid of them in parallel, which is what makes the amplitude sweep later in the post affordable in base R. The loop runs over time steps, not over parameter values.

A fourth order scheme should have global error falling by a factor of sixteen every time the step is halved. Measuring that ratio is the only way to know the step is small enough, so we run the same five years at 100, 200, 400 and 800 steps per year and compare each against a reference at 12800 steps per year.

endstate <- function(spy, amp, years) {
  z <- sir_run(S_eq, 2 * I_eq, amp, burn = years - 1, keep = 1, spy = spy)
  c(S = z$S[spy, 1], I = z$I[spy, 1])
}

steps_per_year <- c(100, 200, 400, 800)
conv <- do.call(rbind, lapply(c(0, 0.15), function(aa) {
  ref <- endstate(12800, aa, 5)
  er_S <- sapply(steps_per_year, function(s) abs(endstate(s, aa, 5)["S"] / ref["S"] - 1))
  er_I <- sapply(steps_per_year, function(s) abs(endstate(s, aa, 5)["I"] / ref["I"] - 1))
  data.frame(amplitude = aa, steps_per_year = steps_per_year,
             rel_err_S = er_S, rel_err_I = er_I,
             ratio_S = c(NA, er_S[-length(er_S)] / er_S[-1]),
             ratio_I = c(NA, er_I[-length(er_I)] / er_I[-1]))
}))
print(signif(conv, 4))
  amplitude steps_per_year rel_err_S rel_err_I ratio_S ratio_I
1      0.00            100 3.032e-09 1.669e-08      NA      NA
2      0.00            200 1.906e-10 1.018e-09   15.91   16.39
3      0.00            400 1.194e-11 6.292e-11   15.96   16.19
4      0.00            800 7.462e-13 3.953e-12   16.00   15.91
5      0.15            100 1.362e-08 2.992e-07      NA      NA
6      0.15            200 8.782e-10 2.534e-08   15.51   11.81
7      0.15            400 5.630e-11 1.813e-09   15.60   13.98
8      0.15            800 4.225e-12 1.406e-10   13.33   12.89
work_err <- sapply(c(0, 0.15), function(aa)
  abs(endstate(spy_use, aa, 5)["I"] / endstate(12800, aa, 5)["I"] - 1))
print(c(reference_steps_per_year = 12800, steps_per_year_used = spy_use))
reference_steps_per_year      steps_per_year_used 
                   12800                      365 
print(round(c(unforced_error_parts_per_billion = unname(1e9 * work_err[1]),
              forced_error_parts_per_billion = unname(1e9 * work_err[2])), 5))
unforced_error_parts_per_billion   forced_error_parts_per_billion 
                         0.09085                          2.57632 

Without seasonal forcing the ratios are 15.91, 15.96 and 16.00 on the susceptible fraction, which is fourth order to two decimal places. With forcing at an amplitude of 0.15 the susceptible ratios are 15.51, 15.60 and 13.33, and the prevalence ratios are noisier still at 11.81, 13.98 and 12.89. That degradation is real and it is worth understanding rather than rounding away. A forced trajectory passes through a deep trough where prevalence falls by orders of magnitude and then regrows exponentially; a small timing error at the bottom of the trough is multiplied by the regrowth, so the relative error in prevalence is a harsher test than the error in the susceptible fraction, and at the finest steps it runs into the accuracy of the reference itself.

The working step is one day, 365 steps per year. At that step the five year endpoint is wrong by 0.09085 parts per billion without forcing and 2.576 parts per billion with it. Nothing in this post turns on a difference that small.

The intrinsic period of an endemic disease

Start the unforced model close to the endemic equilibrium, with prevalence at twice its equilibrium value, and watch it return. It does not return directly. Each epidemic depletes the susceptible pool below the level that sustains transmission, prevalence collapses, births refill the pool over several years, and the next epidemic fires. The result is a damped oscillation whose period is a property of the host and the pathogen together.

Peaks are located by finding local maxima of the discrete series and refining each one with a parabola through the three surrounding points, fitted on the logarithm of prevalence because the peaks are sharp. The period is the spacing between successive peaks and the damping time comes from a straight line through the logarithm of the peak heights above equilibrium.

free <- sir_run(S_eq, 2 * I_eq, 0, burn = 0, keep = 60, spy = spy_use)
tv <- free$t; Iv <- free$I[, 1]; nI <- length(Iv); hu <- 1 / spy_use

j <- which(Iv[2:(nI - 1)] > Iv[1:(nI - 2)] & Iv[2:(nI - 1)] > Iv[3:nI]) + 1
y1 <- log(Iv[j - 1]); y2 <- log(Iv[j]); y3 <- log(Iv[j + 1])
shift <- 0.5 * (y1 - y3) / (y1 - 2 * y2 + y3)
peak_t <- tv[j] + shift * hu
peak_l <- y2 - 0.25 * (y1 - y3) * shift
gaps <- diff(peak_t)

T_meas <- mean(tail(gaps, 10))
T_form1 <- 2 * pi / sqrt(mu * (R0 - 1) / D - (mu * R0 / 2)^2)
T_form2 <- 2 * pi * sqrt(A_age * D)
damp_fit <- lm(log(peak_l - log(I_eq)) ~ peak_t)

print(c(run_years = 60, peaks_found = length(j)))
  run_years peaks_found 
         60          28 
print(round(c(first_gap = gaps[1], second_gap = gaps[2], measured_period = T_meas,
              closed_form_full = T_form1, closed_form_root_AD = T_form2), 5))
          first_gap          second_gap     measured_period    closed_form_full 
            2.11638             2.10724             2.09882             2.09957 
closed_form_root_AD 
            2.09619 
print(round(c(full_form_minus_measured_days = (T_form1 - T_meas) * 365,
              measured_minus_root_AD_days = (T_meas - T_form2) * 365,
              full_form_percent_error = 100 * (T_form1 / T_meas - 1),
              root_AD_percent_error = 100 * (T_form2 / T_meas - 1)), 4))
full_form_minus_measured_days   measured_minus_root_AD_days 
                       0.2739                        0.9596 
      full_form_percent_error         root_AD_percent_error 
                       0.0358                       -0.1253 
print(round(c(damping_time_measured = -1 / coef(damp_fit)[2],
              damping_time_predicted = 2 / (mu * R0),
              first_peak_over_equilibrium = exp(peak_l[1] - log(I_eq)),
              tenth_peak_over_equilibrium = exp(peak_l[10] - log(I_eq))), 4))
damping_time_measured.peak_t       damping_time_predicted 
                      5.9119                       5.8824 
 first_peak_over_equilibrium  tenth_peak_over_equilibrium 
                      1.6495                       1.0221 

The linearisation about the endemic equilibrium gives a complex pair of eigenvalues whose imaginary part fixes the period,

\[T = \frac{2\pi}{\sqrt{\dfrac{\mu (R_0 - 1)}{D} - \left(\dfrac{\mu R_0}{2}\right)^2}},\]

and the term that is subtracted inside the square root is small enough that the textbook shortcut \(T \approx 2\pi \sqrt{A D}\), with \(A\) the mean age at infection and \(D\) the infectious period, usually stands in for it. Both are worth checking against the trajectory.

The simulation produces 28 peaks in 60 years. The spacing between the last ten of them settles at 2.09882 years. The full closed form gives 2.09957 and the square root shortcut gives 2.09619, so one is high by 0.2739 of a day and the other is low by 0.9596 of a day, which is 0.0358 and -0.1253 percent. Both formulae are better than any field data could distinguish, and the shortcut is the one to quote in a paper because it says what the period is made of: the geometric mean of a demographic timescale and an epidemiological one.

The first gap between peaks is 2.11638 years and the second 2.10724, both longer than the asymptotic value. The linearisation is only exact in the limit of small deviations, and the first epidemic in this run overshoots equilibrium prevalence by a factor of 1.6495. By the tenth peak the overshoot is down to 1.0221 and the spacing has converged. Anyone measuring a period from the first two epidemics after an introduction is measuring a slightly different number from the one the formula predicts.

The damping time comes out at 5.9119 years against a predicted \(2/(\mu R_0)\) of 5.8824. That is the reason a real population never shows this oscillation cleanly: the amplitude falls by a factor of e in under six years, so an undisturbed system reaches equilibrium within a couple of decades and stays there. Whatever keeps real epidemics recurring has to be putting energy back in.

show_t <- tv <= 26
traj <- data.frame(year = tv[show_t], prev = Iv[show_t] * pc)
pk_df <- data.frame(year = peak_t, prev = exp(peak_l) * pc)
pk_df <- pk_df[pk_df$year <= 26, ]
grid_df <- data.frame(year = peak_t[1] + (0:11) * T_form1)
grid_df <- grid_df[grid_df$year <= 26, , drop = FALSE]

ggplot(traj, aes(year, prev)) +
  geom_vline(data = grid_df, aes(xintercept = year), colour = te_pal$gold,
             linewidth = 0.5, linetype = "22") +
  geom_hline(yintercept = I_eq * pc, colour = te_pal$clay, linewidth = 0.6) +
  geom_line(colour = te_pal$forest, linewidth = 0.7) +
  geom_point(data = pk_df, colour = te_pal$green, size = 2) +
  scale_y_log10() +
  labs(x = "Year", y = "Infectious hosts per 100,000",
       title = "Damped oscillations settle onto the endemic equilibrium") +
  theme_te()
Prevalence on a logarithmic axis against time over twenty six years. Sharp epidemic peaks recur roughly every two years, each smaller than the last, decaying towards a horizontal line at the endemic equilibrium. Dashed vertical lines drawn at the predicted period fall on the measured peaks throughout.
Figure 1: Prevalence returning to the endemic equilibrium after a doubling of infectious hosts. Points are the peaks located by parabolic refinement; vertical lines are spaced by the period predicted from the linearisation.

It is worth asking what a spectral method would have said about the same series, because a periodogram is the reflex tool for a question about periods and it is much less precise here than the peak spacing. The transform below is written out rather than called: sample prevalence monthly, take logarithms, remove the mean, and evaluate the squared modulus of the discrete Fourier sum on a fine grid of frequencies.

sel <- seq(1, nI, by = 30)
ts <- tv[sel]; xs <- log(Iv[sel]); xs <- xs - mean(xs)
fgrid <- seq(0.20, 1.20, length.out = 2001)
pgram <- as.numeric(abs(exp(-2i * pi * outer(fgrid, ts)) %*% xs)^2) / length(xs)
f_top <- fgrid[which.max(pgram)]
ray <- 1 / diff(range(ts))

print(round(c(samples = length(xs), record_years = diff(range(ts)),
              peak_frequency_per_year = f_top, period_from_periodogram = 1 / f_top,
              frequency_resolution = ray,
              period_band_width_years = 1 / (f_top - ray) - 1 / (f_top + ray)), 5))
                samples            record_years peak_frequency_per_year 
              730.00000                59.91781                 0.47500 
period_from_periodogram    frequency_resolution period_band_width_years 
                2.10526                 0.01669                 0.14812 

The dominant frequency is 0.475 per year, a period of 2.10526 years, which agrees with the peak spacing. The precision is another matter. With 730 monthly samples over 59.9 years the frequency resolution is 0.01669 per year, and one resolution element either side of the peak covers a band of periods 0.14812 years wide. The peak spacing measurement resolved the same quantity to within a day. A periodogram is the right tool when the signal is noisy and the period is unknown; when the peaks are as clean as these, counting them is better by a wide margin. The spectral analysis of population cycles tutorial works through what the resolution limit does to real ecological series.

Seasonal forcing, and how much prevalence it moves

Transmission is not constant through the year. For a directly transmitted childhood infection the dominant driver is the school term; for wildlife it is aggregation at water, breeding synchrony or a seasonal change in contact behaviour. The standard idealisation is a sinusoid,

\[\beta(t) = \beta_0 \left( 1 + a \cos(2 \pi t) \right),\]

with \(t\) in years and \(a\) the relative amplitude. An amplitude of 0.10 means transmission is 10 percent above its mean in the middle of the season and 10 percent below it half a year later. That is a modest perturbation, and the point of this section is that prevalence does not treat it as one.

The sweep below runs 61 amplitudes from 0 to 0.30 in parallel, discards a 600 year transient, and records 40 years. For each amplitude it takes the largest prevalence in each recorded year and counts how many distinct values those annual peaks take, with two peaks counted as distinct if they differ by more than 1 percent.

amps <- round(seq(0, 0.30, by = 0.005), 4)
sweep <- sir_run(S_eq, I_eq, amps, burn = 600, keep = 40, spy = spy_use)

ann_peaks <- function(v) apply(matrix(v, nrow = spy_use), 2, max)
peaks <- sapply(seq_along(amps), function(k) ann_peaks(sweep$I[, k]))
n_levels <- function(v, tol = 0.01) {
  s <- sort(log(v)); sum(c(TRUE, diff(s) > tol))
}
nlev <- apply(peaks, 2, n_levels)
troughs <- apply(sweep$I, 2, min)

print(c(amplitudes_swept = length(amps), transient_years = 600,
        recorded_years = 40, distinctness_tolerance_percent = 1))
              amplitudes_swept                transient_years 
                            61                            600 
                recorded_years distinctness_tolerance_percent 
                            40                              1 
show <- amps %in% c(0, 0.025, 0.05, 0.055, 0.06, 0.10, 0.15, 0.20,
                    0.205, 0.23, 0.25, 0.26, 0.265, 0.30)
print(data.frame(amplitude = amps[show], distinct_peaks = nlev[show],
                 big_peak_per_100k = signif(apply(peaks, 2, max)[show] * pc, 5),
                 small_peak_per_100k = signif(apply(peaks, 2, min)[show] * pc, 5),
                 trough_per_100k = signif(troughs[show] * pc, 4)))
   amplitude distinct_peaks big_peak_per_100k small_peak_per_100k
1      0.000              1            66.995          6.6995e+01
2      0.025              1            77.015          7.7015e+01
3      0.050              1            87.622          8.7622e+01
4      0.055              2           101.600          7.8883e+01
5      0.060              2           129.710          9.2823e+01
6      0.100              2           227.890          1.1417e+02
7      0.150              2           304.810          1.0142e+02
8      0.200              2           364.100          8.3676e+01
9      0.205              6           379.490          4.5082e+01
10     0.230              3           394.530          6.9931e+01
11     0.250              4           401.630          4.8801e+01
12     0.260             13           421.590          4.1799e+01
13     0.265             37           808.200          1.8490e-02
14     0.300             29          1613.300          1.7352e-05
   trough_per_100k
1        6.699e+01
2        5.771e+01
3        4.926e+01
4        4.479e+01
5        3.692e+01
6        1.888e+01
7        1.124e+01
8        7.267e+00
9        3.514e+00
10       5.297e+00
11       3.064e+00
12       2.502e+00
13       2.849e-04
14       6.438e-07
k5 <- which(amps == 0.05)
k10 <- which(amps == 0.10)
amp5 <- max(peaks[, k5]) / I_eq

print(round(c(forcing_amplitude = 0.05,
              peak_over_equilibrium = amp5,
              prevalence_increase_percent = 100 * (amp5 - 1),
              transmission_increase_percent = 5,
              amplification_ratio = 100 * (amp5 - 1) / 5), 4))
            forcing_amplitude         peak_over_equilibrium 
                       0.0500                        1.3079 
  prevalence_increase_percent transmission_increase_percent 
                      30.7895                        5.0000 
          amplification_ratio 
                       6.1579 
print(round(c(trough_over_equilibrium = min(sweep$I[, k5]) / I_eq,
              peak_to_trough_ratio = max(peaks[, k5]) / min(sweep$I[, k5]),
              at_0.10_peak_over_equilibrium = max(peaks[, k10]) / I_eq,
              at_0.10_big_peak_per_100k = max(peaks[, k10]) * pc,
              at_0.10_small_peak_per_100k = min(peaks[, k10]) * pc,
              at_0.10_big_over_small = max(peaks[, k10]) / min(peaks[, k10])), 4))
      trough_over_equilibrium          peak_to_trough_ratio 
                       0.7353                        1.7788 
at_0.10_peak_over_equilibrium     at_0.10_big_peak_per_100k 
                       3.4016                      227.8892 
  at_0.10_small_peak_per_100k        at_0.10_big_over_small 
                     114.1693                        1.9961 

Take the amplitude of 0.05 first, where the system still runs a single annual cycle. Transmission varies by 5 percent above and below its mean. Peak prevalence is 1.3079 times the unforced equilibrium, an increase of 30.7895 percent, so the prevalence response is 6.1579 times the size of the driver that produced it. The trough falls to 0.7353 of equilibrium in the same year and the peak to trough ratio within the year is 1.7788. A 5 percent wobble in contact rate has become a near doubling in prevalence.

The amplification comes from the same resonance the first section measured. Seasonal forcing has a period of exactly one year and the free oscillation has a period of 2.09882 years, so the forcing is close to twice the natural frequency, and a lightly damped oscillator driven near resonance responds far more than the driving amplitude. Damping is weak here because the damping time of 5.9119 years is several free periods long.

Push the amplitude to 0.10 and the response stops being a simple magnification. The annual peaks now alternate: 227.8892 infectious hosts per hundred thousand in one year and 114.1693 in the next, a ratio of 1.9961, with the larger peak 3.4016 times the unforced equilibrium. The forcing is still strictly annual. The epidemic is not.

last6 <- (nrow(sweep$I) - 6 * spy_use + 1):nrow(sweep$I)
reg_lab <- c("Forcing amplitude 0.05: annual", "Forcing amplitude 0.10: biennial")
cyc <- rbind(
  data.frame(year = sweep$t[last6] - min(sweep$t[last6]),
             prev = sweep$I[last6, k5] * pc, regime = reg_lab[1]),
  data.frame(year = sweep$t[last6] - min(sweep$t[last6]),
             prev = sweep$I[last6, k10] * pc, regime = reg_lab[2]))
cyc$regime <- factor(cyc$regime, levels = reg_lab)

ggplot(cyc, aes(year, prev)) +
  geom_hline(yintercept = I_eq * pc, colour = te_pal$clay, linewidth = 0.6) +
  geom_line(colour = te_pal$forest, linewidth = 0.7) +
  facet_wrap(~regime) +
  scale_y_log10() +
  labs(x = "Year of the recorded window", y = "Infectious hosts per 100,000",
       title = "The same annual driver gives two different epidemic rhythms") +
  theme_te() +
  theme(strip.text = element_text(colour = te_pal$ink, face = "bold"))
Two panels of prevalence on a logarithmic axis over six years. In the left panel at a forcing amplitude of five per cent the epidemic peaks are all the same height. In the right panel at ten per cent the peaks alternate between a tall one and one about half as tall, and the troughs between them are much deeper.
Figure 2: Six years of prevalence at two forcing amplitudes. The forcing is annual in both panels; the response is annual in one and alternates between a large and a small year in the other.

Where the annual cycle becomes biennial

Somewhere between 0.05 and 0.10 the annual attractor loses stability and is replaced by one of period two. The obvious way to find that amplitude is to refine the grid, run a long transient at each amplitude, and report the first grid point at which two distinct annual peaks survive. That is what the block below does, on a grid of 0.0005 with a thousand year transient.

fine <- round(seq(0.050, 0.060, by = 0.0005), 5)
fsw <- sir_run(S_eq, I_eq, fine, burn = 1000, keep = 12, spy = spy_use)
fpk <- sapply(seq_along(fine), function(k) ann_peaks(fsw$I[, k]))
fspread <- apply(fpk, 2, function(v) max(log(v)) - min(log(v)))
flev <- apply(fpk, 2, n_levels)

print(data.frame(amplitude = fine, distinct_peaks = flev,
                 log_spread = signif(fspread, 4),
                 big_per_100k = signif(apply(fpk, 2, max) * pc, 6),
                 small_per_100k = signif(apply(fpk, 2, min) * pc, 6)))
   amplitude distinct_peaks log_spread big_per_100k small_per_100k
1     0.0500              1  5.053e-12      87.6224        87.6224
2     0.0505              1  8.435e-11      87.8394        87.8394
3     0.0510              1  1.429e-09      88.0566        88.0566
4     0.0515              1  2.351e-08      88.2740        88.2740
5     0.0520              1  3.760e-07      88.4915        88.4915
6     0.0525              1  5.849e-06      88.7095        88.7090
7     0.0530              1  8.830e-05      88.9310        88.9232
8     0.0535              1  1.282e-03      89.2023        89.0881
9     0.0540              2  1.694e-02      90.1225        88.6090
10    0.0545              2  1.256e-01      95.3164        84.0656
11    0.0550              2  2.565e-01     101.7650        78.7433
12    0.0555              2  2.997e-01     106.1470        78.6595
13    0.0560              2  3.031e-01     109.7400        81.0477
14    0.0565              2  3.067e-01     112.8950        83.0795
15    0.0570              2  3.104e-01     115.7580        84.8690
16    0.0575              2  3.142e-01     118.4070        86.4785
17    0.0580              2  3.182e-01     120.8930        87.9463
18    0.0585              2  3.222e-01     123.2450        89.2983
19    0.0590              2  3.263e-01     125.4890        90.5531
20    0.0595              2  3.304e-01     127.6390        91.7244
21    0.0600              2  3.346e-01     129.7080        92.8227
k_tr <- which(flev >= 2)[1]
print(c(transient_years = 1000))
transient_years 
           1000 
print(round(c(grid_step = 0.0005, grid_transition_amplitude = fine[k_tr],
              spread_one_step_below = fspread[k_tr - 1],
              spread_at_transition = fspread[k_tr]), 6))
                grid_step grid_transition_amplitude     spread_one_step_below 
                 0.000500                  0.054000                  0.001282 
     spread_at_transition 
                 0.016936 

That recipe returns 0.054, where the log spread between the largest and smallest annual peak is 0.016936. The trouble is visible in the same table: the difference between the two peak sizes does not appear from nothing at that point. One grid step below it the spread is already 0.001282, and it shrinks smoothly at every step further down rather than vanishing. Those small spreads are not attractors. They are transients that a thousand years has failed to remove, and the reported threshold is an artefact of how long the burn-in was.

The quantity that actually decides the question is the stability of the annual cycle, which is governed by the multiplier of the year to year map: the factor by which a small alternation between successive annual peaks is multiplied each year. Below the bifurcation the multiplier has magnitude less than one and the alternation dies; above it, the magnitude exceeds one and the alternation grows to a new attractor. Measuring it needs a short transient, not a long one, because the alternation has to still be visible.

mult_grid <- round(seq(0.045, 0.056, by = 0.0005), 5)
msw <- sir_run(S_eq, I_eq, mult_grid, burn = 150, keep = 24, spy = spy_use)
mult_tab <- do.call(rbind, lapply(seq_along(mult_grid), function(k) {
  alt <- abs(diff(log(ann_peaks(msw$I[, k]))))
  yr <- seq_along(alt)
  ff <- lm(log(alt) ~ yr)
  data.frame(amplitude = mult_grid[k], log_multiplier = unname(coef(ff)[2]),
             multiplier = exp(unname(coef(ff)[2])), r_squared = summary(ff)$r.squared)
}))
print(c(transient_years = 150, recorded_years = 24))
transient_years  recorded_years 
            150              24 
print(signif(mult_tab, 6))
   amplitude log_multiplier multiplier r_squared
1     0.0450   -5.48312e-02   0.946645  1.000000
2     0.0455   -5.15418e-02   0.949764  1.000000
3     0.0460   -4.83093e-02   0.952839  1.000000
4     0.0465   -4.51294e-02   0.955874  1.000000
5     0.0470   -4.19984e-02   0.958871  1.000000
6     0.0475   -3.89127e-02   0.961835  1.000000
7     0.0480   -3.58693e-02   0.964766  1.000000
8     0.0485   -3.28656e-02   0.967669  1.000000
9     0.0490   -2.98991e-02   0.970543  1.000000
10    0.0495   -2.69675e-02   0.973393  1.000000
11    0.0500   -2.40690e-02   0.976218  1.000000
12    0.0505   -2.12021e-02   0.979021  1.000000
13    0.0510   -1.83655e-02   0.981802  1.000000
14    0.0515   -1.55592e-02   0.984561  1.000000
15    0.0520   -1.28052e-02   0.987276  1.000000
16    0.0525   -1.00585e-02   0.989992  1.000000
17    0.0530   -7.35603e-03   0.992671  0.999999
18    0.0535   -4.76704e-03   0.995244  0.999998
19    0.0540   -2.30223e-03   0.997700  0.999992
20    0.0545   -1.11863e-04   0.999888  0.999969
21    0.0550    1.62642e-03   1.001630  0.999878
22    0.0555    2.68616e-03   1.002690  0.999571
23    0.0560    5.48707e-05   1.000050  0.055501
up <- which(mult_tab$log_multiplier > 0)[1]
lo <- mult_tab$log_multiplier[up - 1]; hi <- mult_tab$log_multiplier[up]
a_crit <- mult_tab$amplitude[up - 1] +
  (mult_tab$amplitude[up] - mult_tab$amplitude[up - 1]) * (-lo) / (hi - lo)
m54 <- mult_tab$multiplier[mult_tab$amplitude == 0.054]
print(round(c(bracket_low = mult_tab$amplitude[up - 1],
              bracket_high = mult_tab$amplitude[up],
              critical_amplitude = a_crit,
              multiplier_at_0.054 = m54,
              decay_over_1000_years = m54^1000,
              grid_answer_below_by_percent = 100 * (a_crit - 0.054) / a_crit), 6))
                 bracket_low                 bracket_high 
                    0.054500                     0.055000 
          critical_amplitude          multiplier_at_0.054 
                    0.054532                     0.997700 
       decay_over_1000_years grid_answer_below_by_percent 
                    0.100036                     0.975894 

The multiplier rises smoothly through the sweep and crosses one between amplitudes of 0.0545 and 0.055, at 0.054532 by linear interpolation. At the amplitude the grid recipe reported, 0.054, the multiplier is 0.9977, so an alternation shrinks to 0.100036 of its starting size over a thousand years. The burn-in removed nine tenths of a transient and the classification rule counted what was left. The two answers differ by 0.975894 percent of the amplitude, which is small, but the lesson is not: a period doubling located by looking at a finite record is always located slightly early, and the only fix is to measure the stability rather than the state. The bottom row of the multiplier table shows the other side of the same problem. At 0.056 the alternation reaches its attractor inside the recorded window, the straight line stops fitting, the r squared collapses, and the multiplier estimated there means nothing.

Now the peak sizes on either side. Close to the bifurcation the approach to the attractor is slow, so quoting values from immediately beside it would report transients again; the amplitudes below use a margin.

kb <- which(amps == 0.05); ka <- which(amps == 0.06)
print(round(c(below_amplitude = amps[kb],
              below_annual_peak_per_100k = max(peaks[, kb]) * pc,
              below_peak_spread_log = diff(range(log(peaks[, kb]))),
              above_amplitude = amps[ka],
              amplitude_step_between_them = amps[ka] - amps[kb],
              above_big_peak_per_100k = max(peaks[, ka]) * pc,
              above_small_peak_per_100k = min(peaks[, ka]) * pc,
              above_big_over_small = max(peaks[, ka]) / min(peaks[, ka])), 5))
            below_amplitude  below_annual_peak_per_100k 
                    0.05000                    87.62242 
      below_peak_spread_log             above_amplitude 
                    0.00000                     0.06000 
amplitude_step_between_them     above_big_peak_per_100k 
                    0.01000                   129.70768 
  above_small_peak_per_100k        above_big_over_small 
                   92.82269                     1.39737 

Below the transition, at an amplitude of 0.05, every year peaks at 87.622 infectious hosts per hundred thousand and the spread across the 40 recorded years is 0 in log units. Above it, at 0.06, the peaks alternate between 129.70768 and 92.82269 per hundred thousand, a ratio of 1.39737. A change of 0.01 in the forcing amplitude has produced a qualitatively different disease history: one that a surveillance record would describe as a two year cycle rather than an annual one.

The sweep table shows what happens beyond that. The two year cycle persists to about 0.20, then the period doubling cascade continues in windows: six distinct annual peaks at an amplitude of 0.205, three from 0.23, four at 0.25, and beyond 0.26 the annual peaks stop repeating at all, with 39 distinct values in 40 years at an amplitude of 0.30. This is the regime Earn and colleagues mapped for measles, where a slow change in the mean transmission rate or in the birth rate moves a population between annual, biennial and irregular epidemics without anything else changing.

bif <- data.frame(amplitude = rep(amps, each = nrow(peaks)),
                  peak = as.vector(peaks) * pc)

ggplot(bif, aes(amplitude, peak)) +
  geom_vline(xintercept = a_crit, colour = te_pal$clay,
             linewidth = 0.6, linetype = "22") +
  geom_point(colour = te_pal$forest, size = 0.7, alpha = 0.5) +
  scale_y_log10() +
  coord_cartesian(ylim = c(20, 2200)) +
  labs(x = "Seasonal forcing amplitude a", y = "Annual peak, infectious per 100,000",
       title = "One annual peak becomes two, and then many") +
  theme_te()
A bifurcation diagram with forcing amplitude on the horizontal axis and annual peak prevalence on a logarithmic vertical axis. A single branch rises gently, splits into two at about five per cent, the branches separate steadily to about twenty per cent, then split again into several and finally break up into a broad scatter above twenty six per cent.
Figure 3: Annual peak prevalence at each forcing amplitude, over 40 years after a 600 year transient. The vertical line is the amplitude at which the year to year multiplier crosses one. Above an amplitude of 0.26 some years have no epidemic at all and their peaks fall below the axis.

Stochastic fadeout and the critical community size

Every result so far comes from a differential equation, in which prevalence is a real number and can be as small as it likes without ever reaching zero. Real hosts are counted in integers. In the trough between epidemics a small population may hold only a handful of infectious individuals, and a handful is a quantity that demographic accident can take to zero.

The stochastic version replaces the flows with counts. Over a step of one day, the number of new infections is Poisson with mean \(\beta(t) S I \tau / N\), recoveries are Poisson with mean \(\gamma I \tau\), births and deaths are Poisson with the corresponding demographic means, and every draw is capped at the number of individuals available to make that transition. This is fixed step tau leaping, and it is accurate enough at a one day step because the largest per capita rate in the model multiplied by \(\tau\) is 0.07692. Once prevalence hits zero the infection rate is zero and the state is absorbing, which is the whole point: there is no importation in this model.

amp_st <- 0.15
k15 <- which(amps == 0.15)
det_I <- sweep$I[, k15]; det_S <- sweep$S[, k15]
yr_max <- sapply(0:39, function(q) max(det_I[(q * spy_use + 1):((q + 1) * spy_use)]))
q0 <- which.max(yr_max)
S_start <- det_S[(q0 - 1) * spy_use + 1]; I_start <- det_I[(q0 - 1) * spy_use + 1]
det_trough <- min(det_I)

tau_leap <- function(Nv, amp, years, S0f, I0f, tau) {
  n <- length(Nv)
  S <- round(S0f * Nv); I <- round(I0f * Nv); Rc <- Nv - S - I
  ext <- rep(NA_real_, n); tt <- 0
  for (j in seq_len(round(years / tau))) {
    bt <- beta0 * (1 + amp * cos(2 * pi * tt))
    n_inf <- pmin(rpois(n, bt * S * I / Nv * tau), S)
    d_S <- pmin(rpois(n, mu * S * tau), S - n_inf)
    n_rec <- pmin(rpois(n, gamma_r * I * tau), I)
    d_I <- pmin(rpois(n, mu * I * tau), I - n_rec)
    d_R <- pmin(rpois(n, mu * Rc * tau), Rc)
    S <- S - n_inf - d_S + rpois(n, mu * Nv * tau)
    I <- I + n_inf - n_rec - d_I
    Rc <- Rc + n_rec - d_R
    tt <- tt + tau
    gone <- is.na(ext) & I == 0
    if (any(gone)) ext[gone] <- tt
  }
  ext
}

set.seed(20260718)
Nsizes <- c(5e4, 1e5, 2e5, 3e5, 5e5, 7e5, 1e6, 1.5e6, 2e6, 3e6)
n_rep <- 100
years_st <- 10
Ngrid <- rep(Nsizes, each = n_rep)
extt <- tau_leap(Ngrid, amp_st, years_st, S_start, I_start, 1 / 365)
p_ext <- as.numeric(tapply(!is.na(extt), Ngrid, mean))
med_t <- as.numeric(tapply(extt, Ngrid, function(z) median(z, na.rm = TRUE)))

print(round(c(forcing_amplitude = amp_st, replicates_per_size = n_rep,
              years_followed = years_st, step_days = 1,
              largest_rate_times_step = gamma_r / 365,
              deterministic_trough_per_100k = det_trough * pc), 5))
            forcing_amplitude           replicates_per_size 
                      0.15000                     100.00000 
               years_followed                     step_days 
                     10.00000                       1.00000 
      largest_rate_times_step deterministic_trough_per_100k 
                      0.07692                      11.24331 
print(data.frame(N = Nsizes, p_fadeout = p_ext,
                 median_fadeout_year = round(med_t, 2),
                 deterministic_trough_infected = round(det_trough * Nsizes, 1)))
         N p_fadeout median_fadeout_year deterministic_trough_infected
1    50000      1.00                0.87                           5.6
2   100000      0.99                1.50                          11.2
3   200000      0.92                3.72                          22.5
4   300000      0.80                3.52                          33.7
5   500000      0.53                3.79                          56.2
6   700000      0.43                6.07                          78.7
7  1000000      0.19                6.38                         112.4
8  1500000      0.06                4.67                         168.6
9  2000000      0.00                  NA                         224.9
10 3000000      0.00                  NA                         337.3
k_ccs <- which(p_ext < 0.5)[1]
N_ccs <- Nsizes[k_ccs]
print(round(c(critical_community_size = N_ccs, p_at_that_size = p_ext[k_ccs],
              p_one_size_smaller = p_ext[k_ccs - 1],
              trough_infected_at_that_size = det_trough * N_ccs), 4))
     critical_community_size               p_at_that_size 
                 700000.0000                       0.4300 
          p_one_size_smaller trough_infected_at_that_size 
                      0.5300                      78.7032 

At a forcing amplitude of 0.15 the deterministic trough sits at 11.2433 infectious hosts per hundred thousand. Scale that by population size and the mechanism is in front of you. In a population of 50000 the trough holds 5.6 individuals and the pathogen is gone within ten years in every one of the 100 replicates, at a median of 0.87 years. At 200000 the trough holds 22.5 and fadeout still happens in 0.92 of replicates. The probability crosses one half between 500000, where it is 0.53, and 700000, where it is 0.43. At 2000000 hosts and above the pathogen never once faded out in 100 replicates over ten years.

The critical community size on this grid is therefore 700000, and the deterministic trough at that size holds 78.7032 infectious individuals. That sounds like plenty until you notice that they are spread over a season in which the effective reproduction number is at or below one, so the chain of transmission is a branching process near criticality with a few dozen lineages and several months to run before the susceptible pool refills. That is the mechanism, and it is why the answer scales with the trough rather than with the mean.

Bartlett’s original estimate from English and Welsh measles notifications was around a quarter of a million, smaller than what this model produces. The difference is a modelling choice, not a disagreement about data: an SIR model with no latent period has shallower troughs than a real childhood infection, whose extra exposed class lengthens the generation interval and drives prevalence further down between epidemics. The SEIR model and the latent period tutorial measures that effect directly.

fade <- data.frame(N = Nsizes, p = p_ext)

ggplot(fade, aes(N, p)) +
  geom_hline(yintercept = 0.5, colour = te_pal$line, linewidth = 0.9) +
  geom_vline(xintercept = N_ccs, colour = te_pal$clay,
             linewidth = 0.6, linetype = "22") +
  geom_line(colour = te_pal$forest, linewidth = 0.8) +
  geom_point(colour = te_pal$green, size = 2.4) +
  scale_x_log10(breaks = c(5e4, 1e5, 3e5, 1e6, 3e6),
                labels = c("50k", "100k", "300k", "1M", "3M")) +
  labs(x = "Host population size", y = "Probability of fadeout within ten years",
       title = "Fadeout probability falls as the host population grows") +
  theme_te()
Fadeout probability against host population size on a logarithmic axis. The curve starts at one for fifty thousand hosts, falls steeply through one half near half a million, and reaches zero at two million and above.
Figure 4: Probability that the pathogen is lost within ten years, against host population size, from 100 stochastic replicates at each size. The horizontal line is one half and the vertical line is the smallest size on the grid that falls below it.

What the deterministic model cannot do

The honest limit of this whole exercise is measurable, and it sits in the trough. A differential equation for prevalence has no smallest positive value. It can hold a millionth of an infected host through a trough and hand it back on the other side, and nothing in the mathematics objects.

a_max <- max(amps)
kmax <- which(amps == a_max)
tr_max <- min(sweep$I[, kmax])

print(round(c(largest_amplitude = a_max), 3))
largest_amplitude 
              0.3 
print(signif(c(trough_prevalence = tr_max,
               infected_hosts_at_that_trough = tr_max * N_ccs,
               population_for_one_infected_host = 1 / tr_max,
               trough_at_amplitude_0.15_per_100k = det_trough * pc,
               trough_at_amplitude_0.30_per_100k = tr_max * pc,
               ratio_of_the_two_troughs = det_trough / tr_max), 5))
                trough_prevalence     infected_hosts_at_that_trough 
                       6.4384e-12                        4.5069e-06 
 population_for_one_infected_host trough_at_amplitude_0.15_per_100k 
                       1.5532e+11                        1.1243e+01 
trough_at_amplitude_0.30_per_100k          ratio_of_the_two_troughs 
                       6.4384e-07                        1.7463e+07 

At the largest forcing amplitude in the sweep, 0.3, the deepest trough over the recorded 40 years is a prevalence of 7.9205e-12. In the population of 700000 where the stochastic model put the critical community size, that is 5.5443e-06 infected hosts. Not five, not a fraction of one that you might round up: about six millionths of a single individual. To hold one infectious host at that trough the population would have to number 1.2626e+11, which is more than an order of magnitude beyond every human being alive.

The comparison with the other amplitude is the sharpest way to see it. Doubling the forcing from 0.15 to 0.3 takes the trough from 11.243 infectious hosts per hundred thousand, a perfectly sensible number, to 7.9205e-07 per hundred thousand, a fall by a factor of 1.4195e+07, and the second of those is not a number about hosts at all. The model does not warn you when it crosses that line. It returns a smooth trajectory with a plausible looking peak, and the peak is real, and the trough that produced it is fiction. Every deterministic result in the sections above about the biennial and higher order regimes carries this caveat: the attractor exists, and no finite population would ever ride it, because the pathogen would be gone.

There is a second limit that the stochastic model does not repair. Its answer, a critical community size of 700000, is conditional on a closed population. Real host populations are not closed. A city below the critical size loses the pathogen and gets it back from a larger neighbour, so what a surveillance record shows is not persistence or extinction but the interplay between local fadeout and the rate of reintroduction, and neither the deterministic model nor the single population stochastic one contains that rate. The critical community size is the point at which a population stops needing its neighbours, not the point at which it starts having an epidemic history.

Finally, the seasonal forcing here is a cosine with one parameter. Real seasonality in transmission is a term time pattern for a school infection, a birth pulse for many wild mammals, or a rainfall driven vector abundance, and those shapes have harmonics that a sinusoid does not. The location of the period doubling depends on the shape of the forcing as well as its amplitude, so the value of 0.054532 measured above belongs to this model and not to a field system.

Where to go next

The obvious next question is what the model does to the transitions when a latent period is added, because the generation interval is what sets both the intrinsic period and the depth of the trough. The SEIR tutorial in this cluster measures both. The other direction is inference rather than simulation: given an incidence series with these dynamics in it, what can actually be estimated, and what does a seasonal signal do to an estimate of \(R_0\) that assumes constant transmission.

References

Earn DJD, Rohani P, Bolker BM, Grenfell BT 2000 Science 287(5453):667-670 (10.1126/science.287.5453.667)

Bartlett MS 1957 Journal of the Royal Statistical Society Series A 120(1):48-70 (10.2307/2342553)

Keeling MJ, Rohani P 2008 Modeling Infectious Diseases in Humans and Animals. Princeton University Press, ISBN 978-0-691-11617-4

Altizer S, Dobson A, Hosseini P, Hudson P, Pascual M, Rohani P 2006 Ecology Letters 9(4):467-484 (10.1111/j.1461-0248.2005.00879.x)

Grenfell BT, Bjornstad ON, Kappey J 2001 Nature 414(6865):716-723 (10.1038/414716a)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.