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