diff --git a/examples/pathogen_outbreak/README.md b/examples/pathogen_outbreak/README.md new file mode 100644 index 000000000..3b3a08788 --- /dev/null +++ b/examples/pathogen_outbreak/README.md @@ -0,0 +1,80 @@ +# Pathogen Outbreak & Quarantine Compliance Model + +A fast-spreading pathogen outbreak model with no exposed/latency period +inspired loosely from the game Plague Inc, though the goal here is containment +rather than world domination. The model simulates how quarantine +compliance rates at the population level affect outbreak dynamics, +analyzing changes in infection, death and immunity rates. + +**It showcases:** + +- **Two-threshold quarantine system** - quarantine triggers when infected + count exceeds an upper threshold and lifts only when it drops below a + lower one, preventing quarantine from lifting the moment a single agent + recovers +- **Compliance rate** - a configurable fraction of citizens actually follow + quarantine orders, attempting to simulate real-world partial adherence. The rest + (shown as squares) keep moving freely by not following quarantine instructions. +- **Flee behaviour** — compliant healthy agents moves away from quarantined zones during lockdown. +- **Infected agents freeze** — simulating a government isolating/quarantining an infected + zone. Non-compliant agents ignore this entirely. +- **Emergence of different outcomes** — combination of different configurations produce dramatically different outbreak curves. + +## How It Works + +1. **Initialization** — citizens are placed randomly on a MultiGrid. + A configurable number are set initially as infected. +2. **Disease Spread** — each step, healthy agents check their neighbours including diagonals. + If any are infected, there is a configurable chance of transmission. + No latency period — infection is immediate on contact,chance of getting an infection is 60%. +3. **Quarantine System** — the model monitors total infected count each step. + When it exceeds the upper threshold, quarantine activates. It only lifts + when infected drops below the lower threshold. +4. **Agent Behaviour during Quarantine:** + - Compliant agents(circles) - use Manhattan distance + to flee away from all infected agents. + - Non-compliant agents(squares) — move randomly, ignoring quarantine + - Infected agents - compliant ones, freeze in place, simulating isolation or a lockdowned zone +5. **Recovery** — after 10 steps of infection, agents recover to full + immunity or die with the probability of 10%. Dead agents remain + on the grid as red circles/squares(this is an intentional mechanic) as a visual indicator to assess how compliance affects the compliant as well as non-compliant groups. + +## Interesting Cases to Observe + +| Scenario | Parameters | What to observe | +|----------|-----------|-----------------| +| No compliance | `compliance=0.0` | Unconstrained outbreak, maximum spread | +| Full compliance | `compliance=1.0` | Quarantine collapses the outbreak | +| Partial compliance | `compliance=0.5` | Realistic middle ground | +| Late quarantine | `quarantine_threshold_strt=60` | Triggers too late to matter | +| Dense population | `n=240, width=25` | Flee behaviour is constrained by crowding and even quarantine doesn't help | +| Sparse population| `n=70, width=25` | May stop the outbreak almost immediately or sometimes quarantine doesn't trigger this causes slow wide spread infection| + +The model proposed here is simple and often can provide some quite unexpected outcomes in certain configurations. It is recommended to play around with the model parameters as outcomes can be dramatic even with small changes. + +| Compliance = 0.0 | Compliance = 1.0 | +|-----------------|-----------------| +| ![no compliance](images/no_compliance.png) | ![full compliance](images/full_compliance.png) | + +| Dense Population (n=240, width=25) | Sparse Population (n=80, width=25) | +|-----------------------------------|-----------------------------------| +| ![dense](images/dense_population.png) | ![sparse](images/sparse_population.png) | + +## Usage +``` +pip install -r requirements.txt +solara run app.py +``` + +## Default Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `n` | Number of citizens | 100 | +| `infn` | Initially infected | 5 | +| `width` / `height` | Grid dimensions | 20 x 20 | +| `spread_chance` | Transmission probability on contact | 0.6 | +| `death_chance` | probability of death after infection(only once) | 0.1 | +| `compliance` | Fraction of citizens who follow quarantine | 0.25 | +| `quarantine_threshold_strt` | Infected count that triggers quarantine | 25 | +| `quarantine_threshold_stp` | Infected count that lifts quarantine | 5 | \ No newline at end of file diff --git a/examples/pathogen_outbreak/agents.py b/examples/pathogen_outbreak/agents.py new file mode 100644 index 000000000..338299f9b --- /dev/null +++ b/examples/pathogen_outbreak/agents.py @@ -0,0 +1,85 @@ +import random + +from mesa import Agent + + +class Citizen(Agent): + def __init__(self, model): + super().__init__(model) + + self.state = "healthy" + self.stage = 0 + self.compliant = random.random() < self.model.compliance_rate + + def step(self): + if self.model.quarantine_status and self.compliant: + self.quarantine() + else: + self.move() + + if self.state == "infected": + self.stage += 1 + if self.stage > 9: + chance_of_death = random.random() + + if chance_of_death < 0.1: + self.state = "dead" + # self.remove() + + else: + self.state = "immune" + + cell_state = "healthy" + + cell_neigh = self.model.grid.get_neighbors( + self.pos, moore=True, include_center=False + ) + + for i in cell_neigh: + if i.state == "infected": + cell_state = "infected" + + if self.state == "healthy" and cell_state == "infected": + chances = random.random() + if chances > 0.40: + self.state = "infected" + + def quarantine(self): + if self.state != "dead": + if self.state == "infected": + return + else: + infected_pos = [] + for i in self.model.agents: + if i.state == "infected": + infected_pos.append(i.pos) + + possible_ops = self.model.grid.get_neighborhood( + self.pos, moore=False, include_center=False + ) + emptys = [] + for i in possible_ops: + if self.model.grid.is_cell_empty(i): + emptys.append(i) + + if len(emptys) > 0: + best_cell = max( + emptys, + key=lambda pos: min( + abs(pos[0] - i[0]) + abs(pos[1] - i[1]) + for i in infected_pos + ), + ) + self.model.grid.move_agent(self, best_cell) + + def move(self): + if self.state != "dead": + possible_ops = self.model.grid.get_neighborhood( + self.pos, moore=False, include_center=False + ) + emptys = [] + for i in possible_ops: + if self.model.grid.is_cell_empty(i): + emptys.append(i) + if len(emptys) > 0: + self.model.grid.move_agent(self, self.random.choice(emptys)) diff --git a/examples/pathogen_outbreak/app.py b/examples/pathogen_outbreak/app.py new file mode 100644 index 000000000..b9401e680 --- /dev/null +++ b/examples/pathogen_outbreak/app.py @@ -0,0 +1,65 @@ +from mesa.visualization import SolaraViz, make_plot_component, make_space_component +from model import PathogenModel + + +def make_agent(agent): + color_map = { + "healthy": "tab:green", + "infected": "tab:orange", + "immune": "tab:blue", + "dead": "tab:red", + } + return { + "color": color_map.get(agent.state, "black"), + "size": 20, + "marker": "s" if not agent.compliant else "o", + } + + +model_params = { + "compliance": { + "type": "SliderFloat", + "value": 0.7, + "label": "Quarantine Compliance Rate", + "min": 0.0, + "max": 1.0, + "step": 0.1, + }, + "n": { + "type": "SliderInt", + "value": 100, + "label": "Citizens", + "min": 10, + "max": 300, + "step": 10, + }, + "infn": { + "type": "SliderInt", + "value": 5, + "label": "Infected", + "min": 1, + "max": 20, + "step": 1, + }, + "width": {"type": "SliderInt", "value": 20, "label": "Width", "min": 10, "max": 50}, + "height": { + "type": "SliderInt", + "value": 20, + "label": "Height", + "min": 10, + "max": 50, + }, +} + +model = PathogenModel(n=100, infn=5, width=25, height=25) + +renderer = make_space_component(make_agent) + +graph_ot = make_plot_component(["healthy", "infected", "immune", "dead", "quarantine"]) + +page = SolaraViz( + model, + components=[renderer, graph_ot], + model_params=model_params, + name="Compliance/Quarantine during Outbreak Model", +) diff --git a/examples/pathogen_outbreak/images/dense_population.png b/examples/pathogen_outbreak/images/dense_population.png new file mode 100644 index 000000000..debcf5f49 Binary files /dev/null and b/examples/pathogen_outbreak/images/dense_population.png differ diff --git a/examples/pathogen_outbreak/images/full_compliance.png b/examples/pathogen_outbreak/images/full_compliance.png new file mode 100644 index 000000000..ef3038591 Binary files /dev/null and b/examples/pathogen_outbreak/images/full_compliance.png differ diff --git a/examples/pathogen_outbreak/images/no_compliance.png b/examples/pathogen_outbreak/images/no_compliance.png new file mode 100644 index 000000000..ae5209b10 Binary files /dev/null and b/examples/pathogen_outbreak/images/no_compliance.png differ diff --git a/examples/pathogen_outbreak/images/sparse_population.png b/examples/pathogen_outbreak/images/sparse_population.png new file mode 100644 index 000000000..ae33fb695 Binary files /dev/null and b/examples/pathogen_outbreak/images/sparse_population.png differ diff --git a/examples/pathogen_outbreak/model.py b/examples/pathogen_outbreak/model.py new file mode 100644 index 000000000..85185beaf --- /dev/null +++ b/examples/pathogen_outbreak/model.py @@ -0,0 +1,74 @@ +import random + +from mesa import DataCollector, Model +from mesa.space import MultiGrid + +from .agents import Citizen + + +def c_healthy(model): + return sum(1 for i in model.agents if i.state == "healthy") + + +def c_infected(model): + return sum(1 for i in model.agents if i.state == "infected") + + +def c_immune(model): + return sum(1 for i in model.agents if i.state == "immune") + + +def c_dead(model): + return sum(1 for i in model.agents if i.state == "dead") + + +class PathogenModel(Model): + def __init__( + self, + n=100, + infn=5, + width=20, + height=20, + quarantine_threshold_strt=25, + quarantine_threshold_stp=5, + compliance=0.25, + ): + super().__init__() + self.compliance_rate = compliance + self.quarantine_thresh_up = quarantine_threshold_strt + self.quarantine_thresh_lw = quarantine_threshold_stp + self.infected_count = 0 + self.quarantine_status = False + + self.grid = MultiGrid(width, height, False) + + self.datacollector = DataCollector( + model_reporters={ + "healthy": c_healthy, + "immune": c_immune, + "infected": c_infected, + "dead": c_dead, + "quarantine": lambda i: int(i.quarantine_status), + } + ) + + for _ in range(n): + agent = Citizen(self) + self.grid.place_agent( + agent, (random.randrange(width), random.randrange(height)) + ) + + for i in range(infn): + self.agents[i].state = "infected" + + def step(self): + self.infected_count = c_infected(self) + + if self.infected_count > self.quarantine_thresh_up: + self.quarantine_status = True + elif self.infected_count < self.quarantine_thresh_lw: + self.quarantine_status = False + + self.datacollector.collect(self) + + self.agents.do("step") diff --git a/examples/pathogen_outbreak/requirements.txt b/examples/pathogen_outbreak/requirements.txt new file mode 100644 index 000000000..6f283135f --- /dev/null +++ b/examples/pathogen_outbreak/requirements.txt @@ -0,0 +1,3 @@ +mesa>=3.5 +solara +matplotlib \ No newline at end of file