# Find project root and include ensure_packages.jl
project_root = let
current = pwd()
while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml"))
parent = dirname(current)
parent == current && break
current = parent
end
current
end
include(joinpath(project_root, "scripts", "ensure_packages.jl"))
# Load Agents.jl first to ensure @agent macro is available
# Agents.jl 6.3.1 provides improved performance and new utilities
using Agents
using Agents: run!, dummystep # Import run! for automatic data collection
using Agents.Schedulers # Import schedulers module
@auto_using Graphs CairoMakie StableRNGs DataFrames
# Activate SVG output for responsive figures
# Set seed for reproducibility
rng = StableRNG(1234)
# Define agent type
# Agents.jl uses struct definitions that subtype AbstractAgent
mutable struct Person <: AbstractAgent
id::Int
pos::Int # Position in graph (node ID)
state::Int # e.g., 0=susceptible, 1=infected, 2=recovered
behavior::Symbol # e.g., :normal, :distancing
end
# Create network (using Graphs.jl)
g = erdos_renyi(50, 0.1, seed = 42) # Random network with 50 agents, 10% connection probability
# Create model with network space
# GraphSpace requires graph and positions (one position per node)
# Use Graphs.nv to avoid namespace conflict with Agents.nv
node_positions = [[i] for i in 1:Graphs.nv(g)] # Each node has its own position
space = Agents.GraphSpace(g, node_positions)
# Agent step function: agent makes decision based on neighbors
# This function will be passed to the model constructor (Agents 6.3.1)
function agent_step!(agent, model)
# Get RNG from model (Agents 6.3.1)
rng = model.rng
# Get neighbors (agents connected in network)
node_id = agent.pos # Agent's position in network (node ID)
# Access graph from model space using getfield (bypasses getproperty override)
graph = getfield(model, :space).graph
neighbor_nodes = Graphs.neighbors(graph, node_id) # Neighbor nodes in graph (use Graphs.neighbors)
# Access agents dict using getfield (bypasses getproperty override)
agents_dict = getfield(model, :agents)
# Use model[id] syntax (more efficient in Agents 6.3.1)
neighbors_agents = [model[id] for id in neighbor_nodes if haskey(agents_dict, id)]
# Simple decision rule: if any neighbor is infected, agent may change behaviour
infected_neighbors = [n for n in neighbors_agents if n.state == 1]
if length(infected_neighbors) > 0 && agent.behavior == :normal
# Agent responds to infection risk
if rand(rng) < 0.3 # 30% chance of adopting distancing
agent.behavior = :distancing
end
end
# State dynamics: infection spreads through network
if agent.state == 0 # Susceptible
for neighbor in neighbors_agents
if neighbor.state == 1 && agent.behavior == :normal # Infected neighbor, normal behaviour
if rand(rng) < 0.1 # 10% transmission probability
agent.state = 1 # Become infected
break
end
end
end
elseif agent.state == 1 # Infected
if rand(rng) < 0.05 # 5% recovery probability per step
agent.state = 2 # Recover
end
end
end
# Model step function: system-level updates
# This function will be passed to the model constructor (Agents 6.3.1)
function model_step!(model)
# Could add system-level dynamics here
# For this example, no model-level updates needed
end
# ABM takes space as positional argument, scheduler and properties as keywords
# In Agents 6.3.1, we pass step functions when creating the model
model = ABM(Person, space;
scheduler = Schedulers.Randomly(),
properties = Dict(:rng => rng),
agent_step! = agent_step!,
model_step! = model_step!)
# Add agents to network nodes
# Each agent is placed on a node in the network
for i in 1:50
add_agent!(Person(i, i, 0, :normal), i, model) # Agent with ID i, position i, initial state 0, behavior :normal, on node i
end
# Run simulation
# Initialize: infect a few agents to start epidemic
for i in 1:5
model[i].state = 1 # Infect first 5 agents (more efficient in Agents 6.3.1)
end
# Run simulation and collect data
# Agents.jl 6.3.1 provides run! with automatic data collection and progress meter
# This is more efficient than manual loops and provides better performance
using Agents: run!, dummystep
# run! automatically collects agent and model data
# In Agents 6.3.1, step functions are stored in the model, so we don't pass them to run!
# adata: agent-level data to collect (state, behavior)
# Returns DataFrame with step, id, and requested agent properties
# Benefits: Automatic data collection, progress meter, optimized performance
data, _ = run!(model, 100;
adata = [:state, :behavior],
showprogress = false) # Set to true to show progress meter for long simulations
# Aggregate by time step for visualization
# Note: run! returns DataFrame with "time" column (not "step")
data_agg = combine(groupby(data, :time),
:state => (x -> sum(x .== 0)) => :susceptible,
:state => (x -> sum(x .== 1)) => :infected,
:state => (x -> sum(x .== 2)) => :recovered,
:behavior => (x -> sum(x .== :normal)) => :normal,
:behavior => (x -> sum(x .== :distancing)) => :distancing
)| Row | time | susceptible | infected | recovered | normal | distancing |
|---|---|---|---|---|---|---|
| Int64 | Int64 | Int64 | Int64 | Int64 | Int64 | |
| 1 | 0 | 45 | 5 | 0 | 50 | 0 |
| 2 | 1 | 41 | 9 | 0 | 41 | 9 |
| 3 | 2 | 38 | 11 | 1 | 34 | 16 |
| 4 | 3 | 37 | 10 | 3 | 27 | 23 |
| 5 | 4 | 37 | 10 | 3 | 24 | 26 |
| 6 | 5 | 37 | 10 | 3 | 22 | 28 |
| 7 | 6 | 37 | 9 | 4 | 20 | 30 |
| 8 | 7 | 36 | 10 | 4 | 19 | 31 |
| 9 | 8 | 35 | 10 | 5 | 18 | 32 |
| 10 | 9 | 35 | 10 | 5 | 14 | 36 |
| 11 | 10 | 35 | 9 | 6 | 13 | 37 |
| 12 | 11 | 35 | 8 | 7 | 13 | 37 |
| 13 | 12 | 35 | 8 | 7 | 12 | 38 |
| ⋮ | ⋮ | ⋮ | ⋮ | ⋮ | ⋮ | ⋮ |
| 90 | 89 | 35 | 0 | 15 | 12 | 38 |
| 91 | 90 | 35 | 0 | 15 | 12 | 38 |
| 92 | 91 | 35 | 0 | 15 | 12 | 38 |
| 93 | 92 | 35 | 0 | 15 | 12 | 38 |
| 94 | 93 | 35 | 0 | 15 | 12 | 38 |
| 95 | 94 | 35 | 0 | 15 | 12 | 38 |
| 96 | 95 | 35 | 0 | 15 | 12 | 38 |
| 97 | 96 | 35 | 0 | 15 | 12 | 38 |
| 98 | 97 | 35 | 0 | 15 | 12 | 38 |
| 99 | 98 | 35 | 0 | 15 | 12 | 38 |
| 100 | 99 | 35 | 0 | 15 | 12 | 38 |
| 101 | 100 | 35 | 0 | 15 | 12 | 38 |
Basic multiagent network: agents interacting on a network structure