From 24ac928aabb3b711580b5bef4927bc1798eff40e Mon Sep 17 00:00:00 2001
From: Daniel Vedder <daniel.vedder@idiv.de>
Date: Sat, 14 Jan 2023 20:05:19 +0100
Subject: [PATCH] Wrote tests for @species macro

This is a major commit with plenty of bug fixes. Turns out, macrology
is *hard*! Not everything works yet, there are still several important
FIXMEs scattered throughout the code. But at least the macro
expansion/module scoping that has been bugging me for most of the past
week has been (mostly) fixed now, or at least avoided. We're getting there...
---
 src/nature/nature.jl      |  36 ++++++++-----
 src/nature/populations.jl |  29 ++++++----
 test/nature_tests.jl      | 109 +++++++++++++++++++++++++++++++-------
 3 files changed, 131 insertions(+), 43 deletions(-)

diff --git a/src/nature/nature.jl b/src/nature/nature.jl
index caa5018..27dc09b 100644
--- a/src/nature/nature.jl
+++ b/src/nature/nature.jl
@@ -18,6 +18,8 @@ by trait dictionaries passed by them during initialisation.
 @agent Animal GridAgent{2} begin
     #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
+    # to deepcopy the speciesdict.
     traits::Dict{String,Any}
     sex::Sex
     age::Int32
@@ -38,6 +40,7 @@ end
 Update an animal by one day, executing it's currently active phase function.
 """
 function stepagent!(animal::Animal, model::AgentBasedModel)
+    @debug "Updating $(animalid(animal))."
     animal.age += 1
     animal.traits[animal.traits["phase"]](animal,model)
 end
@@ -98,6 +101,7 @@ macro species(name, body)
             $(esc(body))
             vardict = Base.@locals
             speciesdict = Dict{String,Any}()
+            #delete!(speciesdict, $name) #FIXME remove circular reference from speciesdict
             for k in keys(vardict)
                 speciesdict[string(k)] = vardict[k]
             end
@@ -142,7 +146,7 @@ variables:
     information).
 
 Several utility macros can be used within the body of `@phase` as a short-hand for
-common expressions: `@trait`, `@changephase`, `@respond`, `@here`, `@kill`,
+common expressions: `@trait`, `@setphase`, `@respond`, `@here`, `@kill`,
 `@reproduce`, `@neighbours`.
 
 Note that the first phase that is defined in a species definition block will be
@@ -153,10 +157,9 @@ macro phase(name, body)
     #XXX make this documentable?
     #FIXME Somehow, errors in the phase body are not shown?
     quote
-        $(esc(name)) = function($(esc(:animal))::Animal, $(esc(:model))::AgentBasedModel)
+        Core.@__doc__ $(esc(name)) = function($(esc(:animal))::Animal, $(esc(:model))::AgentBasedModel)
             $(esc(:pos)) = $(esc(:animal)).pos
-            #@debug "Executing phase "*$(String(name))*":\n"*$(esc(body))
-            $(esc(body)) #FIXME isn't being executed
+            $(esc(body))
         end
         ($(esc(:phase)) == "") && ($(esc(:phase)) = $(String(name)))
     end
@@ -176,16 +179,16 @@ macro trait(traitname)
     if traitname in fieldnames(Animal)
         :($(esc(:animal)).$(traitname))
     else
-        :($(esc(:animal)).traits[string($(QuoteNode(traitname)))])
+        :($(esc(:animal)).traits[$(String(traitname))])
     end
 end
 
 """
-    @changephase(newphase)
+    @setphase(newphase)
 
 Switch this animal over to a different phase. This can only be used nested within `@phase`.
 """
-macro changephase(newphase)
+macro setphase(newphase)
     :($(esc(:animal)).traits["phase"] = $(String(newphase)))
 end
 
@@ -197,7 +200,7 @@ This can only be used nested within `@phase`.
 """
 macro respond(eventname, body)
     quote
-        if $(esc(eventname)) in @here(events)
+        if $(esc(eventname)) in $(esc(:model)).landscape[$(esc(:animal)).pos...].events
             $(esc(body))
         end
     end
@@ -210,7 +213,7 @@ A utility macro to quickly access a property of the animal's current position.
 This can only be used nested within `@phase`.
 """
 macro here(property)
-    :($(esc(:model)).landscape[$(esc(:animal)).pos...].$(property))
+    :(model.landscape[animal.pos...].$(property))
 end
 
 """
@@ -220,7 +223,10 @@ Kill this animal. This is a thin wrapper around `kill!()`, and passes on any arg
 This can only be used nested within `@phase`.
 """
 macro kill(args...)
-    :(kill!($(esc(:animal)), $(esc(:model)), $(args...)))
+    quote
+        kill!($(esc(:animal)), $(esc(:model)), $(args...))
+        return
+    end
 end
 
 """
