From 2673d3bae780dfd444f58ba58878080065208805 Mon Sep 17 00:00:00 2001
From: Daniel Vedder <daniel.vedder@idiv.de>
Date: Fri, 31 May 2024 16:36:35 +0200
Subject: [PATCH] Started upgrading animal tests

---
 Makefile                      |  2 +-
 src/Persefone.jl              |  8 +--
 src/crop/farmplot.jl          |  6 +--
 src/nature/macros.jl          | 20 ++++++--
 src/nature/populations.jl     | 11 +++--
 src/nature/species/skylark.jl |  7 +--
 src/parameters.toml           |  3 +-
 test/nature_tests.jl          | 92 ++++++++++++++++++++++-------------
 8 files changed, 91 insertions(+), 58 deletions(-)

diff --git a/Makefile b/Makefile
index 7d1cfcc..6ef66c4 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ SHELL = /bin/bash
 run:
 	# run an example simulation
 	if [ -d "example_results" ]; then rm -r example_results; fi
-	./run.jl -o example_results
+	julia run.jl -o example_results
 	#src/analysis/analyse_nature.R example_results
 
 test:
diff --git a/src/Persefone.jl b/src/Persefone.jl
index 4e1c38e..fd4dbe4 100644
--- a/src/Persefone.jl
+++ b/src/Persefone.jl
@@ -79,6 +79,7 @@ export
     @distancetoedge,
     @randompixel,
     @randomdirection,
+    @nearby_animals,
     @neighbours,
     @move,
     @walk,
@@ -132,18 +133,13 @@ include("nature/nature.jl")
 include("nature/macros.jl")
 include("nature/populations.jl")
 include("nature/ecologicaldata.jl")
-#TODO loop over files in nature/species directory
-# (the below doesn't work yet)
-# for f in readdir("nature/species", join=true)
-#     endswith(f, ".jl") && include(f)
-# end
 include("nature/species/skylark.jl")
 include("nature/species/wolpertinger.jl")
 include("nature/species/wyvern.jl")
 
 include("core/simulation.jl") #this must be last
 
-# precompile important functions - XXX use PrecompileTools.jl
+# precompile important functions - TODO use PrecompileTools.jl
 precompile(initialise, (String,Int))
 precompile(stepsimulation!, (SimulationModel,))
 
diff --git a/src/crop/farmplot.jl b/src/crop/farmplot.jl
index 328b5bc..cd18822 100644
--- a/src/crop/farmplot.jl
+++ b/src/crop/farmplot.jl
@@ -180,12 +180,12 @@ end
 """
     cropname(model, position)
 
-Return the name of the crop at this position, or nothing if there is no crop here
+Return the name of the crop at this position, or an empty string if there is no crop here
 (utility wrapper).
 """
 function cropname(pos::Tuple{Int64,Int64}, model::SimulationModel)
-    ismissing(model.landscape[pos...].fieldid) ? nothing :
-              model.farmplots[model.landscape[pos...].fieldid].croptype.name
+    field = model.landscape[pos...].fieldid
+    ismissing(field) ? "" : model.farmplots[field].croptype.name
 end
 
 """
diff --git a/src/nature/macros.jl b/src/nature/macros.jl
index e0df561..7b0f414 100644
--- a/src/nature/macros.jl
+++ b/src/nature/macros.jl
@@ -47,7 +47,9 @@ the `model` variable (an object of type `SimulationModel`).
 """
 macro species(name, body)
     quote
-        @kwdef mutable struct $(name) <: Animal
+        @kwdef mutable struct $name <: Animal
+            #FIXME once Julia 1.11 is released, escape $name above
+            #(https://discourse.julialang.org/t/kwdef-constructor-not-available-outside-of-module/114675/4)
             const id::Int64
             const sex::Sex = hermaphrodite
             const parents::Tuple{Int64,Int64} = (-1, -1) #XXX assumes sexual reprod.
@@ -63,9 +65,6 @@ macro species(name, body)
             $(esc(name))(id=id, sex=sex, parents=parents, pos=pos, phase=phase)
         # define a single-argument constructor for utility purposes (especially testing)
         $(esc(name))(id) = $(esc(name))(id=id, parents=(-1, -1), pos=(-1, -1))
