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

Modified

2026-10-14

Corrected 14 October 2026: the post put the way prevalence amplifies the seasonal driver down to parametric driving, the instability behind the later period doubling, and said this is why the gain grows towards it; it now says that to first order the forcing is an additive push on the growth rate of log prevalence, that the swing in log prevalence is nearly proportional to the amplitude, and that the percentage gain rises only because a percentage change grows faster than the change in the logarithm.

Corrected 1 October 2026: the full closed form for the intrinsic period used the recovery rate alone where the linearisation has recovery plus death, so it seemed to miss the measured period by about a quarter of a day; corrected, it matches. The multiplier of the year to year map was read from the decay of a transient, which near the crossing is not that multiplier; it is now computed by linearising the map about the annual cycle, which puts the period doubling slightly lower, one grid step down.

Corrected 30 September 2026: the limits section said that no finite population could ride any biennial or higher order attractor; it now limits that caveat to the strongest forcing, since at moderate forcing the biennial trough is a sensible number of hosts and large populations kept the pathogen.

Corrected 27 September 2026: annual peaks were taken as the largest prevalence in each calendar year, which in the biennial regime caught the rise of the next epidemic instead of the small one; they are now epidemic peaks, so at a forcing amplitude of 0.10 the small year peaks at about 34 rather than 114 per hundred thousand (a sixth of the large year, not a half), and the lower branch of the bifurcation diagram has moved. The comparison with Bartlett no longer blames the missing latent period, which would widen the gap rather than close it.

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 per cent 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 on the susceptible fraction are 15.91, 15.96 and 16.00, converging on the fourth order value of 16 and reaching it to two decimal places at the smallest step. 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. No conclusion in this post turns on a difference that small. The exact counts and trough depths quoted later for the irregular regime at the strongest forcing do, as the numbers from any chaotic trajectory would, so they illustrate that regime rather than measure it.

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) * gm - (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.09882 
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.0002                        0.9596 
      full_form_percent_error         root_AD_percent_error 
                       0.0000                       -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{\mu (R_0 - 1)(\gamma + \mu) - \left(\dfrac{\mu R_0}{2}\right)^2}},\]

where \(\gamma + \mu\) is the rate at which hosts leave the infectious class, because infectious hosts also die. The term that is subtracted inside the square root is small, and so is \(\mu\) beside \(\gamma\), so the textbook shortcut \(T \approx 2\pi \sqrt{A D}\), with \(A\) the mean age at infection and \(D = 1/\gamma\) 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.09882, within 0.0002 of a day of the measurement, and the square root shortcut gives 2.09619, low by 0.9596 of a day, which is 0.1253 per cent. 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 per cent above its mean in the middle of the season and 10 per cent 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 epidemic peak in each recorded year (the largest local maximum of prevalence that falls in that year, so that a value on the rising or falling limb of an epidemic at the turn of the year is not counted as a peak) and counts how many distinct values those annual peaks take, with two peaks counted as distinct if they differ by more than 1 per cent.

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) {
  n <- length(v); i <- 2:(n - 1)
  top <- i[v[i] >= v[i - 1] & v[i] >= v[i + 1]]
  yr <- factor((top - 1) %/% spy_use, levels = seq_len(n / spy_use) - 1)
  unname(vapply(split(v[top], yr), function(z) if (length(z)) max(z) else NA_real_, numeric(1)))
}
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], years_with_peak = colSums(!is.na(peaks))[show],
                 distinct_peaks = nlev[show],
                 big_peak_per_100k = signif(apply(peaks, 2, max, na.rm = TRUE)[show] * pc, 5),
                 small_peak_per_100k = signif(apply(peaks, 2, min, na.rm = TRUE)[show] * pc, 5),
                 trough_per_100k = signif(troughs[show] * pc, 4)))
   amplitude years_with_peak distinct_peaks big_peak_per_100k
1      0.000              40              1            66.995
2      0.025              40              1            77.015
3      0.050              40              1            87.622
4      0.055              40              2           101.600
5      0.060              40              2           129.710
6      0.100              40              2           227.890
7      0.150              40              2           304.810
8      0.200              40              2           364.100
9      0.205              40              6           379.490
10     0.230              40              3           394.530
11     0.250              40              4           401.630
12     0.260              40             14           421.590
13     0.265              37             34           808.200
14     0.300              30             22          1613.300
   small_peak_per_100k trough_per_100k
1           6.6995e+01       6.699e+01
2           7.7015e+01       5.771e+01
3           8.7622e+01       4.926e+01
4           7.8883e+01       4.479e+01
5           6.1681e+01       3.692e+01
6           3.4485e+01       1.888e+01
7           2.6344e+01       1.124e+01
8           2.3016e+01       7.267e+00
9           1.0502e+01       3.514e+00
10          2.0132e+01       5.297e+00
11          1.3681e+01       3.064e+00
12          1.2464e+01       2.502e+00
13          9.8473e-03       2.849e-04
14          3.8942e-06       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 
                      34.4850                        6.6084 