@@ -240,6 +246,7 @@ Return an iterator over all animals in the given radius around this animal, excl
 This can only be used nested within `@phase`.
 """
 macro neighbours(radius)
+    #TODO enable filtering by species
     :(nearby_animals($(esc(:animal)), $(esc(:model)), $radius))
 end
 
@@ -283,6 +290,9 @@ macro habitat(body)
     end
 end
 
+##XXX Can I make sure (e.g. through `try/catch`) that the following macros
+## are not called anywhere outside @habitat/@phase?
+
 """
     @landcover
 
@@ -334,13 +344,13 @@ macro distancetoedge()
 end
 
 """
-    @countanimals(speciesname, radius=0)
+    @countanimals(species="", radius=0)
 
 Count the number of animals of the given species in this location.
 This is a utility wrapper that can only be used nested within `@phase` or `@habitat`.
 """
-macro countanimals(speciesname, radius=0)
-    :(countanimals($(esc(:pos)), $(esc(:model)), $speciesname, $radius))
+macro countanimals(args...)
+    :(countanimals($(esc(:pos)), $(esc(:model)); $(map(esc, args)...)))
 end
 
 ##TODO add movement macros
diff --git a/src/nature/populations.jl b/src/nature/populations.jl
index ce1d793..f4e05f7 100644
--- a/src/nature/populations.jl
+++ b/src/nature/populations.jl
@@ -32,6 +32,7 @@ This can be used to create the `initialise!` variable in a species definition bl
 """
 function initpopulation(habitatdescriptor::Function; phase::Union{String,Nothing}=nothing,
                         popsize::Int64=-1, pairs::Bool=false, asexual::Bool=false)
+    #TODO add a `popdensity` argument
     function(species::Dict{String,Any}, model::AgentBasedModel)
         n = 0
         lastn = 0
@@ -42,13 +43,17 @@ function initpopulation(habitatdescriptor::Function; phase::Union{String,Nothing
             for x in shuffle!(Vector(1:width))
                 for y in shuffle!(Vector(1:height))
                     if habitatdescriptor((x,y), model)
+                        #FIXME we probably have to do a deepcopy here, to prevent all conspecifics
+                        # sharing the same speciesdict object. However, this is (a) expensive
+                        # and (b) we seem to have a self-reference in the speciesdict, leading to
+                        # an infinity loop on deepcopying...
                         if pairs
-                            add_agent!((x,y), Animal, model, species, female, 0)
-                            add_agent!((x,y), Animal, model, species, male, 0)
+                            add_agent!((x,y), Animal, model, deepcopy(species), female, 0)
+                            add_agent!((x,y), Animal, model, deepcopy(species), male, 0)
                             n += 2
                         else
                             sex = asexual ? hermaphrodite : rand([male, female])
-                            add_agent!((x,y), Animal, model, species, sex, 0)
+                            add_agent!((x,y), Animal, model, deepcopy(species), sex, 0)
                             n += 1
                         end
                     end
@@ -73,6 +78,7 @@ A simplified version of `initpopulation()`. Creates a function that initialises
 `popsize` individuals, spread at random across the landscape.
 """
 function initrandompopulation(popsize::Int64; kwargs...)
+    #XXX How should this be called if users are supposed to use @initialise?
     initpopulation(@habitat(true); popsize=popsize, kwargs...)
 end
 
@@ -87,7 +93,8 @@ Produce one or more offspring for the given animal at its current location.
 function reproduce!(animal::Animal, model::AgentBasedModel, n::Int64=1)
     for i in 1:n
         sex = (animal.sex == hermaphrodite) ? hermaphrodite : rand([male, female])
-        add_agent!(animal.pos, Animal, model, animal.traits, sex, 0)
+        species = animal.traits # @eval $(Symbol(animal.traits["name"]))($model) #FIXME
+        add_agent!(animal.pos, Animal, model, species, sex, 0)
     end
     @debug "$(animalid(animal)) has reproduced."
 end
@@ -114,6 +121,7 @@ end
 Return an iterator over all animals in the given radius around this position.
 """
 function nearby_animals(pos::Tuple{Int64,Int64}, model::AgentBasedModel, radius::Int64)
+    #TODO enable filtering by species
     neighbours = (model[id] for id in nearby_ids(pos, model, radius))
     Iterators.filter(a -> typeof(a) == Animal, neighbours)
 end
@@ -124,21 +132,22 @@ end
 Return an iterator over all animals in the given radius around this animal, excluding itself.
 """
 function nearby_animals(animal::Animal, model::AgentBasedModel, radius::Int64)
