crisis_world / server /dynamics.py
Angshuman28's picture
Upload folder using huggingface_hub
9a82298 verified
Raw
History Blame Contribute Delete
4.72 kB
"""SIR epidemiological model (discrete-time stochastic) for CrisisWorld."""
from __future__ import annotations
from math import exp
import numpy as np
from ..models import RegionState
from ._internal import EpiParams
def _advance_single_region(
region: RegionState,
params: EpiParams,
rng: np.random.Generator,
) -> RegionState:
"""Intra-region SIR step. Returns updated region (new object)."""
if region.infected == 0:
return region
pop = region.population
s = pop - region.infected - region.recovered - region.deceased
if s <= 0:
# No susceptible left -- only recovery and death
new_infections = 0
else:
effective_beta = params.beta * 0.5 if region.restricted else params.beta
p_infect = 1.0 - exp(-effective_beta * region.infected / pop)
p_infect = max(0.0, min(1.0, p_infect))
new_infections = int(rng.binomial(s, p_infect))
new_recoveries = int(rng.binomial(region.infected, params.gamma))
new_deaths = int(rng.binomial(region.infected, params.mu))
# Clamp: recoveries + deaths cannot exceed current infected
total_outflow = new_recoveries + new_deaths
if total_outflow > region.infected:
scale = region.infected / total_outflow
new_recoveries = int(new_recoveries * scale)
new_deaths = region.infected - new_recoveries
infected = max(0, region.infected + new_infections - new_recoveries - new_deaths)
recovered = max(0, region.recovered + new_recoveries)
deceased = max(0, region.deceased + new_deaths)
# Final safety: ensure I+R+D <= population
total = infected + recovered + deceased
if total > pop:
excess = total - pop
infected = max(0, infected - excess)
return region.model_copy(
update={
"infected": infected,
"recovered": recovered,
"deceased": deceased,
}
)
def _compute_spillovers(
regions: tuple[RegionState, ...],
adjacency: dict[str, list[str]],
params: EpiParams,
rng: np.random.Generator,
) -> dict[str, int]:
"""Compute inter-region spillover infections.
Returns a mapping of region_id -> total incoming spillover count.
"""
by_id = {r.region_id: r for r in regions}
incoming: dict[str, int] = {r.region_id: 0 for r in regions}
for region in regions:
if region.infected == 0:
continue
neighbors = adjacency.get(region.region_id, [])
for neighbor_id in neighbors:
spillover = int(rng.binomial(region.infected, params.inter_region_spread))
if region.restricted:
spillover = int(spillover * 0.1)
# Cap at target susceptible count
target = by_id.get(neighbor_id)
if target is None:
continue
target_s = target.population - target.infected - target.recovered - target.deceased
spillover = min(spillover, max(0, target_s))
incoming[neighbor_id] += spillover
return incoming
def advance_epi_state(
regions: tuple[RegionState, ...],
adjacency: dict[str, list[str]],
params: EpiParams,
rng: np.random.Generator,
) -> tuple[RegionState, ...]:
"""Advance all regions by one SIR time step.
1. Intra-region dynamics (infection, recovery, death).
2. Inter-region spillover from adjacent infected regions.
All stochastic draws use the provided ``rng`` for reproducibility.
"""
# Early exit: nothing to do if no region is infected
if all(r.infected == 0 for r in regions):
return regions
# Phase 1: intra-region dynamics
updated = tuple(_advance_single_region(r, params, rng) for r in regions)
# Phase 2: inter-region spillover (computed from *original* regions)
incoming = _compute_spillovers(regions, adjacency, params, rng)
# Apply spillover to updated regions
result: list[RegionState] = []
for r in updated:
spill = incoming.get(r.region_id, 0)
if spill <= 0:
result.append(r)
continue
# Cap spillover at susceptible in the *updated* region
s = r.population - r.infected - r.recovered - r.deceased
spill = min(spill, max(0, s))
new_infected = r.infected + spill
# Final safety clamp
total = new_infected + r.recovered + r.deceased
if total > r.population:
new_infected = r.population - r.recovered - r.deceased
result.append(r.model_copy(update={"infected": max(0, new_infected)}))
return tuple(result)