k_small <- which(amps == 0.005)
half_log_range <- function(k) 0.5 * log(max(peaks[, k]) / min(sweep$I[, k])) / amps[k]
print(round(c(small_amplitude = amps[k_small],
              small_amplitude_ratio = (max(peaks[, k_small]) / I_eq - 1) / amps[k_small],
              half_log_range_per_amplitude_small = half_log_range(k_small),
              half_log_range_per_amplitude_0.05 = half_log_range(k5),
              leaving_rate_over_angular_frequency = gm / (2 * pi)), 4))
                    small_amplitude               small_amplitude_ratio 
                             0.0050                              5.8197 
 half_log_range_per_amplitude_small   half_log_range_per_amplitude_0.05 
                             5.7762                              5.7594 
leaving_rate_over_angular_frequency 
                             4.4718 

Take the amplitude of 0.05 first, where the system still runs a single annual cycle. Transmission varies by 5 per cent above and below its mean. Peak prevalence is 1.3079 times the unforced equilibrium, an increase of 30.7895 per cent, 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 per cent wobble in contact rate has become a near doubling in prevalence.

The amplification is not a resonance. Seasonal forcing has a period of exactly one year and the free oscillation the first section measured has a period of 2.09882 years, so the forcing sits close to twice the natural frequency, which for an oscillator pushed from outside is well above resonance, where the response is small rather than large. To first order in the amplitude the forcing here is such a push: at equilibrium \(\beta_0 S = \gamma + \mu\), so the seasonal term adds \(a (\gamma + \mu) \cos(2 \pi t)\) to the growth rate of log prevalence. With the susceptible pool held still, that term would make log prevalence a sine wave over the year with a half range of \(a (\gamma + \mu) / (2 \pi)\), and \((\gamma + \mu) / (2 \pi)\) is 4.47, large because hosts leave the infectious class fast. The pull of the susceptible pool, which drives the free oscillation, raises that half range by a further factor of only 1.29. The response is nearly linear in the logarithm: half the range of log prevalence over the year is 5.7762 times the amplitude at an amplitude of 0.005 and 5.7594 times here, a little below the period doubling further down this page. In percentage terms the prevalence response at the smaller amplitude is 5.8197 times the driver; the ratio is larger here only because a percentage change in prevalence grows faster than the change in its logarithm, not because of the nearby instability that splits the annual cycle in two.

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 34.4850 in the next, a ratio of 6.6084, 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 less than a sixth 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  3.408e-01     106.1470        75.4899
13    0.0560              2  4.075e-01     109.7400        73.0125
14    0.0565              2  4.643e-01     112.8950        70.9655
15    0.0570              2  5.145e-01     115.7580        69.2015
16    0.0575              2  5.599e-01     118.4070        67.6423
17    0.0580              2  6.016e-01     120.8930        66.2399
18    0.0585              2  6.404e-01     123.2450        64.9622
19    0.0590              2  6.767e-01     125.4890        63.7866
20    0.0595              2  7.109e-01     127.6390        62.6975
21    0.0600              2  7.433e-01     129.7080        61.6808
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. It can be measured directly: find the annual cycle as the fixed point of the map that carries the state forward one year, by Newton’s method on the same integrator, and take the eigenvalues of that map’s Jacobian there, from central differences.

mult_grid <- round(seq(0.045, 0.056, by = 0.0005), 5)
year_map <- function(x, a) {
  z <- sir_run(x[1], exp(x[2]), a, burn = 0, keep = 1, spy = spy_use)
  c(z$S[spy_use, 1], log(z$I[spy_use, 1]))
}
annual_multiplier <- function(a, h = 1e-6) {
  jac <- function(x) sapply(1:2, function(i) {
    e <- replace(c(0, 0), i, h)
    (year_map(x + e, a) - year_map(x - e, a)) / (2 * h)
  })
  x <- c(S_eq, log(I_eq))
  for (k in 1:50) x <- year_map(x, a)
  for (it in 1:20) {
    step <- solve(jac(x) - diag(2), year_map(x, a) - x)
    x <- x - step
    if (max(abs(step)) < 1e-12) break
  }
  max(Mod(eigen(jac(x))$values))
}
mult_tab <- data.frame(amplitude = mult_grid,
                       multiplier = sapply(mult_grid, annual_multiplier))
mult_tab$log_multiplier <- log(mult_tab$multiplier)
print(signif(mult_tab, 6))
   amplitude multiplier log_multiplier