+    #TODO enable filtering by species
     neighbours = (model[id] for id in nearby_ids(animal.pos, model, radius))
     Iterators.filter(a -> typeof(a) == Animal && a.id != animal.id, neighbours)
 end
 
 """
-    countanimals(pos, model, speciesname, radius=0)
+    countanimals(pos, model; species="", radius=0)
 
-Count the number of animals of the given species in this location (optionally supplying a radius).
+Count the number of animals in this location (optionally supplying a species name and radius).
 """
-function countanimals(pos::Tuple{Int64,Int64}, model::AgentBasedModel,
-                      speciesname::String, radius::Int64=0)
+function countanimals(pos::Tuple{Int64,Int64}, model::AgentBasedModel;
+                      species::String="", radius::Int64=0)
     n = 0
-    #XXX can we ignore capitalisation in the spelling of `speciesname`?
+    #XXX can we ignore capitalisation in the spelling of `species`?
     for a in nearby_animals(pos, model, radius)
-        a.traits["name"] == speciesname && (n += 1)
+        (species == "" || a.traits["name"] == species) && (n += 1)
     end
     return n
 end
diff --git a/test/nature_tests.jl b/test/nature_tests.jl
index 73debd7..74b9c15 100644
--- a/test/nature_tests.jl
+++ b/test/nature_tests.jl
@@ -3,10 +3,6 @@
 ### These are the tests for the nature model (excluding individual species).
 ###
 
-@testset "Species macros" begin
-    #TODO
-end
-
 @testset "Habitat macros" begin
     # set up the testing landscape
     model = smalltestlandscape(Union{Animal,FarmPlot})
@@ -16,12 +12,12 @@ end
     add_agent!((3,3), Animal, model, species, Ps.male, 1)
     add_agent!((4,4), Animal, model, species, Ps.female, 1)
     # create a set of habitat descriptors
-    h1 = @habitat(Ps.@landcover() == Ps.water)
-    h2 = @habitat(Ps.@croptype() == Ps.wheat &&
-                  Ps.@cropheight() < 2)
-    h3 = @habitat(Ps.@distanceto(water) > 2 &&
-                  Ps.@distancetoedge() <= 2)
-    h4 = @habitat(Ps.@countanimals("test_animal", 1) == 1)
+    h1 = @habitat(@landcover() == Ps.water)
+    h2 = @habitat(@croptype() == Ps.wheat &&
+                  @cropheight() < 2)
+    h3 = @habitat(@distanceto(water) > 2 &&
+                  @distancetoedge() <= 2)
+    h4 = @habitat(@countanimals(species="test_animal", radius=1) == 1)
     # test the descriptors
     @test h1((6,4), model) == true
     @test h1((5,4), model) == false
@@ -42,31 +38,104 @@ end
     # create a set of initialisation functions
     initfun1 = Ps.initrandompopulation(10)
     initfun2 = Ps.initrandompopulation(6*6*3, asexual=true)
