From 36f8a06fe51084e404e11b516945a41fca7020c5 Mon Sep 17 00:00:00 2001 From: Daniel Vedder <daniel.vedder@idiv.de> Date: Mon, 13 Nov 2023 19:39:48 +0100 Subject: [PATCH] Added lots of functions and macros for the species DSL --- docs/src/nature.md | 9 ++++ src/analysis/makieplots.jl | 5 +- src/core/simulation.jl | 3 ++ src/crop/farmplot.jl | 1 + src/farm/farm.jl | 1 + src/nature/ecologicaldata.jl | 6 ++- src/nature/macros.jl | 88 ++++++++++++++++++++++++++++++++++-- src/nature/nature.jl | 3 +- src/nature/populations.jl | 30 +++++++++++- src/world/landscape.jl | 47 +++++++++++++++++-- 10 files changed, 181 insertions(+), 12 deletions(-) diff --git a/docs/src/nature.md b/docs/src/nature.md index 499a764..86b8b70 100644 --- a/docs/src/nature.md +++ b/docs/src/nature.md @@ -9,6 +9,15 @@ Modules = [Persefone] Pages = ["nature/nature.jl"] ``` +## macros.jl + +This file contains all the macros that can be used in the species DSL. + +```@autodocs +Modules = [Persefone] +Pages = ["nature/macros.jl"] +``` + ## populations.jl This file contains a set of utility functions for species, including initialisation, diff --git a/src/analysis/makieplots.jl b/src/analysis/makieplots.jl index 6a6ef45..807015f 100644 --- a/src/analysis/makieplots.jl +++ b/src/analysis/makieplots.jl @@ -31,7 +31,7 @@ function visualisemap(model::AgentBasedModel,date=nothing,landcover=nothing) #XXX other colour schemes: :tab10, :Accent_8, :Dark2_8, :Paired_12, :Set1_9 # https://juliagraphics.github.io/ColorSchemes.jl/stable/catalogue/ update_theme!(palette=(color=cgrad(:seaborn_bright, - length(@param(nature.targetspecies))),), + max(2, length(@param(nature.targetspecies)))),), cycle=[:color]) for s in @param(nature.targetspecies) points = @select!(@subset(inds, :Species .== s, :Date .== date), @@ -54,12 +54,13 @@ Returns a Makie figure object. function populationtrends(model::AgentBasedModel) pops = model.datatables["populations"] update_theme!(palette=(color=cgrad(:seaborn_bright, - length(@param(nature.targetspecies))),), + max(2, length(@param(nature.targetspecies)))),), cycle=[:color]) f = Figure() dates = @param(core.startdate):@param(core.enddate) ax = Axis(f[1,1], xlabel="Date", ylabel="Population size", limits=((1, length(dates)), nothing), xticks = gettickmarks(dates)) + #XXX Y axis doesn't reach 0? for s in @param(nature.targetspecies) points = @select!(@subset(pops, :Species .== s), :Abundance) iszero(size(points)[1]) && continue diff --git a/src/core/simulation.jl b/src/core/simulation.jl index 10d6d54..b77917b 100644 --- a/src/core/simulation.jl +++ b/src/core/simulation.jl @@ -28,6 +28,7 @@ Carry out a complete simulation run using a pre-initialised model object. function simulate!(model::AgentBasedModel) @info "Simulation run started at $(Dates.now())." runtime = Dates.value(@param(core.enddate)-@param(core.startdate))+1 + #TODO remove Agents.jl-related code, reimplement this more cleanly (#72) step!(model, dummystep, stepsimulation!, runtime) finalise!(model) end @@ -58,6 +59,7 @@ Initialise a model object using a ready-made settings dict. This is a helper function for `initialise()`. """ function initmodel(settings::Dict{String, Any}) + #TODO remove Agents.jl-related code, reimplement this more cleanly (#72) @debug "Initialising model object." createdatadir(settings["core.outdir"], settings["core.overwrite"]) logger = modellogger(settings["core.loglevel"], settings["core.outdir"]) @@ -127,6 +129,7 @@ Execute one update of the model. function stepsimulation!(model::AgentBasedModel) with_logger(model.logger) do @info "Simulating day $(model.date)." + #TODO remove Agents.jl-related code, reimplement this more cleanly (#72) for a in Schedulers.ByType((Farmer,FarmPlot,Animal), true)(model) try #The animal may have been killed stepagent!(model[a], model) diff --git a/src/crop/farmplot.jl b/src/crop/farmplot.jl index 9389bfa..200a106 100644 --- a/src/crop/farmplot.jl +++ b/src/crop/farmplot.jl @@ -12,6 +12,7 @@ This represents one field, i.e. a collection of pixels with the same management. This is the spatial unit with which the crop growth model and the farm model work. """ @agent FarmPlot GridAgent{2} begin + #TODO remove Agents.jl-related code, reimplement this more cleanly (#72) pixels::Vector{Tuple{Int64, Int64}} croptype::CropType phase::GrowthPhase diff --git a/src/farm/farm.jl b/src/farm/farm.jl index 0362efe..42c5a42 100644 --- a/src/farm/farm.jl +++ b/src/farm/farm.jl @@ -9,6 +9,7 @@ This is the agent type for the farm ABM. (Not yet implemented.) """ @agent Farmer GridAgent{2} begin + #TODO remove Agents.jl-related code, reimplement this more cleanly (#72) fields::Vector{FarmPlot} croprotation::Vector{CropType} #TODO add AES diff --git a/src/nature/ecologicaldata.jl b/src/nature/ecologicaldata.jl index 36cb125..02c646a 100644 --- a/src/nature/ecologicaldata.jl +++ b/src/nature/ecologicaldata.jl @@ -27,7 +27,10 @@ function savepopulationdata(model::AgentBasedModel) pops = Dict{String,Int}(s=>0 for s = @param(nature.targetspecies)) for a in allagents(model) (typeof(a) != Animal) && continue - pops[a.traits["name"]] += 1 + pops[a.name] += 1 + end + for a in model.migrants + pops[a.first.name] += 1 end data = [] for p in keys(pops) @@ -45,6 +48,7 @@ monthly, yearly, or at the end of a simulation, depending on the parameter `nature.indoutfreq`. WARNING: Produces very big files! """ function saveindividualdata(model::AgentBasedModel) + #XXX doesn't include migrants! data = [] for a in allagents(model) (typeof(a) != Animal) && continue diff --git a/src/nature/macros.jl b/src/nature/macros.jl index 0b2dfca..6ca2086 100644 --- a/src/nature/macros.jl +++ b/src/nature/macros.jl @@ -103,7 +103,7 @@ variables: Several utility macros can be used within the body of `@phase` as a short-hand for common expressions: [`@trait`](@ref), [`@setphase`](@ref), [`@respond`](@ref), [`@kill`](@ref), [`@reproduce`](@ref), [`@neighbours`](@ref), [`@rand`](@ref), -[`@shuffle!`](@ref). +[`@shuffle!`](@ref), [`@move`](@ref), [`@walk`](@ref), [`@follow`](@ref). Note that the first phase that is defined in a species definition block will be the phase that animals are assigned at birth, unless the variable `phase` is @@ -114,6 +114,7 @@ macro phase(name, body) quote Core.@__doc__ function $(esc(name))($(esc(:animal))::Animal, $(esc(:model))::AgentBasedModel) $(esc(:pos)) = $(esc(:animal)).pos + #$(esc(:date)) = $(esc(:model)).date #XXX does this make sense? $(esc(body)) end ($(esc(:phase)) == "") && ($(esc(:phase)) = $(String(name))) @@ -127,6 +128,8 @@ A utility macro to quickly access an animal's trait value. This can only be used nested within [`@phase`](@ref). """ macro trait(traitname) + #TODO provide a version that can access another animal's traits + #XXX replace with an @v macro? (shorter, and not all variables are "traits") #XXX This would error if called in the first part of a species definition block # (i.e. outside of a @phase block). Although this is specified in the documentation, # it is unexpected and liable to be overlooked. Can we add a third clause to @@ -134,6 +137,28 @@ macro trait(traitname) :($(esc(:animal)).$(traitname)) end +""" + @animal(id) + +Return the animal/agent object associated with this ID number. +This can only be used in a context where the `model` object is available +(e.g. nested within [`@phase`](@ref)). +""" +macro animal(id) + :($(esc(:model))[$(id)]) +end + +""" + @isalive(id) + +Test whether the animal with the given ID is still alive. +This can only be used in a context where the `model` object is available +(e.g. nested within [`@phase`](@ref)). +""" +macro isalive(id) + :(isalive($(id), $(esc(:model)))) +end + """ @setphase(newphase) @@ -300,6 +325,17 @@ macro distancetoedge() :(distancetoedge($(esc(:pos)), $(esc(:model)))) end +""" + @randompixel(range, habitatdescriptor) + +Find a random pixel within a given `range` of the animal's location that matches the +habitatdescriptor (create this using [`@habitat`](@ref)). This is a utility wrapper +that can only be used nested within [`@phase`](@ref). +""" +macro randompixel(args...) + :(randompixel($(esc(:pos)), $(esc(:model)), $(map(esc, args)...))) +end + """ @countanimals(species="", radius=0) @@ -308,7 +344,53 @@ This is a utility wrapper that can only be used nested within [`@phase`](@ref) or [`@habitat`](@ref). """ macro countanimals(args...) - :(countanimals($(esc(:pos)), $(esc(:model)); $(map(esc, args)...))) + :(countanimals($(esc(:pos)), $(esc(:model)))) +end + +##TODO test movement macros + +""" + @move(position) + +Move the current individual to a new position. +This is a utility wrapper that can only be used nested within [`@phase`](@ref). +""" +macro move(position) + :(move_agent!($(esc(:animal)), $(esc(position)), $(esc(:model)))) +end + +""" + @walk(direction) + +Walk the animal in a given direction, which is specified by a tuple of coordinates +relative to the animal's current position (i.e. `(2, -3)` increments the X coordinate +by 2 and decrements the Y coordinate by 3.) This is a utility wrapper that can only be +used nested within [`@phase`](@ref). +""" +macro walk(direction) + #XXX add `ifempty` keyword? + :(walk!($(esc(:animal)), $(esc(direction)), $(esc(:model)))) +end + +""" + @randomwalk(distance) + +Walk in a random direction for a specified number of steps. +This is a utility wrapper that can only be used nested within [`@phase`](@ref). +""" +macro randomwalk(distance) + #XXX add `ifempty` keyword? + :(randomwalk!($(esc(:animal)), $(esc(:model)), $(esc(distance)))) +end +#TODO add own walking functions that respect habitat descriptors + +""" + @follow(leader, distance) + +Move to a location within the given distance of the leading animal. +This is a utility wrapper that can only be used nested within [`@phase`](@ref). +""" +macro follow(leader, distance) + :(followanimal!($(esc(:animal)), $(esc(leader)), $(esc(:model)), $(esc(distance)))) end -##TODO add movement macros diff --git a/src/nature/nature.jl b/src/nature/nature.jl index 92f3c6f..4cd5599 100644 --- a/src/nature/nature.jl +++ b/src/nature/nature.jl @@ -18,6 +18,7 @@ trait variable can still be accessed as if it were a normal field name, i.e. the trait `phase` can be accessed and modified with `animal.phase`.) """ @agent Animal GridAgent{2} begin + #TODO remove Agents.jl-related code, reimplement this more cleanly (#72) #XXX is it (performance-)wise to use a dict for the traits? # Doesn't that rather obviate the point of having an agent struct? # If I could move the mutable traits to the struct, I wouldn't need @@ -72,7 +73,7 @@ Update an animal by one day, executing it's currently active phase function. """ function stepagent!(animal::Animal, model::AgentBasedModel) animal.age += 1 - animal.traits[animal.phase](animal,model) #FIXME + animal.traits[animal.phase](animal,model) #FIXME -> note to self: why? end """ diff --git a/src/nature/populations.jl b/src/nature/populations.jl index e00d22f..d7680ec 100644 --- a/src/nature/populations.jl +++ b/src/nature/populations.jl @@ -103,13 +103,16 @@ The `mate` argument gives the ID of the reproductive partner. """ function reproduce!(animal::Animal, model::AgentBasedModel, mate::Int64, n::Int64=1) (animal.sex == male) && @warn "Male $(animalid(animal)) is reproducing." + offspring = [] for i in 1:n sex = (animal.sex == hermaphrodite) ? hermaphrodite : @rand([male, female]) # We need to generate a fresh species dict here species = @eval $(Symbol(animal.traits["name"]))($model) - add_agent!(animal.pos, Animal, model, species, (animal.id, mate), sex, 0) + a = add_agent!(animal.pos, Animal, model, species, (animal.id, mate), sex, 0) + push!(offspring, a.id) end @debug "$(animalid(animal)) has reproduced." + offspring end """ @@ -146,6 +149,15 @@ function migrate!(animal::Animal, model::AgentBasedModel, arrival::Date) @debug "$(animalid(animal)) has migrated." end +""" + isalive(id, model) + +Test whether the animal with the given ID is still alive. +""" +function isalive(animalid::Int64, model::AgentBasedModel) + animalid in allids(model) +end + """ nearby_animals(pos, model, radius) @@ -183,4 +195,18 @@ function countanimals(pos::Tuple{Int64,Int64}, model::AgentBasedModel; return n end -##TODO add movement functions +""" + followanimal!(follower, leader, model, distance=0) + +Move the follower animal to a location near the leading animal. +""" +function followanimal!(follower::Animal, leader::Animal, model::AgentBasedModel, + distance::Int64=0) + #TODO test function + spacing = Tuple(@rand(-distance:distance, 2)) + targetposition = safebounds(spacing .+ leader.pos, model) + move_agent!(follower, targetposition, model) +end + +##TODO add random walk with habitat descriptor + diff --git a/src/world/landscape.jl b/src/world/landscape.jl index 628781c..b324298 100644 --- a/src/world/landscape.jl +++ b/src/world/landscape.jl @@ -126,6 +126,7 @@ Calculate the distance from the given location to the closest location matching habitat descriptor function. Caution: can be computationally expensive! """ function distanceto(pos::Tuple{Int64,Int64}, model::AgentBasedModel, habitatdescriptor::Function) + #XXX allow testing for multiple habitat types? (habitatdescriptor(pos, model)) && (return 0) dist = 1 width, height = size(model.landscape) @@ -160,8 +161,7 @@ Calculate the distance from the given location to the closest habitat of the spe Caution: can be computationally expensive! """ function distanceto(pos::Tuple{Int64,Int64}, model::AgentBasedModel, habitattype::LandCover) - #XXX allow testing for multiple habitat types? - # can't use @habitat here because nature.jl is loaded later than this file + # can't use @habitat here because macros.jl is loaded later than this file distanceto(pos, model, function(p,m) landcover(p,m) == habitattype end) end @@ -172,7 +172,48 @@ Calculate the distance from the given location to the closest neighbouring habit Caution: can be computationally expensive! """ function distancetoedge(pos::Tuple{Int64,Int64}, model::AgentBasedModel) - # can't use @habitat here because nature.jl is loaded later than this file + # can't use @habitat here because macros.jl is loaded later than this file distanceto(pos, model, function(p,m) landcover(p,m) != landcover(pos, model) end) end +""" + randompixel(position, model, range, habitatdescriptor) + +Find a random pixel within a given `range` of the `position` that matches the +habitatdescriptor (create this using [`@habitat`](@ref)). +""" +function randompixel(pos::Tuple{Int64,Int64}, model::AgentBasedModel, range::Int64=1, + habitatdescriptor::Function=(pos,model)->nothing) + for x in @shuffle!((pos[1]-range):(pos[1]+range)) + for y in @shuffle!((pos[2]-range):(pos[2]+range)) + !inbounds((x,y), model) && continue + habitatdescriptor((x,y), model) && return (x,y) + end + end + nothing +end + +""" + inbounds(pos, model) + +Is the given position within the bounds of the model landscape? +""" +function inbounds(pos, model) + dims = size(model.landscape) + pos[1] > 0 && pos[1] <= dims[1] && pos[2] > 0 && pos[2] <= dims[2] +end + +""" + safebounds(pos, model) + +Make sure that a given position is within the bounds of the model landscape. +""" +function safebounds(pos::Tuple{Int64,Int64}, model::AgentBasedModel) + dims = size(model.landscape) + x, y = pos + x <= 0 && (x = 1) + x > dims[1] && (x = dims[1]) + y <= 0 && (y = 1) + y > dims[2] && (y = dims[2]) + (x,y) +end -- GitLab