Lab 04b: Interfaces, traits and checking the Ecosystem
Warmup - Stepping through time
We now have all necessary functions in place to make agents perform one step of our simulation. At the beginning of each step an animal looses energy. Afterwards it tries to find some food, which it will subsequently eat. If the animal then has less than zero energy it dies and is removed from the world. If it has positive energy it will try to reproduce.
Plants have a simpler life. They simply grow if they have not reached their maximal size.
Exercise
Implement a method
agent_step!(::Animal,::World)which performs the following steps:Decrement
of agent by 1.0.With
, try to find some food and eat it. If
, the animal dies. With
, try to reproduce.
Implement a method
agent_step!(::Plant,::World)which performs the following steps:- If the size of the plant is smaller than
max_size, increment the plant's size by one.
- If the size of the plant is smaller than
Show solution
function agent_step!(p::Plant, w::World)
if p.size < p.max_size
p.size += 1
end
end
function agent_step!(a::Animal, w::World)
a.energy -= 1
if rand() <= a.foodprob
dinner = find_food(a,w)
eat!(a, dinner, w)
end
if a.energy < 0
kill_agent!(a,w)
return
end
if rand() <= a.reprprob
reproduce!(a,w)
end
endAn agent_step! of a sheep in a world with a single grass should make it consume the grass, let it reproduce, and eventually die if there is no more food and its energy is at zero:
julia> sheep = Sheep(1,2.0,2.0,1.0,1.0,male);
julia> grass = Grass(2,2,2);
julia> world = World([sheep, grass])
Main.World{Main.Agent}
🌿 #2 100% grown
🐑♂ #1 E=2.0 ΔE=2.0 pr=1.0 pf=1.0
julia> agent_step!(sheep, world); world
Main.World{Main.Agent}
🌿 #2 0% grown
🐑♂ #1 E=5.0 ΔE=2.0 pr=1.0 pf=1.0
julia> # NOTE: The second agent step leads to an error.
# Can you figure out what is the problem here?
agent_step!(sheep, world); world
ERROR: MethodError: no method matching eat!(::Main.Animal{🐑}, ::Nothing, ::Main.World{Main.Agent})
The function `eat!` exists, but no method is defined for this combination of argument types.
Closest candidates are:
eat!(::Main.Animal{🐑}, ::Main.Plant{🌿}, ::Main.World)
@ Main ~/work/Scientific-Programming-in-Julia/Scientific-Programming-in-Julia/docs/build/lectures/lecture_03/Lab03Ecosystem.jl:104
eat!(::Main.Animal{🐺}, ::Main.Animal{🐑}, ::Main.World)
@ Main ~/work/Scientific-Programming-in-Julia/Scientific-Programming-in-Julia/docs/build/lectures/lecture_03/Lab03Ecosystem.jl:108Exercise
Finally, lets implement a function world_step! which performs one agent_step! for each agent. Note that simply iterating over all agents could lead to problems because we are mutating the agent dictionary. One solution for this is to iterate over a copy of all agent IDs that are present when starting to iterate over agents. Additionally, it could happen that an agent is killed by another one before we apply agent_step! to it. To solve this you can check if a given ID is currently present in the World.
Show solution
# make it possible to eat nothing
eat!(::Animal, ::Nothing, ::World) = nothing
function world_step!(world::World)
# make sure that we only iterate over IDs that already exist in the
# current timestep this lets us safely add agents
ids = copy(keys(world.agents))
for id in ids
# agents can be killed by other agents, so make sure that we are
# not stepping dead agents forward
!haskey(world.agents,id) && continue
a = world.agents[id]
agent_step!(a,world)
end
endjulia> w = World([Sheep(1), Sheep(2), Wolf(3)])
Main.World{Main.Animal}
🐺♀ #3 E=10.0 ΔE=8.0 pr=0.1 pf=0.2
🐑♀ #2 E=4.0 ΔE=0.2 pr=0.8 pf=0.6
🐑♀ #1 E=4.0 ΔE=0.2 pr=0.8 pf=0.6
julia> world_step!(w); w
Main.World{Main.Animal}
🐺♀ #3 E=9.0 ΔE=8.0 pr=0.1 pf=0.2
🐑♀ #2 E=3.0 ΔE=0.2 pr=0.8 pf=0.6
🐑♀ #1 E=3.0 ΔE=0.2 pr=0.8 pf=0.6
julia> world_step!(w); w
Main.World{Main.Animal}
🐺♀ #3 E=8.0 ΔE=8.0 pr=0.1 pf=0.2
🐑♀ #2 E=2.0 ΔE=0.2 pr=0.8 pf=0.6
🐑♀ #1 E=2.0 ΔE=0.2 pr=0.8 pf=0.6
julia> world_step!(w); w
Main.World{Main.Animal}
🐺♀ #3 E=7.0 ΔE=8.0 pr=0.1 pf=0.2
🐑♀ #2 E=1.0 ΔE=0.2 pr=0.8 pf=0.6
🐑♀ #1 E=1.0 ΔE=0.2 pr=0.8 pf=0.6Finally, lets run a few simulation steps and plot the solution
n_grass = 1_000
n_sheep = 40
n_wolves = 4
gs = [Grass(id) for id in 1:n_grass]
ss = [Sheep(id) for id in (n_grass+1):(n_grass+n_sheep)]
ws = [Wolf(id) for id in (n_grass+n_sheep+1):(n_grass+n_sheep+n_wolves)]
w = World(vcat(gs,ss,ws))
counts = Dict(n=>[c] for (n,c) in agent_count(w))
for _ in 1:100
world_step!(w)
for (n,c) in agent_count(w)
push!(counts[n],c)
end
end
using Plots
plt = plot()
for (n,c) in counts
plot!(plt, c, label=string(n), lw=2)
end
plt
Package: Ecosystem.jl
You know how to create a package from the bachelor course. We will need it in the following labs.
Exercise
Create a package
Ecosystem.jl(PkgTemplates.jlor]generate Ecosystem).Copy the code of the ecosystem including the stepping functions from the warmup into
src/Ecosystem.jl.Add
StatsBaseas a dependency and export the types and functions.Check that
using Ecosystemandworld_step!work in a fresh session (Revise.jlrecommended).
Show solution
See lab 4 of 2025 for a step-by-step guide. The resulting source file is Lab04Ecosystem.jl wrapped in
module Ecosystem
using StatsBase: sample
# ... content of Lab04Ecosystem.jl ...
export World, Species, PlantSpecies, AnimalSpecies, Grass, Sheep, Wolf
export Agent, Animal, Plant, Sex, female, male
export agent_step!, world_step!, agent_count, eats, eat!, find_food, reproduce!, kill_agent!
endPart I: The World as a collection
So far, every function digs into w.agents |> values |> collect. The World is a collection of agents, let it behave like one.
Exercise
Implement the iteration interface for World, iterating over its agents:
Base.iterateBase.lengthBase.eltype
Then check what you got for free:
w = World([Grass(1), Sheep(2), Wolf(3)])
for a in w
println(a)
end
collect(w)
count(a -> a isa Animal{Sheep}, w)
sum(agent_count, w)
filter(a -> eats(Wolf(4), a), collect(w))HINTS:
The agents are stored in a
Dict, which is already iterable. Can you forward the iteration tovalues(w.agents)?eltypeis defined on the type:Base.eltype(::Type{World{A}}) where A.
Show solution
Base.iterate(w::World, s...) = iterate(values(w.agents), s...)
Base.length(w::World) = length(w.agents)
Base.eltype(::Type{World{A}}) where A = AThree lines and the World works with for, collect, count, sum, filter, map, ...
Discussion
What happens with
collect(w)if you do not definelength? Which trait is responsible? (See the lecture.)Rewrite
find_foodusing the new interface. Is it simpler?Is it safe to iterate over the
Worldwhileworld_step!adds and removes agents?
Part II: Who eats whom? A Diet trait
The current rules are written species by species:
eats(::Animal{Sheep}, g::Plant{Grass}) = g.size > 0
eats(::Animal{Wolf}, ::Animal{Sheep}) = true
eats(::Agent, ::Agent) = falseand similarly for eat!. Adding a rabbit means writing new methods for rabbits eating grass and for wolves eating rabbits. Adding a fox ... The table grows with the square of the number of species.
What really matters is the diet of the species: herbivores eat plants, carnivores eat herbivores.
Exercise
Define a trait
Dietwith valuesHerbivoreandCarnivore.Assign
DiettoSheepandWolf.Replace the species-specific methods of
eatsandeat!by methods dispatching on the trait:a herbivore eats a plant with
size > 0,a carnivore eats an animal whose species is a herbivore.
Check that the simulation behaves as before.
HINTS:
Trait function on the species type:
Diet(::Type{Sheep}) = Herbivore().Trait dispatch:
eats(a::Animal{A}, b::Agent) where A = eats(Diet(A), b).Keep the fallback for anything that is not food.
Show solution
abstract type Diet end
struct Herbivore <: Diet end
struct Carnivore <: Diet end
Diet(::Type{Sheep}) = Herbivore()
Diet(::Type{Wolf}) = Carnivore()
eats(a::Animal{A}, b::Agent) where A = eats(Diet(A), b)
eats(::Herbivore, p::Plant) = p.size > 0
eats(::Carnivore, b::Animal{B}) where B = Diet(B) isa Herbivore
eats(::Diet, ::Agent) = false
eat!(a::Animal{A}, b::Agent, w::World) where A = eat!(Diet(A), a, b, w)
function eat!(::Herbivore, a::Animal, p::Plant, ::World)
a.energy += p.size * a.Δenergy
p.size = 0
end
function eat!(::Carnivore, a::Animal, b::Animal, w::World)
a.energy += b.energy * a.Δenergy
kill_agent!(b, w)
endNote that eat!(::Animal, ::Nothing, ::World) stays, nothing is not an Agent.
The payoff: a new species Rabbit, defined outside of the package, without modifying eats or eat!:
using Ecosystem
abstract type Rabbit <: AnimalSpecies end
Rabbit(id; E=2.0, ΔE=0.3, pr=0.9, pf=0.7, s=rand(instances(Sex))) = Rabbit(id, E, ΔE, pr, pf, s)
Base.show(io::IO, ::Type{Rabbit}) = print(io, "🐇")
Ecosystem.Diet(::Type{Rabbit}) = Herbivore() # the only line about food
eats(Rabbit(1), Grass(2, 3, 3)) # true
eats(Wolf(1), Rabbit(2)) # trueRabbits eat grass and wolves eat rabbits. Neither eats nor eat! was touched.
Discussion
Why not
abstract type Herbivore <: AnimalSpecies endandabstract type Sheep <: Herbivore end? What if we want to split species also by habitat or by size?Is
Ecosystem.Diet(::Type{Rabbit})type piracy?Wolves eating rabbits more eagerly than sheep: can a specific method
eats(::Animal{Wolf}, ::Animal{Rabbit})override the trait?
Part III: Checking the Ecosystem
Nothing of the above is checked by the compiler. Let us bring some rules back.
Exercise
Add tests to test/runtests.jl:
A testset for the iteration interface of
World(length,collect,eltype).A testset for the
Diettrait: who eats whom (wolf→sheep, sheep→grass, not sheep→wolf, not wolf→wolf).
Run them with ]test.
Automated checks of the package with Aqua.jl are part of the homework.
Exercise
A colleague contributed a function to the package:
total_energy(w::World) = sum(a.enrgy for a in w if a isa Animal)Use JET.jl to find the problems without running the simulation:
using JET
w = World([Grass(1), Sheep(2)])
@report_call total_energy(w)Fix the reported problems.
Change the function to
sum(a.energy for a in w)(all agents). What does JET say forWorld([Grass(1), Sheep(2)]), and what forWorld([Grass(1), Grass(2)])? Why the difference?Run
report_package(Ecosystem).
Show solution
JET reports two problems:
FieldError: type Animal has no field `enrgy`, available fields: `id`, `energy`, `Δenergy`, `reprprob`, `foodprob`, `sex`
may throw: ArgumentError("reducing over an empty collection is not allowed; consider supplying `init` to the reducer")total_energy(w::World) = sum((a.energy for a in w if a isa Animal); init=0.0)On World{Agent}, JET cannot prove that a Plant will ever reach a.energy, the element type is abstract. On World{Plant{Grass}} it can, and reports FieldError: type Plant has no field energy. JET sees only what the types tell it.
Outlook
Try @report_opt world_step!(w). JET reports runtime dispatch. What is it and does it matter? Next lecture.