1     0.0450   0.946645   -0.054831200
2     0.0455   0.949764   -0.051541800
3     0.0460   0.952839   -0.048309300
4     0.0465   0.955874   -0.045129400
5     0.0470   0.958871   -0.041998400
6     0.0475   0.961835   -0.038912700
7     0.0480   0.964766   -0.035869300
8     0.0485   0.967669   -0.032865600
9     0.0490   0.970544   -0.029899000
10    0.0495   0.973393   -0.026967300
11    0.0500   0.976219   -0.024068500
12    0.0505   0.979022   -0.021200700
13    0.0510   0.981805   -0.018362300
14    0.0515   0.984569   -0.015551700
15    0.0520   0.987314   -0.012767500
16    0.0525   0.990041   -0.010008500
17    0.0530   0.992753   -0.007273310
18    0.0535   0.995449   -0.004560950
19    0.0540   0.998131   -0.001870350
20    0.0545   1.000800    0.000799441
21    0.0550   1.003460    0.003449310
22    0.0555   1.006100    0.006080070
23    0.0560   1.008730    0.008692510
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.054000                     0.054500 
          critical_amplitude          multiplier_at_0.054 
                    0.054350                     0.998131 
       decay_over_1000_years grid_answer_below_by_percent 
                    0.154070                     0.644486 

The multiplier rises smoothly through the sweep and crosses one between amplitudes of 0.0540 and 0.0545, at 0.054350 by linear interpolation. At the amplitude the grid recipe reported, 0.054, the multiplier is 0.9981, so an alternation shrinks to 0.154070 of its starting size over a thousand years. The burn-in removed most of a transient and the classification rule counted what was left. The two answers differ by 0.644486 per cent of the amplitude, which is small, but the lesson is not: a period doubling located by looking at a finite record is easily located early, because a transient that dies this slowly looks like a new attractor, and the only fix is to measure the stability rather than the state.

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 
                   61.68075                     2.10289 

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 61.68075 per hundred thousand, a ratio of 2.10289. 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 0.225. The six distinct annual peaks at an amplitude of 0.205 are an exception: a six year cycle that coexists there with the two year one, and the single starting point of the sweep lies in its basin. Then the period doubling cascade continues: three distinct annual peaks at 0.23, from a four year cycle whose two large years differ by less than the 1 per cent tolerance, four at 0.25, and beyond 0.26 the annual peaks rarely repeat: at an amplitude of 0.265 only 37 of the 40 years have an epidemic peak at all, and they take 34 distinct values; at 0.30 it is 22 distinct values in 30 years with a peak. 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)
bif <- bif[!is.na(bif$peak), ]

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(labels = function(x) format(x, scientific = FALSE, drop0trailing = TRUE, trim = TRUE)) +
  coord_cartesian(ylim = c(10, 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 two per cent, apart from one column of six points just above twenty, then the lower branch splits again into several and the whole finally breaks 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 peak at all and are not plotted, and some 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 50,000 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 200,000 the trough holds 22.5 and fadeout still happens in 0.92 of replicates. The probability passes one half near 500,000, where it is 0.53, and is below it by 700,000, where it is 0.43; with 100 replicates a value that close to one half can land either side of it, so which grid step the crossing falls on is not firm. At 2,000,000 hosts and above none of the 100 replicates lost the pathogen over ten years.

The critical community size on this grid is therefore 700,000, 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 several dozen lineages and several months to run before the susceptible pool refills. That is the mechanism, and it is why the answer should scale 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 missing latent period does not explain the gap: an exposed class lengthens the generation interval and drives prevalence further down between epidemics, and on the mechanism above a deeper trough raises the critical size rather than lowering it. The two numbers are also not measured the same way. Bartlett read fadeout from the notifications of real towns, which the pathogen could re-enter from outside, while this model is a closed population with one set of rates and a cosine forcing, and this post does not measure which of those differences accounts for the gap. The SEIR model and the latent period tutorial adds the exposed class that this model leaves out.

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 falls to 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 \(6.4384 \times 10^{-12}\). In the population of 700,000 where the stochastic model put the critical community size, that is \(4.5069 \times 10^{-6}\) infected hosts. Not a count of hosts, and not a fraction of one that you might round up: about 4.5 millionths of a single individual. To hold one infectious host at that trough the population would have to number 155.32 billion, 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 \(6.4384 \times 10^{-7}\) per hundred thousand, a fall by a factor of 17.463 million, 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 for the strongest forcing, where the troughs are this deep, carries this caveat: the attractor exists, and no finite population would ever ride it, because the pathogen would be gone. The biennial regime at moderate forcing is not in that position: at 0.15 the trough is a sensible number of hosts, and the largest populations above kept the pathogen through all ten years.

There is a second limit that the stochastic model does not repair. Its answer, a critical community size of 700,000, 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.054350 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 adds the latent period to a single unforced epidemic, so this question is still open here. 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 updates by email

An occasional email when tutorials are added or substantially corrected. No spam; unsubscribe anytime.

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