-    initfun3 = Ps.initpopulation(@habitat(Ps.@landcover() == Ps.grass), pairs=true)
-    initfun4 = Ps.initpopulation(@habitat(Ps.@landcover() == Ps.water &&
-                                          Ps.@countanimals("test_animal", 0) < 5),
+    initfun3 = Ps.initpopulation(@habitat(@landcover() == Ps.grass), pairs=true)
+    initfun4 = Ps.initpopulation(@habitat(@landcover() == Ps.water &&
+                                          @countanimals(species="test_animal", radius=0) < 5),
                                  popsize=10)
     # apply and test the initialisation functions
     @test_logs (:info, "Initialised 10 $(spec)s.") initfun1(species, model)
     @test all(a -> a.sex in (Ps.male, Ps.female), allagents(model))
     genocide!(model)
     @test_logs (:info, "Initialised 108 $(spec)s.") initfun2(species, model)
-    @test Ps.countanimals((1,1), model, spec, 0) == Ps.countanimals((6,6), model, spec, 0) == 3
+    @test Ps.countanimals((1,1), model, species=spec, radius=0) ==
+        Ps.countanimals((6,6), model, species=spec, radius=0) == 3
     @test all(a -> a.sex == Ps.hermaphrodite, allagents(model))
     genocide!(model)
     @test_logs (:info, "Initialised 16 $(spec)s.") initfun3(species, model)
-    @test Ps.countanimals((2,2), model, spec, 2) == Ps.countanimals((5,3), model, spec, 1) == 0
-    @test Ps.countanimals((5,5), model, spec, 0) == Ps.countanimals((6,6), model, spec, 0) == 2
+    @test Ps.countanimals((2,2), model, species=spec, radius=2) ==
+        Ps.countanimals((5,3), model, species=spec, radius=1) == 0
+    @test Ps.countanimals((5,5), model, species=spec, radius=0) ==
+        Ps.countanimals((6,6), model, species=spec, radius=0) == 2
     a1, a2 = Ps.nearby_animals((6,6), model, 0)
     @test a1.sex != a2.sex
     genocide!(model)
     @test_logs((:warn, "There are not enough suitable locations for $(spec) in the landscape."),
                (:info, "Initialised 5 $(spec)s."),
                initfun4(species, model))
-    @test Ps.countanimals((1,1), model, spec, 4) == 0
-    @test Ps.countanimals((6,4), model, spec, 0) == 5
+    @test Ps.countanimals((1,1), model, species=spec, radius=4) == 0
+    @test Ps.countanimals((6,4), model, species=spec, radius=0) == 5
 end
 
-@testset "Population functions" begin
-    #TODO
+@testset "Species macros" begin
+    # set up an example landscape and species
+    # (note: have to use `Persephone` instead of `Ps` due to macro expansion issues)
+    #FIXME I am having tons of trouble here with module scoping and macro expansion,
+    # a known tricky issue in Julia (https://github.com/JuliaLang/julia/issues/23221,
+    # https://github.com/p-i-/MetaGuideJulia/wiki#example-swap-macro-to-illustrate-esc).
+    # Basically, one needs to avoid nesting modules too deeply (i.e. more than one nesting level)
+    model = smalltestlandscape(Union{Animal,Farmer,FarmPlot})
+    
+    @species Mermaid begin
+        ageofmaturity = 2
+        #@initialise!(@habitat(@landcover() == Persephone.water), pairs=true) #FIXME
+        initialise! = Persephone.initpopulation(@habitat(@landcover() == Persephone.water), pairs=true)
+
+        @phase life begin
+            @debug "$(Persephone.animalid(animal)) is swimming happily in its pond."
+            @respond Persephone.pesticide @kill()
+            @respond Persephone.harvest @setphase(drought)
+            @debug "Animal: $animal"
+            if @trait(sex) == Persephone.female && @countanimals() < 3 &&
+                @trait(age) >= @trait(ageofmaturity)
+                @debug "$(Persephone.animalid(animal)) is reproducing."
+                @reproduce()
+            end
+        end
+
+        @phase drought begin
+            @debug "$(Persephone.animalid(animal)) is trying to survive a drought."
+            @respond Persephone.sowing @setphase(life)
+        end
+    end
+    
+    # test a complete mermaid life cycle
+    pond = (6,4)
+    mermaid = Mermaid(model)
+    @test typeof(Mermaid) <: Function
+    @test typeof(mermaid["life"]) <: Function
+    @test typeof(mermaid["initialise!"]) <: Function
+    @test typeof(mermaid[mermaid["phase"]]) <: Function
+    @test mermaid["phase"] == "life"
+    @test_logs (:info, "Initialised 2 Mermaids.") mermaid["initialise!"](mermaid, model)
+    @test Ps.countanimals((1,1), model, radius=4) == 0
+    @test Ps.countanimals(pond, model) == 2
+    @test model[1].age == 0
+    createevent!(model, [pond], Ps.harvest)
+    @test_logs((:debug, "Mermaid 1 is swimming happily in its pond."),
+               (:debug, "Mermaid 2 is swimming happily in its pond."),
+               min_level=Logging.Debug, match_mode=:any,
+               stepsimulation!(model))
+    @test model[1].age == 1
+    @test model[2].traits["phase"] == "drought"
+    createevent!(model, [pond], Ps.sowing)
+    @test_logs((:debug, "Mermaid 1 is trying to survive a drought."),
+               (:debug, "Mermaid 2 is trying to survive a drought."),
+               min_level=Logging.Debug, match_mode=:any,
+               stepsimulation!(model))
+    @test_logs((:debug, "Mermaid 1 is swimming happily in its pond."),
+               (:debug, "Mermaid 1 has reproduced."),
+               (:debug, "Mermaid 2 is swimming happily in its pond."),
+               min_level=Logging.Debug, match_mode=:any,
+               stepsimulation!(model))
+    @test Ps.countanimals(pond, model) == 3
+    createevent!(model, [pond], Ps.pesticide)
+    @test_logs((:debug, "Mermaid 1 is swimming happily in its pond."),
+               (:debug, "Mermaid 1 has died."),
+               (:debug, "Mermaid 2 is swimming happily in its pond."),
+               (:debug, "Mermaid 2 has died."),
+               (:debug, "Mermaid 3 is swimming happily in its pond."),
+               (:debug, "Mermaid 3 has died."),
+               min_level=Logging.Debug, match_mode=:any,
+               stepsimulation!(model))
+    @test Ps.countanimals(pond, model) == 0
 end
-- 
GitLab