-        # allow species to be defined outside of the Persefone module, but still
-        # available inside it (needed by `initnature!()` and `reproduce!()`)
-        (@__MODULE__() != $(esc(:Persefone))) && ($(esc(:Persefone)).$name = $(name))
     end
 end
 
@@ -309,7 +308,7 @@ end
 """
     @cropname
 
-Return the name of the local croptype, or nothing if there is no crop here.
+Return the name of the local croptype, or an empty string if there is no crop here.
 This is a utility wrapper that can only be used nested within [`@phase`](@ref)
 or [`@habitat`](@ref).
 """
@@ -383,6 +382,17 @@ macro randomdirection(args...)
     :(randomdirection($(esc(:model)), $(map(esc, args)...)))
 end
 
+"""
+    @nearby_animals(radius=0, species="")
+
+Return an iterator over all animals in the given radius around the current position.
+This can only be used nested within [`@phase`](@ref) or [`@habitat`](@ref).
+"""
+macro nearby_animals(args...)
+    #XXX does it make sense to use `pos` here? What if an an animal wants to look at another place?
+    :(nearby_animals($(esc(:pos)), $(esc(:model)), $(map(esc, args)...))) #FIXME
+end
+
 """
     @neighbours(radius=0, conspecifics=true)
 
diff --git a/src/nature/populations.jl b/src/nature/populations.jl
index 6fdb7be..26d3cd0 100644
--- a/src/nature/populations.jl
+++ b/src/nature/populations.jl
@@ -76,7 +76,10 @@ function initpopulation!(speciesname::String, model::SimulationModel)
         for x in @shuffle!(Vector(1:width))
             for y in @shuffle!(Vector(1:height))
                 if p.habitat((x,y), model) &&
-                    (p.popdensity <= 0 || @chance(1/p.popdensity)) #XXX what if pd==0?
+                    (p.popdensity <= 0 || n == 0 || @chance(1/p.popdensity)) #XXX what if pd==0?
+                    #XXX `n==0` above guarantees that at least one individual is created, even
+                    # in a landscape that is otherwise too small for the specified popdensity -
+                    # do we want this?
                     if p.pairs
                         a1 = species(length(model.animals)+1, male, (-1, -1), (x,y), p.initphase)
                         a2 = species(length(model.animals)+2, female, (-1, -1), (x,y), p.initphase)
@@ -105,7 +108,7 @@ function initpopulation!(speciesname::String, model::SimulationModel)
         end
         lastn = n
     end
-    @info "Initialised $(n) $(species)s."
+    @info "Initialised $(n) $(speciesname)s."
 end
 
 #XXX initpopulation with dispersal from an original source?
@@ -209,7 +212,7 @@ end
 Return a list of animals in the given radius around this position, optionally filtering by species.
 """
 function nearby_animals(pos::Tuple{Int64,Int64}, model::SimulationModel;
-                        radius::Int64=0, species="")
+                        radius::Int64=0, species="") #XXX add type for species
     neighbours = nearby_ids(pos, model, radius)
     isempty(neighbours) && return neighbours
     if isempty(species)
