Daisyworld
Study this example to learn about
- Simple agent properties with complex model interactions
- Collecting data with the low-level data collection API
- Diffusion of a quantity in a
GridSpace
- the
fill_space!
function - represent a space "surface property" as an agent
- counting time in the model and having time-dependent dynamics
- data collection in a mixed-agent model
- performing interactive scientific research
Overview of Daisyworld
This model explores the Gaia hypothesis, which considers the Earth as a single, self-regulating system including both living and non-living parts.
Daisyworld is filled with black and white daisies. Their albedo's differ, with black daisies absorbing light and heat, warming the area around them; white daisies doing the opposite. Daisies can only reproduce within a certain temperature range, meaning too much (or too little) heat coming from the sun and/or surrounds will ultimately halt daisy propagation.
When the climate is too cold it is necessary for the black daisies to propagate in order to raise the temperature, and vice versa – when the climate is too warm, it is necessary for more white daisies to be produced in order to cool the temperature. The interplay of the living and non living aspects of this world manages to find an equilibrium over a wide range of parameter settings, although with enough external forcing, the daisies will not be able to regulate the temperature of the planet and eventually go extinct.
Defining the agent types
Daisy
has three values (other than the required id
and pos
for an agent that lives on a GridSpace
. Each daisy has an age
, confined later by a maximum age set by the user, a breed
(either :black
or :white
) and an associated albedo
value, again set by the user. Land
represents the surface. We could make Land
also have an albedo field, but in this world, the entire surface has the same albedo and thus we make it a model parameter.
Notice that the Land
does not necessarily have to be an agent, and one could represent surface temperature via a matrix (parameter of the model). This is done in an older version, see file examples/daisyworld_matrix.jl
. The old version has a slight performance advantage. However, the advantage of making the surface composed of agents is that visualization is simple and one can use the interactive application to also visualize surface temperature. It is also available from the Models
module as Models.daisyworld
.
using Agents, AgentsPlots, Plots
using Statistics: mean
mutable struct Daisy <: AbstractAgent
id::Int
pos::Tuple{Int,Int}
breed::Symbol
age::Int
albedo::Float64 # 0-1 fraction
end
mutable struct Land <: AbstractAgent
id::Int
pos::Tuple{Int,Int}
temperature::Float64
end
const DaisyWorld = ABM{Union{Daisy, Land}};
World heating
The surface temperature of the world is heated by its sun, but daisies growing upon it absorb or reflect the starlight – altering the local temperature.
function update_surface_temperature!(node::Int, model::DaisyWorld)
ids = get_node_contents(node, model)
# All grid points have at least one agent (the land)
absorbed_luminosity = if length(ids) == 1
# Set luminosity via surface albedo
(1 - model.surface_albedo) * model.solar_luminosity
else
# more than 1 agents: daisy exists
# Set luminosity via daisy albedo
(1 - model[ids[2]].albedo) * model.solar_luminosity
end
# We expect local heating to be 80 ᵒC for an absorbed luminosity of 1,
# approximately 30 for 0.5 and approximately -273 for 0.01.
local_heating = absorbed_luminosity > 0 ? 72 * log(absorbed_luminosity) + 80 : 80
# Surface temperature is the average of the current temperature and local heating.
T0 = model[ids[1]].temperature
model[ids[1]].temperature = (T0 + local_heating) / 2
end
In addition, temperature diffuses over time
function diffuse_temperature!(node::Int, model::DaisyWorld)
ratio = get(model.properties, :ratio, 0.5) # diffusion ratio
ids = space_neighbors(node, model)
meantemp = sum(model[i].temperature for i in ids if model[i] isa Land)/8
land = model[get_node_contents(node, model)[1]] # land at current node
# Each neighbor land patch is giving up 1/8 of the diffused
# amount to each of *its* neighbors
land.temperature = (1 - ratio)*land.temperature + ratio*meantemp
end
Daisy dynamics
The final piece of the puzzle is the life-cycle of each daisy. This method defines an optimal temperature for growth. If the temperature gets too hot or too cold, daisies will not wish to propagate. So long as the temperature is favorable, daisies compete for land and attempt to spawn a new plant of their breed
in locations close to them.
function propagate!(node::Int, model::DaisyWorld)
ids = get_node_contents(node, model)
if length(ids) > 1
daisy = model[ids[2]]
temperature = model[ids[1]].temperature
# Set optimum growth rate to 22.5 ᵒC, with bounds of [5, 40]
seed_threshold = (0.1457 * temperature - 0.0032 * temperature^2) - 0.6443
if rand() < seed_threshold
# Collect all adjacent cells that have no daisies
empty_neighbors = Int[]
neighbors = node_neighbors(node, model)
for n in neighbors
if length(get_node_contents(n, model)) == 1
push!(empty_neighbors, n)
end
end
if !isempty(empty_neighbors)
# Seed a new daisy in one of those cells
seeding_place = vertex2coord(rand(empty_neighbors), model)
a = Daisy(nextid(model), seeding_place, daisy.breed, 0, daisy.albedo)
add_agent_pos!(a, model)
end
end
end
end
And if the daisies cross an age threshold, they die out. Death is controlled by the agent_step
function
function agent_step!(agent::Daisy, model::DaisyWorld)
agent.age += 1
agent.age >= model.max_age && kill_agent!(agent, model)
end
We also need to define a version for the Land
instances (the dynamics of the Land
are resolved at model level)
agent_step!(agent::Land, model::DaisyWorld) = nothing
The model step function and agent step functions for Agents.jl to advance Daisyworld's dynamics. Since we have constructed a number of helper functions, these methods are quite straightforward.
function model_step!(model)
for n in nodes(model)
update_surface_temperature!(n, model)
diffuse_temperature!(n, model)
propagate!(n, model)
end
model.tick += 1
solar_activity!(model)
end
Notice that solar_activity!
changes the incoming solar radiation over time, if the given "scenario" (a model parameter) is :ramp
. The parameter tick
of the model keeps track of time.
function solar_activity!(model::DaisyWorld)
if model.scenario == :ramp
if model.tick > 200 && model.tick <= 400
model.solar_luminosity += model.solar_change
end
if model.tick > 500 && model.tick <= 750
model.solar_luminosity -= model.solar_change/2
end
elseif model.scenario == :change
model.solar_luminosity += model.solar_change
end
end
Initialising Daisyworld
Here, we construct a function to initialize a Daisyworld. We use fill_space!
to fill the space with Land
instances. Then, we need to know how many daisies of each type to seed the planet with and what their albedo's are. We also want a value for surface albedo, as well as solar intensity (and we also choose between constant or time-dependent intensity with scenario
).
import StatsBase
import DrWatson: @dict
function daisyworld(;
griddims = (30, 30),
max_age = 25,
init_white = 0.2, # % cover of the world surface of white breed
init_black = 0.2, # % cover of the world surface of black breed
albedo_white = 0.75,
albedo_black = 0.25,
surface_albedo = 0.4,
solar_change = 0.005,
solar_luminosity = 1.0, # initial luminosity
scenario = :default,
)
space = GridSpace(griddims, moore = true, periodic = true)
properties = @dict max_age surface_albedo solar_luminosity solar_change scenario
properties[:tick] = 0
# create a scheduler that only schedules Daisies
daisysched(model) = [a.id for a in allagents(model) if a isa Daisy]
model = ABM(Union{Daisy, Land}, space;
scheduler = daisysched, properties = properties, warn = false
)
# fill model with `Land`: every grid cell has 1 land instance
fill_space!(Land, model, 0.0) # zero starting temperature
# Populate with daisies: each cell has only one daisy (black or white)
white_nodes = StatsBase.sample(1:nv(space), Int(init_white * nv(space)); replace = false)
for n in white_nodes
wd = Daisy(nextid(model), vertex2coord(n, space), :white, rand(0:max_age), albedo_white)
add_agent_pos!(wd, model)
end
allowed = setdiff(1:nv(space), white_nodes)
black_nodes = StatsBase.sample(allowed, Int(init_black * nv(space)); replace = false)
for n in black_nodes
wd = Daisy(nextid(model), vertex2coord(n, space), :black, rand(0:max_age), albedo_black)
add_agent_pos!(wd, model)
end
return model
end
Visualizing & animating
Lets run the model with constant solar isolation and visualize the result
model = daisyworld()
AgentBasedModel with 1260 agents of type Union{Main.ex-daisyworld.Daisy, Main.ex-daisyworld.Land} space: GridSpace with 900 nodes and 3600 edges scheduler: daisysched properties: Dict{Symbol,Any}(:solar_luminosity => 1.0,:max_age => 25,:surface_albedo => 0.4,:solar_change => 0.005,:tick => 0,:scenario => :default)
To visualize we need to define the necessary functions for plotabm
. The daisies will obviously be black or white, but the land will have a color that reflects its temperature, with -50 darkest and 100 ᵒC brightest color
daisycolor(a::Daisy) = a.breed
const landcolor = cgrad(:thermal)
daisycolor(a::Land) = landcolor[(a.temperature+50)/150]
And we plot daisies as circles, and land patches as squares
daisyshape(a::Daisy) = :circle
daisysize(a::Daisy) = 7
daisyshape(a::Land) = :square
daisysize(a::Land) = 8.8
Notice that we want to ensure that the Land
patches are always plotted first.
plotsched = by_type((Land, Daisy), false)
plotkwargs = (
ac = daisycolor, am = daisyshape, as = daisysize,
scheduler = plotsched, aspect_ratio = 1, size = (600, 600), showaxis = false,
)
p = plotabm(model; plotkwargs...)
And after a couple of steps
step!(model, agent_step!, model_step!, 5)
p = plotabm(model; plotkwargs...)
Let's do some animation now
model = daisyworld()
anim = @animate for i in 0:30
p = plotabm(model; plotkwargs...)
title!(p, "step $(i)")
step!(model, agent_step!, model_step!)
end
gif(anim, "daisyworld.gif", fps = 3)
Running this animation for longer hints that this world achieves quasi-equilibrium for some input parameters, where one breed
does not totally dominate the other. Of course we can check this easily through data collection. Notice that here we have to define a function breed
that returns the daisy's breed
field. We cannot use just :breed
to automatically find it, because in this mixed agent model, the Land
doesn't have any breed
.
black(y) = count(x -> x == :black, y)
white(y) = count(x -> x == :white, y)
breed(a) = a isa Daisy ? a.breed : :land
adata = [(breed, black), (breed, white)]
model = daisyworld(; solar_luminosity = 1.0)
agent_df, model_df = run!(model, agent_step!, model_step!, 1000; adata = adata)
p = plot(agent_df[!, :step], agent_df[!, :black_breed], label = "black")
plot!(p, agent_df[!, :step], agent_df[!, :white_breed], label = "white")
plot!(p; xlabel = "tick", ylabel = "daisy count")
Time dependent dynamics
To use the time-dependent dynamics we simply use the keyword scenario = :ramp
during model creation. However, we also want to see how the planet surface temperature changes and would be nice to plot solar luminosity as well. Thus, we define in addition
gettemperature(a) = a isa Land ? a.temperature : missing
meantemperature(x) = mean(skipmissing(x))
adata = [(breed, black), (breed, white), (gettemperature, meantemperature)]
3-element Array{Tuple{Function,Function},1}: (Main.ex-daisyworld.breed, Main.ex-daisyworld.black) (Main.ex-daisyworld.breed, Main.ex-daisyworld.white) (Main.ex-daisyworld.gettemperature, Main.ex-daisyworld.meantemperature)
And, to have it as reference, we also record the solar luminosity value
mdata = [:solar_luminosity]
1-element Array{Symbol,1}: :solar_luminosity
And we run (and plot) everything
model = daisyworld(solar_luminosity = 1.0, scenario = :ramp)
agent_df, model_df = run!(model, agent_step!, model_step!, 1000; adata = adata, mdata = mdata)
p = plot(agent_df[!, :step], agent_df[!, :black_breed], label = "black")
plot!(p, agent_df[!, :step], agent_df[!, :white_breed], label = "white")
plot!(p; xlabel = "tick", ylabel = "daisy count")
p2 = plot(agent_df[!, :step], agent_df[!, aggname(adata[3])], ylabel = "temperature")
p3 = plot(model_df[!, :step], model_df[!, :solar_luminosity], ylabel = "L", xlabel = "ticks")
plot(p, p2, p3, layout = (3, 1))
Interactive scientific research
Julia is an interactive language, and thus everything that you do with Agents.jl can be considered interactive. However, we can do even better by using our interactive application. In this example, rather than describing what solar forcing we want to investigate before hand, we use the interactive application, to control by ourselves, in real time, how much solar forcing is delivered to daisyworld.
So, let's use interactive_abm
from the Interactive application page!
using InteractiveChaos, Makie, Random
Random.seed!(165)
model = daisyworld(; solar_luminosity = 1.0, solar_change = 0.0, scenario = :change)
Thankfully, we have already defined the necessary adata, mdata
as well as the agent color/shape/size functions, and we can re-use them for the interactive application. Because InteractiveChaos
uses a different plotting package, Makie.jl, the plotting functions we have defined for plotabm
need to be slightly adjusted. In the near future, AgentsPlots.jl will move to Makie.jl, so no adjustment will be necessary.
using AbstractPlotting: to_color
daisycolor(a::Daisy) = RGBAf0(to_color(a.breed))
const landcolor = cgrad(:thermal)
daisycolor(a::Land) = to_color(landcolor[(a.temperature+50)/150])
daisyshape(a::Daisy) = :circle
daisysize(a::Daisy) = 0.6
daisyshape(a::Land) = :rect
daisysize(a::Land) = 1
The only significant addition to use the interactive application is that we make a parameter container for surface albedo and for the rate of change of solar luminosity, and add some labels for clarity.
params = Dict(
:solar_change => -0.1:0.01:0.1,
:surface_albedo => 0:0.01:1,
)
alabels = ["black", "white", "T"]
mlabels = ["L"]
landfirst = by_type((Land, Daisy), false)
scene, agent_df, model_def = interactive_abm(
model, agent_step!, model_step!, params;
ac = daisycolor, am = daisyshape, as = daisysize,
mdata = mdata, adata = adata, alabels = alabels, mlabels = mlabels,
scheduler = landfirst # crucial to change model scheduler!
)