Skip to content

SIR with optimal vaccination

An SIR epidemic with the vaccination rate that minimises the burden of infections and vaccines.

  • Name: sir_vaccination
  • Field: biology/epidemiology
  • Formalism: ordinary differential equations
  • References: Lenhart and Workman 2007

Factory

@register_model(
    field='biology/epidemiology',
    tags=('optimal control', 'vaccination', 'sir', 'pontryagin', 'ode'),
    references=('Lenhart and Workman 2007',),
    defaults={'until': 30.0, 'dt': 0.2, 'solver': PontryaginSolver(objective=_cost)},
    observables=lambda system: [ObservableSpec('vaccinated', _vaccinated), ObservableSpec('cost', _cost)],
)
def sir_vaccination(
    S: float = 0.99,
    I: float = 0.01,
    *,
    beta: float = 0.5,
    gamma: float = 0.1,
    a: float = 1.0,
    b: float = 1.0,
) -> System:
    """Return an SIR epidemic with the vaccination rate that minimises the burden of infections and vaccines.

    Run with an optimal control solver, vaccination is strongest early, while many are susceptible, and the epidemic
    peaks lower than without vaccination. With no weight `a` on the infections the optimal rate is 0, the epidemic
    without control.

    Args:
        S:
            The initial susceptible fraction.

        I:
            The initial infected fraction.

        beta:
            The transmission rate.

        gamma:
            The recovery rate.

        a:
            The weight of the infected fraction in the cost.

        b:
            The weight of the squared vaccination rate in the cost.

    Returns:
        system:
            The model, with the fractions `population.S`, `population.I`, and `population.R`, the vaccination rate
            `population.u`, and the observables `vaccinated` and `cost`, the running cost.

    Examples:
        >>> from skcomplex.models.biology.epidemiology import sir_vaccination
        >>> sir_vaccination(beta=0.3).elements[0].beta
        0.3
    """
    population = VaccinatedPopulationElement(
        'population',
        S=S,
        I=I,
        R=max(1 - S - I, 0.0),
        beta=beta,
        gamma=gamma,
        a=a,
        b=b,
    )
    return System(elements=[population])

Components

class VaccinatedPopulationElement(Element):
    """Susceptible, infected, and recovered fractions of a population vaccinated at a bounded rate.

    The fractions follow `S' = -beta S I - u S`, `I' = beta S I - gamma I`, and `R' = gamma I + u S`, where the
    vaccination rate `u` is a control input between 0 and its largest value. The planner minimises the burden of the
    infections and the cost of the vaccines, `∫ (a I + b u^2 / 2) dt`, after Lenhart and Workman.

    Args:
        label:
            The label of the population.

        visual:
            How the element is drawn, or `None` for `default_visual_`.

        S:
            The initial susceptible fraction.

        I:
            The initial infected fraction.

        R:
            The initial recovered fraction.

        u:
            The vaccination rate, the control input.

        beta:
            The transmission rate.

        gamma:
            The recovery rate.

        a:
            The weight of the infected fraction in the cost.

        b:
            The weight of the squared vaccination rate in the cost.

    Examples:
        >>> from skcomplex.models.biology.epidemiology import VaccinatedPopulationElement
        >>> VaccinatedPopulationElement('population', I=0.01).I
        0.01
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        S: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.99,
        I: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.01,
        R: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
        u: Annotated[float, ParameterSpec(adaptive=True, bounds=(0.0, MAX_RATE))] = 0.0,
        beta: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.5,
        gamma: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
        a: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
        b: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.S = S
        self.I = I
        self.R = R
        self.u = u
        self.beta = beta
        self.gamma = gamma
        self.a = a
        self.b = b

    def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
        """Return the flows between the fractions.

        Args:
            s:
                The namespace of the symbols the laws can use.

        Returns:
            laws:
                The laws of the component.
        """
        infection, recovery, vaccination = s.beta * s.S * s.I, s.gamma * s.I, s.u * s.S
        return [
            RateLaw(s.S, -infection - vaccination),
            RateLaw(s.I, infection - recovery),
            RateLaw(s.R, recovery + vaccination),
        ]

Equations

population.S: diff(S_population(t), t) == -beta_population*I_population(t)*S_population(t) - S_population(t)*u_population(t)
population.I: diff(I_population(t), t) == beta_population*I_population(t)*S_population(t) - gamma_population*I_population(t)
population.R: diff(R_population(t), t) == gamma_population*I_population(t) + S_population(t)*u_population(t)
population.u: diff(u_population(t), t) == 0