combine score into one. add some documentation. add energy_threshold to prevent overfeeding

main
Michael Brehm 2024-06-23 20:21:52 +02:00
parent 0e1f7e8a62
commit 28712d3a7c
3 changed files with 567 additions and 169 deletions

2
env/Project.toml vendored
View File

@ -2,4 +2,6 @@
Agents = "46ada45e-f475-11e8-01d0-f70cc89e6671" Agents = "46ada45e-f475-11e8-01d0-f70cc89e6671"
AgentsExampleZoo = "88acaeb2-2f63-4ada-bca2-2825d9da22ed" AgentsExampleZoo = "88acaeb2-2f63-4ada-bca2-2825d9da22ed"
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a"
Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"

View File

@ -1,14 +1,19 @@
using Agents, Random, GLMakie using Agents, Random, GLMakie
# needed to check why the prey dies
@enum DeathCause begin @enum DeathCause begin
Starvation Starvation
Predation Predation
end end
# for defining animals. most of the fields dont get used directly, but converted to model parameters to change them in GLMakie
mutable struct AnimalDefinition mutable struct AnimalDefinition
n::Int32 n::Int32
symbol::Char symbol::Char
color::GLMakie.ColorTypes.RGBA{Float32} color::GLMakie.ColorTypes.RGBA{Float32}
energy_threshold::Float64 reproduction_energy_threshold::Int32
forage_energy_threshold::Int32
energy_usage::Int32
reproduction_prob::Float64 reproduction_prob::Float64
Δenergy::Float64 Δenergy::Float64
perception::Int32 perception::Int32
@ -16,22 +21,27 @@ mutable struct AnimalDefinition
dangers::Vector{String} dangers::Vector{String}
food::Vector{String} food::Vector{String}
end end
# some helper functions to get generated model parameters for animals
reproduction_prop(a) = abmproperties(model)[Symbol(a.def.type*"_"*"reproduction_prob")] reproduction_prop(a) = abmproperties(model)[Symbol(a.def.type*"_"*"reproduction_prob")]
Δenergy(a) = abmproperties(model)[Symbol(a.def.type*"_"*"Δenergy")] Δenergy(a) = abmproperties(model)[Symbol(a.def.type*"_"*"Δenergy")]
perception(a) = abmproperties(model)[Symbol(a.def.type*"_"*"perception")] perception(a) = abmproperties(model)[Symbol(a.def.type*"_"*"perception")]
energy_threshold(a) = abmproperties(model)[Symbol(a.def.type*"_"*"energy_threshold")] reproduction_energy_threshold(a) = abmproperties(model)[Symbol(a.def.type*"_"*"reproduction_energy_threshold")]
#might be better to use @multiagent and @subagent with predator prey as subtypes. Allows to dispatch different functions per kind and change execution order with schedulers.bykind forage_energy_threshold(a) = abmproperties(model)[Symbol(a.def.type*"_"*"forage_energy_threshold")]
energy_usage(a) = abmproperties(model)[Symbol(a.def.type*"_"*"energy_usage")]
# Animal with AnimalDefinition and fields that change during simulation
# might be better to use @multiagent and @subagent with predator prey as subtypes. Allows to dispatch different functions per kind and change execution order with schedulers.bykind
@agent struct Animal(GridAgent{2}) @agent struct Animal(GridAgent{2})
energy::Float64 energy::Float64
def::AnimalDefinition def::AnimalDefinition
death_cause::Union{DeathCause,Nothing} death_cause::Union{DeathCause,Nothing}
nearby_dangers nearby_dangers
nearby_food nearby_food
food_scores scores
danger_scores
end end
# get nearby food and danger for later when choosing the next position
function perceive!(a::Animal,model) function perceive!(a::Animal,model)
if perception(a) > 0 if perception(a) > 0
nearby = collect(nearby_agents(a, model, perception(a))) nearby = collect(nearby_agents(a, model, perception(a)))
@ -42,62 +52,56 @@ function perceive!(a::Animal,model)
end end
end end
end end
# move the animal and subtract energy
function move!(a::Animal,model) function move!(a::Animal,model)
best_pos = calculate_best_pos(a,model) best_pos = choose_position(a,model)
if !isnothing(best_pos) if !isnothing(best_pos)
#make sure predators can step on cells with prey by setting ifempty to false #make sure predators can step on cells with prey, but prey cannot step on other prey
ids = ids_in_position(best_pos, model) ids = ids_in_position(best_pos, model)
if !isempty(ids) && model[first(ids)].def.type a.def.food if !isempty(ids) && model[first(ids)].def.type a.def.food
move_towards!(a, best_pos, model; ifempty = false) move_agent!(a, best_pos, model)
else elseif isempty(best_pos, model)
move_towards!(a, best_pos, model) move_agent!(a, best_pos, model)
end end
else else
randomwalk!(a, model) randomwalk!(a, model)
end end
a.energy -= 1 a.energy -= energy_usage(a)
end end
function calculate_best_pos(a::Animal,model)
danger_scores = [] # choose best position based on scoring
food_scores = [] # could have also used the AStar pathfinding from Agents.jl with custom cost_function, but this seemed easier
positions = collect(nearby_positions(a, model, 1)) function choose_position(a::Animal,model)
# weight scores with utility functions scores = []
positions = push!(collect(nearby_positions(a, model, 1)),a.pos)
for pos in positions for pos in positions
danger_score = 0 score = 0
for danger in a.nearby_dangers for danger in a.nearby_dangers
distance = findmax(abs.(pos.-danger))[1] distance = findmax(abs.(pos.-danger))[1]
if distance != 0 if distance != 0
danger_score=danger_score-1/distance score -= 50/distance
else else
danger_score-=2 score -= 100
end end
end end
food_score = 0
for food in a.nearby_food for food in a.nearby_food
distance = findmax(abs.(pos.-food))[1] if a.energy < forage_energy_threshold(a)
if distance != 0 distance = findmax(abs.(pos.-food))[1]
food_score=food_score+1/distance if distance != 0
else score += 1/distance
food_score+=2 else
score += 2
end
end end
end end
push!(danger_scores,danger_score) push!(scores, score)
push!(food_scores,food_score)
end
a.danger_scores = danger_scores
a.food_scores = food_scores
#findall(==(minimum(x)),x) to find all mins
#best to filter out all positions where there is already an agent and take into account the current position, so sheeps dont just stand still when the position is occupied
if !isempty(a.nearby_dangers) #&& a.energy > a.def.energy_threshold
safest_position = positions[findmax(danger_scores)[2]]
return safest_position
elseif !isempty(a.nearby_food) #&& a.energy < a.def.energy_threshold
foodiest_position = positions[findmax(food_scores)[2]]
return foodiest_position
else
return nothing
end end
a.scores = scores
return positions[rand(abmrng(model), findall(==(maximum(scores)),scores))]
end end
# add energy if predator is on tile with prey, or prey is on tile with grass
function eat!(a::Animal, model) function eat!(a::Animal, model)
prey = first_prey_in_position(a, model) prey = first_prey_in_position(a, model)
if !isnothing(prey) if !isnothing(prey)
@ -112,38 +116,35 @@ function eat!(a::Animal, model)
end end
return return
end end
# dublicate the animal, based on chance and if it has enough energy
function reproduce!(a::Animal, model) function reproduce!(a::Animal, model)
if a.energy > energy_threshold(a) && rand(abmrng(model)) reproduction_prop(a)#a.def.reproduction_prob if a.energy > reproduction_energy_threshold(a) && rand(abmrng(model)) reproduction_prop(a)
a.energy /= 2 a.energy /= 2
replicate!(a, model) replicate!(a, model)
end end
end end
# usefull debug information when hovering over animals in GLMakie
function Agents.agent2string(agent::Animal) function Agents.agent2string(agent::Animal)
food_scores = "" scores = ""
danger_scores = ""
f(x) = string(round(x,digits=2)) f(x) = string(round(x,digits=2))
if !isempty(agent.food_scores) if !isempty(agent.scores)
food_scores = "\n" * f(agent.food_scores[6]) * "|" * f(agent.food_scores[7]) * "|" * f(agent.food_scores[8]) * "\n" * scores = "\n" *
f(agent.food_scores[4]) * "|" * " " * "|" * f(agent.food_scores[5]) * "\n" * f(agent.scores[6]) * "|" * f(agent.scores[7]) * "|" * f(agent.scores[8]) * "\n" *
f(agent.food_scores[1]) * "|" * f(agent.food_scores[2]) * "|" * f(agent.food_scores[3]) f(agent.scores[4]) * "|" * f(agent.scores[9]) * "|" * f(agent.scores[5]) * "\n" *
end f(agent.scores[1]) * "|" * f(agent.scores[2]) * "|" * f(agent.scores[3])
if !isempty(agent.danger_scores)
danger_scores = "\n" * f(agent.danger_scores[6]) * "|" * f(agent.danger_scores[7]) * "|" * f(agent.danger_scores[8]) * "\n" *
f(agent.danger_scores[4]) * "|" * " " * "|" * f(agent.danger_scores[5]) * "\n" *
f(agent.danger_scores[1]) * "|" * f(agent.danger_scores[2]) * "|" * f(agent.danger_scores[3])
end end
""" """
Type = $(agent.def.type) Type = $(agent.def.type)
ID = $(agent.id) ID = $(agent.id)
energy = $(agent.energy) energy = $(agent.energy)
perception = $(agent.def.perception)
death = $(agent.death_cause) death = $(agent.death_cause)
food_scores = $(food_scores) scores = $(scores)
danger_scores = $(danger_scores)
""" """
end end
# helper functions
function move_away!(agent, pos, model) function move_away!(agent, pos, model)
direction = agent.pos .- pos direction = agent.pos .- pos
direction = clamp.(direction,-1,1) direction = clamp.(direction,-1,1)
@ -162,7 +163,7 @@ end
function random_empty_fully_grown(positions, model) function random_empty_fully_grown(positions, model)
n_attempts = 2*length(positions) n_attempts = 2*length(positions)
while n_attempts != 0 while n_attempts != 0
pos_choice = rand(positions) pos_choice = rand(abmrng(model), positions)
isempty(pos_choice, model) && return pos_choice isempty(pos_choice, model) && return pos_choice
n_attempts -= 1 n_attempts -= 1
end end
@ -177,8 +178,8 @@ end
function initialize_model(; function initialize_model(;
events = [], events = [],
animal_defs = [ animal_defs = [
AnimalDefinition(100,'●',RGBAf(1.0, 1.0, 1.0, 0.8),20, 0.3, 6, 1, "Sheep", ["Wolf"], ["Grass"]), AnimalDefinition(100,'●',RGBAf(1.0, 1.0, 1.0, 0.8),20, 20, 1, 0.3, 6, 1, "Sheep", ["Wolf"], ["Grass"]),
AnimalDefinition(20,'▲',RGBAf(0.2, 0.2, 0.3, 0.8),20, 0.07, 20, 1, "Wolf", [], ["Sheep"]) AnimalDefinition(20,'▲',RGBAf(0.2, 0.2, 0.3, 0.8),20, 20, 1, 0.07, 20, 1, "Wolf", [], ["Sheep"])
], ],
dims = (20, 20), dims = (20, 20),
regrowth_time = 30, regrowth_time = 30,
@ -187,10 +188,7 @@ function initialize_model(;
) )
rng = MersenneTwister(seed) rng = MersenneTwister(seed)
space = GridSpace(dims, periodic = true) space = GridSpace(dims, periodic = true)
## Model properties contain the grass as two arrays: whether it is fully grown ## Generate model parameters
## and the time to regrow. Also have static parameter `regrowth_time`.
## Notice how the properties are a `NamedTuple` to ensure type stability.
## define as dictionary(mutable) instead of tuples(immutable) as per https://github.com/JuliaDynamics/Agents.jl/issues/727
animal_properties = generate_animal_parameters(animal_defs) animal_properties = generate_animal_parameters(animal_defs)
model_properties = Dict( model_properties = Dict(
:events => events, :events => events,
@ -200,14 +198,16 @@ function initialize_model(;
:Δenergy_grass => Δenergy_grass, :Δenergy_grass => Δenergy_grass,
) )
properties = merge(model_properties,animal_properties) properties = merge(model_properties,animal_properties)
## Initialize model
model = StandardABM(Animal, space; model = StandardABM(Animal, space;
agent_step! = animal_step!, model_step! = model_step!, agent_step! = animal_step!, model_step! = model_step!,
properties, rng, scheduler = Schedulers.Randomly(), warn = false, agents_first = false properties, rng, scheduler = Schedulers.Randomly(), warn = false, agents_first = false
) )
## Add animals
for def in animal_defs for def in animal_defs
for _ in 1:def.n for _ in 1:def.n
energy = rand(abmrng(model), 1:(def.Δenergy*2)) - 1 energy = rand(abmrng(model), 1:(def.Δenergy*2)) - 1
add_agent!(Animal, model, energy, def, nothing, [], [], [], []) add_agent!(Animal, model, energy, def, nothing, [], [], [])
end end
end end
## Add grass with random initial growth ## Add grass with random initial growth
@ -220,21 +220,13 @@ function initialize_model(;
return model return model
end end
# ## Defining the stepping functions # Animals move every step and loose energy. If they dont have enough, they die, otherwise they consume energy and reproduce.
# Sheep and wolves behave similarly: # For fair behaviour we move perception into the model step, so every animal makes its decision on one the same state
# both lose 1 energy unit by moving to an adjacent position and both consume
# a food source if available. If their energy level is below zero, they die.
# Otherwise, they live and reproduce with some probability.
# They move to a random adjacent position with the [`randomwalk!`](@ref) function.
# Notice how the function `sheepwolf_step!`, which is our `agent_step!`,
# is dispatched to the appropriate agent type via Julia's Multiple Dispatch system.
function animal_step!(a::Animal, model) function animal_step!(a::Animal, model)
if !isnothing(a.death_cause) #if !isnothing(a.death_cause)
#remove_agent!(a, model) #remove_agent!(a, model)
#return #return
end #end
#perceive!(a, model) #perceive!(a, model)
move!(a, model) move!(a, model)
if a.energy < 0 if a.energy < 0
@ -248,12 +240,10 @@ end
function model_step!(model) function model_step!(model)
handle_event!(model) handle_event!(model)
grass_step!(model) grass_step!(model)
model_animal_step!(model)
end end
# The behavior of grass function differently. If it is fully grown, it is consumable. function model_animal_step!(model)
# Otherwise, it cannot be consumed until it regrows after a delay specified by
# `regrowth_time`. The dynamics of the grass is our `model_step!` function.
function grass_step!(model)
ids = collect(allids(model)) ids = collect(allids(model))
dead_animals = filter(id -> !isnothing(model[id].death_cause), ids) dead_animals = filter(id -> !isnothing(model[id].death_cause), ids)
for id in ids for id in ids
@ -263,7 +253,10 @@ function grass_step!(model)
perceive!(model[id], model) perceive!(model[id], model)
end end
end end
@inbounds for p in positions(model) # we don't have to enable bound checking end
function grass_step!(model)
@inbounds for p in positions(model)
if !(model.fully_grown[p...]) if !(model.fully_grown[p...])
if model.growth[p...] model.regrowth_time if model.growth[p...] model.regrowth_time
model.fully_grown[p...] = true model.fully_grown[p...] = true
@ -275,22 +268,34 @@ function grass_step!(model)
end end
function handle_event!(model) function handle_event!(model)
ids = collect(allids(model)) agents = collect(allagents(model))
for event in model.events for event in model.events
if event.timer == event.t_start # start event if event.timer == event.t_start # start event
if event.name == "Drought" if event.name == "Drought"
model.regrowth_time = event.value model.regrowth_time = event.value
predators = filter(id -> !("Grass" model[id].def.food), ids) predators = filter(a -> !("Grass" a.def.food), agents)
for id in predators for a in predators
abmproperties(model)[Symbol(model[id].def.type*"_"*"perception")] = 2 abmproperties(model)[Symbol(a.def.type*"_"*"perception")] = 2
end end
elseif event.name == "PreyReproduceSeasonal"
prey = filter(a -> "Grass" a.def.food, agents)
for a in prey
abmproperties(model)[Symbol(a.def.type*"_"*"reproduction_prob")] = event.value
end
elseif event.name == "PredatorReproduceSeasonal"
predators = filter(a -> !("Grass" a.def.food), agents)
for a in predators
abmproperties(model)[Symbol(a.def.type*"_"*"reproduction_prob")] = event.value
end
elseif event.name == "Flood" elseif event.name == "Flood"
flood_kill_chance = event.value flood_kill_chance = event.value
for id in ids for a in agents
if (flood_kill_chance rand(abmrng(model))) if (flood_kill_chance rand(abmrng(model)))
remove_agent!(model[id], model) remove_agent!(a, model)
end end
end end
@ -300,18 +305,6 @@ function handle_event!(model)
model.fully_grown[p...] = false model.fully_grown[p...] = false
end end
end end
elseif event.name == "PreyReproduceSeasonal"
prey = filter(id -> "Grass" model[id].def.food, ids)
for id in prey
abmproperties(model)[Symbol(model[id].def.type*"_"*"reproduction_prob")] = event.value
end
elseif event.name == "PredatorReproduceSeasonal"
predators = filter(id -> !("Grass" model[id].def.food), ids)
for id in predators
abmproperties(model)[Symbol(model[id].def.type*"_"*"reproduction_prob")] = event.value
end
end end
end end
@ -343,7 +336,7 @@ function handle_event!(model)
if event.timer == event.t_end # end event if event.timer == event.t_end # end event
if event.name == "Drought" if event.name == "Drought"
model.regrowth_time = event.pre_value model.regrowth_time = event.pre_value
predators = filter(id -> !("Grass" model[id].def.food), ids) predators = filter(id -> !("Grass" model[id].def.food), agents)
for id in predators for id in predators
abmproperties(model)[Symbol(model[id].def.type*"_"*"perception")] = 1 abmproperties(model)[Symbol(model[id].def.type*"_"*"perception")] = 1
end end
@ -360,15 +353,15 @@ function handle_event!(model)
end end
elseif event.name == "PreyReproduceSeasonal" elseif event.name == "PreyReproduceSeasonal"
prey = filter(id -> "Grass" model[id].def.food, ids) prey = filter(a -> "Grass" a.def.food, agents)
for id in prey for a in prey
abmproperties(model)[Symbol(model[id].def.type*"_"*"reproduction_prob")] = event.pre_value abmproperties(model)[Symbol(a.def.type*"_"*"reproduction_prob")] = event.pre_value
end end
elseif event.name == "PredatorReproduceSeasonal" elseif event.name == "PredatorReproduceSeasonal"
predators = filter(id -> !("Grass" model[id].def.food), ids) predators = filter(a -> !("Grass" a.def.food), agents)
for id in predators for a in predators
abmproperties(model)[Symbol(model[id].def.type*"_"*"reproduction_prob")] = event.pre_value abmproperties(model)[Symbol(a.def.type*"_"*"reproduction_prob")] = event.pre_value
end end
end end
@ -388,7 +381,9 @@ function generate_animal_parameters(defs::Vector{AnimalDefinition})
parameter_dict[Symbol(def.type*"_"*"Δenergy")]=def.Δenergy parameter_dict[Symbol(def.type*"_"*"Δenergy")]=def.Δenergy
parameter_dict[Symbol(def.type*"_"*"reproduction_prob")]=def.reproduction_prob parameter_dict[Symbol(def.type*"_"*"reproduction_prob")]=def.reproduction_prob
parameter_dict[Symbol(def.type*"_"*"perception")]=def.perception parameter_dict[Symbol(def.type*"_"*"perception")]=def.perception
parameter_dict[Symbol(def.type*"_"*"energy_threshold")]=def.energy_threshold parameter_dict[Symbol(def.type*"_"*"forage_energy_threshold")]=def.forage_energy_threshold
parameter_dict[Symbol(def.type*"_"*"reproduction_energy_threshold")]=def.reproduction_energy_threshold
parameter_dict[Symbol(def.type*"_"*"energy_usage")]=def.energy_usage
end end
return parameter_dict return parameter_dict
end end
@ -399,7 +394,9 @@ function generate_animal_parameter_ranges(defs::Vector{AnimalDefinition})
parameter_range_dict[Symbol(def.type*"_"*"Δenergy")]=0:1:100 parameter_range_dict[Symbol(def.type*"_"*"Δenergy")]=0:1:100
parameter_range_dict[Symbol(def.type*"_"*"reproduction_prob")]=0:0.01:1 parameter_range_dict[Symbol(def.type*"_"*"reproduction_prob")]=0:0.01:1
parameter_range_dict[Symbol(def.type*"_"*"perception")]=0:1:10 parameter_range_dict[Symbol(def.type*"_"*"perception")]=0:1:10
parameter_range_dict[Symbol(def.type*"_"*"energy_threshold")]=0:1:100 parameter_range_dict[Symbol(def.type*"_"*"forage_energy_threshold")]=0:1:100
parameter_range_dict[Symbol(def.type*"_"*"reproduction_energy_threshold")]=0:1:100
parameter_range_dict[Symbol(def.type*"_"*"energy_usage")]=0:1:10
end end
return parameter_range_dict return parameter_range_dict
end end

File diff suppressed because one or more lines are too long