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