@@ -225,7 +228,7 @@ end
 Return a list of animals in the given radius around this animal, excluding itself. By default,
 only return conspecific animals.
 """
-function neighbours(animal::Animal, model::SimulationModel, radius::Int64=0, conspecifics=true)
+function neighbours(animal::Animal, model::SimulationModel, radius::Int64=0, conspecifics::Bool=true)
     filter(a -> a.id != animal.id,
            nearby_animals(animal.pos, model, radius = radius,
                           species = conspecifics ? speciesof(animal) : ""))
diff --git a/src/nature/species/skylark.jl b/src/nature/species/skylark.jl
index 038c0a0..0846dca 100644
--- a/src/nature/species/skylark.jl
+++ b/src/nature/species/skylark.jl
@@ -6,7 +6,7 @@
 skylarkhabitat = @habitat((@landcover() == grass ||
                          # settle on grass or arable land (but not maize)
                          (@landcover() == agriculture && @cropname() != "maize")) &&
-                        @distancetoedge() > 5) # at least 50m from other habitats
+                        @distancetoedge() >= 5) # at least 50m from other habitats
                         #XXX this ought to check for distance to forest and builtup,
                         # but that's very expensive (see below)
                         # @distanceto(forest) > 5 && # at least 50m from forest edges
@@ -32,6 +32,7 @@ At the moment, this implementation is still in development.
 @species Skylark begin
 
     #XXX use Unitful.jl
+    #XXX add type annotations
     
     eggtime = 11 # 11 days from laying to hatching
     eggpredationmortality = 0.03 # per-day egg mortality from predation
@@ -256,6 +257,6 @@ end
     habitat = skylarkhabitat
     initphase = mating
     birthphase = egg
-    popdensity=300 #XXX use Unitful.jl
-    pairs=true
+    popdensity = 300 #XXX use Unitful.jl
+    pairs = true
 end
diff --git a/src/parameters.toml b/src/parameters.toml
index fe701fb..3a068c7 100644
--- a/src/parameters.toml
+++ b/src/parameters.toml
@@ -30,7 +30,8 @@ weatherfile = "data/regions/jena-small/weather.csv" # location of the weather da
 farmmodel = "FieldManager" # which version of the farm model to use (not yet implemented)
 
 [nature]
-targetspecies = ["Skylark"] #["Wolpertinger", "Wyvern"] # list of target species to simulate
+#targetspecies = ["Wolpertinger", "Wyvern"] # list of target species to simulate - example species
+targetspecies = ["Skylark"] # list of target species to simulate
 popoutfreq = "daily" # output frequency population-level data, daily/monthly/yearly/end/never
 indoutfreq = "end" # output frequency individual-level data, daily/monthly/yearly/end/never
 insectmodel = ["season", "habitat", "pesticides", "weather"] # factors affecting insect growth
diff --git a/test/nature_tests.jl b/test/nature_tests.jl
index b552dfa..f23cba5 100644
--- a/test/nature_tests.jl
+++ b/test/nature_tests.jl
@@ -5,10 +5,29 @@
 
 ## Test species definition
 
-@species Mermaid begin
+#FIXME reactivate once Julia 1.11 is released and @species is fixed
+#(https://discourse.julialang.org/t/kwdef-constructor-not-available-outside-of-module/114675/4)
+# @species Mermaid begin
+#     ageofmaturity = 2
+#     pesticidemortality = 1.0
+# end
+
+#FIXME remove manual definition of Mermaid once Julia 1.11 is released (see above)
+@kwdef mutable struct Mermaid <: Animal
+    const id::Int64
+    const sex::Persefone.Sex = Persefone.hermaphrodite
+    const parents::Tuple{Int64,Int64} = (-1, -1)
+    pos::Tuple{Int64,Int64}
+    phase::Function = (self,model)->nothing
+    age::Int = 0
+    energy::Union{Persefone.EnergyBudget,Nothing} = nothing
+    offspring::Vector{Int64} = Vector{Int64}()
     ageofmaturity = 2
     pesticidemortality = 1.0
 end
+Mermaid(id, sex, parents, pos, phase) =
+    Mermaid(id=id, sex=sex, parents=parents, pos=pos, phase=phase)
+Mermaid(id) = Mermaid(id=id, parents=(-1, -1), pos=(-1, -1))
 
 @create Mermaid begin
     @debug "Created $(animalid(self))."
@@ -40,7 +59,6 @@ end
 
 ## Test sets
 
-#FIXME
 @testset "Habitat macros" begin
     # set up the testing landscape
     model = inittestmodel()
@@ -49,7 +67,7 @@ end
           FarmPlot(1, [(6,6)], model.crops["winter wheat"], Ps.janfirst,
                    0.0, 0.0, 0.0, 0.0, Vector{Ps.EventType}()))
     push!(model.animals,
-          Mermaid(1, Ps.male, (-1,-1), (3,3), life), #FIXME unsupported keyword arguments?
+          Mermaid(1, Ps.male, (-1,-1), (3,3), life),
           Mermaid(2, Ps.female, (-1,-1), (4,4), life))
     # create a set of habitat descriptors
     h1 = @habitat(@landcover() == Ps.water)
@@ -57,7 +75,7 @@ end
                   @cropheight() < 2)
     h3 = @habitat(@distanceto(Ps.water) > 2 &&
                   @distancetoedge() <= 2)
-    h4 = @habitat(length(@neighbours(1)) == 1)
+    h4 = @habitat(length(@nearby_animals(radius=1)) == 1) #FIXME defining radius doesn't work
     # test the descriptors
     @test h1((6,4), model) == true
     @test h1((5,4), model) == false
@@ -66,9 +84,9 @@ end
     @test h3((3,3), model) == true
     @test h3((5,4), model) == false
     @test h3((6,1), model) == false
-    @test h4((2,2), model) == true
-    @test h4((3,3), model) == false
-    @test h4((1,1), model) == false
+    @test_broken h4((2,2), model) == true
+    @test_broken h4((3,3), model) == false
+    @test_broken h4((1,1), model) == false
 end
 
 #FIXME the way initialisation works has completely changed...
@@ -213,31 +231,35 @@ end
                                   
 end
 
-# @testset "Skylark submodel" begin
-#     # set up a modified test landscape
-#     model = inittestmodel()
-#     for x in 1:4
-#         model.landscape[x,4] = Pixel(Ps.agriculture, missing, [], [])
-#     end
-#     # test migration
-#     @test_logs((:info, "Initialised 2 Skylarks."),
-#                (:debug, "Skylark 1 has migrated."),
-#                (:debug, "Skylark 2 has migrated."),
-#                min_level=Logging.Debug, match_mode=:any,
-#                Ps.initpopulation!("Skylark", Ps.withtestlogger(model)))
-#     @test length(model.animals) == 2
-#     @test all(isnothing, model.animals)
-#     @test length(model.migrants) == 2
-#     @test model.migrants[1].first.sex != model.migrants[2].first.sex
-#     for a in model.migrants
-#         leave, arrive = a.first.migrationdates
-#         @test leave[1] in (9, 10) || (leave[1] == 11 && leave[2] <= 15)
-#         @test (arrive[1] == 2 && arrive[2] >= 15) || (arrive[1] == 3 && arrive[2] <= 15)
-#     end
-#     model.date = Date(year(model.date), 3, 17)
-#     @test_logs((:debug, "Skylark 1 has returned."),
-#                (:debug, "Skylark 2 has returned."),
-#                min_level=Logging.Debug, match_mode=:any,
-#                Ps.updatenature!(Ps.withtestlogger(model)))
-#     #TODO
-# end
+#TODO test Wolpertinger/Wyvern?
+
+@testset "Skylark submodel" begin
+    # set up a modified test landscape
+    model = inittestmodel()
+    for x in 1:6
+        for y in 5:6
+            model.landscape[x,y] = Pixel(Ps.agriculture, missing, [], [])
+        end
+    end
+    # test migration
+    @test_logs((:info, "Initialised 2 Skylarks."),
+               (:debug, "Skylark 1 has migrated."),
+               (:debug, "Skylark 2 has migrated."),
+               min_level=Logging.Debug, match_mode=:any,
+               Ps.initpopulation!("Skylark", Ps.withtestlogger(model)))
+    @test length(model.animals) == 2
+    @test all(isnothing, model.animals)
+    @test length(model.migrants) == 2
+    @test model.migrants[1].first.sex != model.migrants[2].first.sex
+    for a in model.migrants
+        leave, arrive = a.first.migrationdates
+        @test leave[1] in (9, 10) || (leave[1] == 11 && leave[2] <= 15)
+        @test (arrive[1] == 2 && arrive[2] >= 15) || (arrive[1] == 3 && arrive[2] <= 15)
+    end
+    model.date = Date(year(model.date), 3, 17)
+    @test_logs((:debug, "Skylark 1 has returned."),
+               (:debug, "Skylark 2 has returned."),
+               min_level=Logging.Debug, match_mode=:any,
+               Ps.updatenature!(Ps.withtestlogger(model)))
+    #TODO
+end
-- 
GitLab