Source code for laser.measles.abm.components.process_vital_dynamics
"""
Component defining the VitalDynamicsProcess, which simulates the vital dynamics in the ABM model with MCV1.
"""
import numpy as np
from laser.core import SortedQueue
from pydantic import Field
from laser.measles.abm.model import ABMModel
from laser.measles.components import BaseVitalDynamicsParams
from laser.measles.components import BaseVitalDynamicsProcess
from laser.measles.utils import cast_type
class VitalDynamicsParams(BaseVitalDynamicsParams):
"""
Parameters for VitalDynamicsProcess.
"""
routine_immunization_delay: int = Field(default=9 * 30, description="Delay in days before routine immunization is administered")
[docs]
class VitalDynamicsProcess(BaseVitalDynamicsProcess):
"""
Process for simulating vital dynamics in the ABM model with MCV1 and constant birth and mortality rates (not age-structured).
"""
def __init__(self, model, verbose: bool = False, params: VitalDynamicsParams | None = None) -> None:
params_: VitalDynamicsParams = params or VitalDynamicsParams()
super().__init__(model, verbose=verbose, params=params_)
# re-initialize people frame with correct capacity
capacity = self.calculate_capacity(model=model)
model.initialize_people_capacity(capacity=capacity, initial_count=model.scenario["pop"].sum())
people = model.people
patches = model.patches
date_of_birth_dtype = np.int32
self.null_value = np.iinfo(date_of_birth_dtype).max
people.add_scalar_property("active", dtype=np.bool, default=False)
people.add_scalar_property("date_of_birth", dtype=date_of_birth_dtype, default=self.null_value)
people.add_scalar_property("date_of_vaccination", dtype=np.uint32, default=self.null_value)
patches.add_scalar_property("births", dtype=np.uint32)
self.vaccination_queue: SortedQueue = SortedQueue(capacity=capacity, values=people.date_of_vaccination)
if model.params.num_ticks >= self.null_value:
raise ValueError("Simulation is too long; birth and vaccination dates must be able to store the number of ticks")
[docs]
def __call__(self, model, tick: int) -> None:
"""
Simulate the vital dynamics process.
"""
patches = model.patches
people = model.people
population = patches.states.sum(axis=0)
# Deaths
# ------
# Calculate number of deaths
deaths = model.prng.poisson(population.sum() * self.mu_death) # over all patches
# select agents
idx = model.prng.choice(np.where(model.people.active)[0], size=deaths, replace=False)
# deactivate agents
model.people.active[idx] = False
# update state counter
for state_idx, _ in enumerate(model.params.states):
mask = model.people.state[idx] == state_idx # mask of in-active agents in the current state
cnt = np.bincount(model.people.patch_id[idx][mask], minlength=model.patches.states.shape[-1])
model.patches.states[state_idx] -= cast_type(cnt, model.patches.states.dtype)
# Reset dead agents' epidemic state to prevent ghost processing by downstream components.
# Without this, agents deactivated mid-epidemic retain non-zero exposure/infection timers
# and susceptible-state flags, causing DiseaseProcess to generate spurious E→I and I→R
# transitions that decrement patches.states below zero (uint32 underflow).
if len(idx) > 0:
r_state = np.uint8(model.params.states.index("R"))
model.people.state[idx] = r_state
if hasattr(model.people, "etimer"):
model.people.etimer[idx] = 0
if hasattr(model.people, "itimer"):
model.people.itimer[idx] = 0
if self.lambda_birth > 0:
# Births
# ------
# Calculate number of births
births = model.prng.poisson(population * self.lambda_birth) # in each patch
# find indices of the people frame for initializing
istart, iend = people.add(births.sum())
people.active[istart:iend] = True # mark newborns as active
people.date_of_birth[istart:iend] = tick # born today
people.susceptibility[istart:iend] = 1.0 # all newborns are susceptible TODO: add maternal immunity component
people.date_of_vaccination[istart:iend] = tick + self._routine_immunization_delay()
index = istart
# update patch id
for this_patch_id, this_patch_births in enumerate(births):
people.patch_id[index : index + this_patch_births] = this_patch_id
index += this_patch_births
# update states
patches.states.S += cast_type(births, patches.states.dtype)
# Routine immunization
# --------------------
while len(self.vaccination_queue) > 0 and people.date_of_vaccination[self.vaccination_queue.peeki()] <= tick:
i = self.vaccination_queue.popi()
people.susceptibility[i] = 0.0 # susceptibility
people.state[i] = model.params.states.index("R") # move to recovered state
[docs]
def calculate_capacity(self, model) -> int:
"""Estimate the necessary capacity of the people laserframe."""
rate = self.lambda_birth # - self.mu_death # calculated per tick
buffered_ticks = (model.params.num_ticks * model.params.time_step_days // 365 + 1) * 365 / model.params.time_step_days
N = model.scenario["pop"].to_numpy() * np.exp(rate * buffered_ticks)
return int(N.sum())
def _routine_immunization_delay(self) -> int:
"""Delay in ticks before routine immunization is administered."""
params: VitalDynamicsParams = self.params # type: ignore
return params.routine_immunization_delay * self.model.params.time_step_days
def _initialize(self, model: ABMModel) -> None:
# initialize the people laserframe with correct capacity
# initial_pop = model.scenario["pop"].sum()
# model.initialize_people_capacity(capacity=self.calculate_capacity(model), initial_count=initial_pop)
# Activate population
model.people.active[0 : model.people.count] = True
# Simple initializer for ages where birth rate = mortality rate:
# Initialize ages for existing population
if self.mu_death > 0:
model.people.date_of_birth[0 : model.people.count] = cast_type(
-1 * model.prng.exponential(1 / self.mu_death, model.people.count), model.people.date_of_birth.dtype
)