diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a692c70b533865e4208db0b02fec9bef50ad11..0ea22d9d622839c96ae0b75c4c23342cd9171f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,9 @@ way the species definition macros work and are used. - functions (and associated macros) to replace Agents.jl functionality: - `move!()` and `walk!()` - - `nearby_ids()`, `nearby_animals()`, `neighbours()` + - `nearby_ids()`, `nearby_animals()`, `countanimals()`, `neighbours()` - `directionto()`, `distanceto()`, `randomdirection()` - - `nagents()` + - `nagents()`, `killallanimals!()` - `@here` diff --git a/src/Persefone.jl b/src/Persefone.jl index fd4dbe42036758ad45bdbb68b3c3dc481cf7c01e..7e2fdd5c24efa789fa4259f2c203066f1852d85b 100644 --- a/src/Persefone.jl +++ b/src/Persefone.jl @@ -80,6 +80,7 @@ export @randompixel, @randomdirection, @nearby_animals, + @countanimals, @neighbours, @move, @walk, diff --git a/src/core/simulation.jl b/src/core/simulation.jl index 646f47a51e4562a980e2fd7437aa1bcce3e83e8a..fbc42358ea062a75d7c3655134dbcfc8c3779864 100644 --- a/src/core/simulation.jl +++ b/src/core/simulation.jl @@ -35,7 +35,7 @@ end Return the total number of agents in a model object. """ function nagents(model::AgricultureModel) - length(model.animals)+length(model.migrants)+length(model.farmers)+length(model.farmplots) + length(model.animals)+length(model.farmers)+length(model.farmplots) end """ diff --git a/src/nature/macros.jl b/src/nature/macros.jl index 7b0f414b595de75b683df5d7f6a5054f3eba81f8..226f979295a3f579e4bc5f890d2593620fa690b0 100644 --- a/src/nature/macros.jl +++ b/src/nature/macros.jl @@ -48,7 +48,7 @@ the `model` variable (an object of type `SimulationModel`). macro species(name, body) quote @kwdef mutable struct $name <: Animal - #FIXME once Julia 1.11 is released, escape $name above + #TODO 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 @@ -389,8 +389,21 @@ Return an iterator over all animals in the given radius around the current posit This can only be used nested within [`@phase`](@ref) or [`@habitat`](@ref). """ macro nearby_animals(args...) + #FIXME doesn't work properly when nested in `@habitat` (kwargs not recognised) + #XXX do I need this macro if I have @countanimals and @neighbours? #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 + :(nearby_animals($(esc(:pos)), $(esc(:model)), $(map(esc, args)...))) +end + +""" + @countanimals(radius=0, species="") + +Count the number of animals at or near this location, optionally filtering by species. +This can only be used nested within [`@phase`](@ref) or [`@habitat`](@ref). +""" +macro countanimals(args...) + #FIXME doesn't work properly when nested in `@habitat` (kwargs not recognised) + :(countanimals($(esc(:pos)), $(esc(:model)), $(map(esc, args)...))) end """ diff --git a/src/nature/nature.jl b/src/nature/nature.jl index 2742a8d01b0dd57bd6829698b487c0e248a3325e..85e753d39a1a340c6edf528335251a684c104252 100644 --- a/src/nature/nature.jl +++ b/src/nature/nature.jl @@ -32,9 +32,10 @@ abstract type Animal <: ModelAgent end Return the species name of this animal as a string. """ -function speciesof(a::Animal) +function speciesof(a::Union{Animal,Type}) # strip out the module name if necessary (`Persefone.<species>`) - spstrings = split(string(typeof(a)), ".") + (a isa Animal) && (a = typeof(a)) + spstrings = split(string(a), ".") length(spstrings) == 1 ? spstrings[1] : spstrings[2] end @@ -122,3 +123,17 @@ function updatenature!(model::SimulationModel) deleteat!(model.migrants, 1) end end + +""" + killallanimals!(model) + +Remove all animal individuals from the simulation. +""" +function killallanimals!(model) + for a in model.animals + kill!(a, model) + end + model.migrants = Vector{Pair{Animal, Date}}() + model.animals = Vector{Union{Animal,Nothing}}() + return +end diff --git a/src/nature/populations.jl b/src/nature/populations.jl index 26d3cd0319e9e9fe32e90a49288459987d51ea78..7494dbc3ebab3bdea94f16f941fb27ddad7f643a 100644 --- a/src/nature/populations.jl +++ b/src/nature/populations.jl @@ -40,7 +40,7 @@ using [`@populate`](@ref). birthphase::Function habitat::Function = @habitat(true) popsize::Int64 = -1 - popdensity::Int64 = -1 + popdensity::Int64 = -1 #XXX this is counterintuitive pairs::Bool = false asexual::Bool = false end @@ -61,13 +61,23 @@ Initialise the population of the given species, based on the parameters stored in [`PopInitParams`](@ref). Define these using [`@populate`](@ref). """ function initpopulation!(speciesname::String, model::SimulationModel) - # get the PopInitParams and check for validity species = speciestype(speciesname) p = populationparameters(species) - (p.popsize <= 0 && p.popdensity <= 0) && #XXX not sure what this would do - @warn("initpopulation() called with popsize and popdensity both <= 0") + initpopulation!(species, p, model) +end + +""" + initpopulation!(speciestype, popinitparams, model) + +Initialise the population of the given species, based on the given initialisation parameters. +This is an internal function called by initpopulation!(), and was split off from it to allow +better testing. +""" +function initpopulation!(species::Type, p::PopInitParams, model::SimulationModel) + (p.popsize <= 0 && p.popdensity <= 0) && # can be legit if a habitat descriptor is provided + @warn("initpopulation!() called with popsize and popdensity both <= 0") (p.popsize > 0 && p.popdensity > 0) && #XXX not sure what this would do - @warn("initpopulation() called with popsize and popdensity both > 0") + @warn("initpopulation!() called with popsize and popdensity both > 0") # create as many individuals as necessary in the landscape n = 0 lastn = 0 @@ -103,12 +113,12 @@ function initpopulation!(speciesname::String, model::SimulationModel) (p.popsize > 0 && n >= p.popsize) && break end if lastn == n # prevent an infinite loop - we don't have a Cray... - @warn "There are not enough suitable locations for $(species) in the landscape." + @warn "There are not enough suitable locations for $(speciesof(species)) in the landscape." break end lastn = n end - @info "Initialised $(n) $(speciesname)s." + @info "Initialised $(n) $(speciesof(species))s." end #XXX initpopulation with dispersal from an original source? @@ -222,6 +232,17 @@ function nearby_animals(pos::Tuple{Int64,Int64}, model::SimulationModel; end end +""" + countanimals(pos, model; radius=0, species="") + +Return the number of animals in the given radius around this position, optionally filtering +by species. +""" +function countanimals(pos::Tuple{Int64,Int64}, model::SimulationModel; + radius::Int64=0, species="") #XXX add type for species + length(nearby_animals(pos, model, radius=radius, species=species)) +end + """ neighbours(animal, model, radius=0, conspecifics=true) diff --git a/src/nature/species/skylark.jl b/src/nature/species/skylark.jl index 0846dca8c2c359e8d7c198804675761bc975eff0..848858a767c37c2d8b06e0cad27f0245e1cacea4 100644 --- a/src/nature/species/skylark.jl +++ b/src/nature/species/skylark.jl @@ -32,34 +32,34 @@ At the moment, this implementation is still in development. @species Skylark begin #XXX use Unitful.jl - #XXX add type annotations + #TODO add type annotations - eggtime = 11 # 11 days from laying to hatching - eggpredationmortality = 0.03 # per-day egg mortality from predation - nestharvestmortality = 0.9 # egg/nestling mortality after a harvest event (XXX guess) + eggtime::Int64 = 11 # 11 days from laying to hatching + eggpredationmortality::Float64 = 0.03 # per-day egg mortality from predation + nestharvestmortality::Float64 = 0.9 # egg/nestling mortality after a harvest event (XXX guess) nestlingtime = 7:11 # 7-11 days from hatching to leaving nest - nestlingpredationmortality = 0.03 # per-day nestling mortality from predation + nestlingpredationmortality::Float64 = 0.03 # per-day nestling mortality from predation fledglingtime = 25:30 # 25-30 days from hatching to independence - fledglingharvestmortality = 0.5 # fledgling mortality after harvest - fledglingpredationmortality = 0.01 # per-day fledgling mortality from predation - firstyearmortality = 0.38 # total mortality in the first year after independence + fledglingharvestmortality::Float64 = 0.5 # fledgling mortality after harvest + fledglingpredationmortality::Float64 = 0.01 # per-day fledgling mortality from predation + firstyearmortality::Float64 = 0.38 # total mortality in the first year after independence migrationdates = () # is defined by each individual in @create(Skylark) - migrationmortality = 0.33 # chance of dying during the winter + migrationmortality::Float64 = 0.33 # chance of dying during the winter - mate = -1 # the agent ID of the mate (-1 if none) + mate::Int64 = -1 # the agent ID of the mate (-1 if none) nest = () # coordinates of current nest nestingbegin = (April, 10) # begin nesting in the middle of April nestbuildingtime = 4:5 # 4-5 days needed to build a nest (doubled for first nest) - nestcompletion = 0 # days left until the nest is built + nestcompletion::Int64 = 0 # days left until the nest is built eggsperclutch = 2:5 # 2-5 eggs laid per clutch clutch = [] # IDs of offspring in current clutch - breedingdelay = 18 # wait 18 days after hatching to start a new brood - nestingend = July # last month of nesting + breedingdelay::Int64 = 18 # wait 18 days after hatching to start a new brood + nestingend::Int64 = July # last month of nesting - habitats = skylarkhabitat + habitats::Function = skylarkhabitat end """ diff --git a/test/nature_tests.jl b/test/nature_tests.jl index f23cba579f39e698116079b422c607a6e49b3aec..da1c81cbfc8ddc03fdb7e220188aaae7d800cfc8 100644 --- a/test/nature_tests.jl +++ b/test/nature_tests.jl @@ -5,58 +5,43 @@ ## Test species definition -#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 +# new species have to be defined within the Persefone module scope, otherwise things don't work +Persefone.eval(quote -#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}() +@species Mermaid begin 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))." end @phase Mermaid life begin - @debug "$(Persefone.animalid(self)) is swimming happily in its pond." - @respond Persefone.pesticide @kill(self.pesticidemortality, "poisoning") - @respond Persefone.harvesting @setphase(drought) - @debug "Animal: $self" - if self.sex == Persefone.female && length(@neighbours()) < 3 && - self.age >= self.ageofmaturity && @landcover() == Persefone.water + @debug "$(animalid(self)) is swimming happily in its pond." + @respond pesticide @kill(self.pesticidemortality, "poisoning") + @respond harvesting @setphase(drought) + if self.sex == female && length(@neighbours()) < 3 && + self.age >= self.ageofmaturity && @landcover() == water @reproduce() end end @phase Mermaid drought begin n = sum(1 for a in @neighbours()) - @debug "$(Persefone.animalid(self)) is experiencing drought with $n neighbour(s)." - @respond Persefone.sowing @setphase(life) + @debug "$(animalid(self)) is experiencing drought with $n neighbour(s)." + @respond sowing @setphase(life) end @populate Mermaid begin birthphase = life initphase = life - habitat = @habitat(@landcover() == Persefone.water) + habitat = @habitat(@landcover() == water) pairs=true end +end) # end eval + ## Test sets @testset "Habitat macros" begin @@ -67,15 +52,16 @@ 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), - Mermaid(2, Ps.female, (-1,-1), (4,4), life)) + Ps.Mermaid(1, Ps.male, (-1,-1), (3,3), Ps.life), + Ps.Mermaid(2, Ps.female, (-1,-1), (4,4), Ps.life)) # create a set of habitat descriptors h1 = @habitat(@landcover() == Ps.water) h2 = @habitat(@cropname() == "winter wheat" && @cropheight() < 2) h3 = @habitat(@distanceto(Ps.water) > 2 && @distancetoedge() <= 2) - h4 = @habitat(length(@nearby_animals(radius=1)) == 1) #FIXME defining radius doesn't work + #FIXME nested macros don't work properly, counting seems wrong + h4 = @habitat(@countanimals(radius=1) == 1) # test the descriptors @test h1((6,4), model) == true @test h1((5,4), model) == false @@ -89,93 +75,97 @@ end @test_broken h4((1,1), model) == false end -#FIXME the way initialisation works has completely changed... -# @testset "Species initialisation" begin -# model = inittestmodel() -# spec = "test_animal" -# species::Dict{String,Any} = Dict("name"=>spec) -# # create a set of initialisation functions -# initfun1 = Ps.initrandompopulation(10) -# initfun2 = Ps.initrandompopulation(8*8*3, asexual=true) -# initfun3 = Ps.initpopulation(@habitat(@landcover() == Ps.grass), pairs=true) -# initfun4 = Ps.initpopulation(@habitat(@landcover() == Ps.water && -# length(@neighbours(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 192 $(spec)s.") initfun2(species, model) -# @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 36 $(spec)s.") initfun3(species, model) -# @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, species=spec, radius=4) == 0 -# @test Ps.countanimals((6,4), model, species=spec, radius=0) == 5 -# end - -#FIXME the way species work has completely changed... -# @testset "Species macros" begin -# # create a model landscape and a test species -# model = inittestmodel() -# # test a complete mermaid life cycle -# pond = (6,4) -# mermaid = Mermaid(1) -# @test typeof(mermaid) <: Animal -# @test typeof(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.harvesting) -# @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!(Ps.withtestlogger(model))) -# @test model[1].age == 1 -# @test model[2].traits["phase"] == "drought" -# createevent!(model, [pond], Ps.sowing) -# @test_logs((:debug, "Mermaid 1 is experiencing drought with 1 neighbour(s)."), -# (:debug, "Mermaid 2 is experiencing drought with 1 neighbour(s)."), -# min_level=Logging.Debug, match_mode=:any, -# stepsimulation!(Ps.withtestlogger(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!(Ps.withtestlogger(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 from poisoning."), -# (:debug, "Mermaid 2 is swimming happily in its pond."), -# (:debug, "Mermaid 2 has died from poisoning."), -# (:debug, "Mermaid 3 is swimming happily in its pond."), -# (:debug, "Mermaid 3 has died from poisoning."), -# min_level=Logging.Debug, match_mode=:any, -# stepsimulation!(Ps.withtestlogger(model))) -# @test Ps.countanimals(pond, model) == 0 - -# # test @rand (this is done more easily outside of @species) -# @test typeof(@rand()) == Float64 -# @test @rand([true, true]) +@testset "Species initialisation" begin + model = inittestmodel() + # initialisation parameters, set 1 + initparams1 = Ps.PopInitParams(birthphase = Ps.life, initphase = Ps.life, popsize = 10) + @test_logs((:info, "Initialised 10 Mermaids."), + Ps.initpopulation!(Ps.Mermaid, initparams1, model)) + @test all(a -> a.sex in (Ps.male, Ps.female), model.animals) + Ps.killallanimals!(model) + # initialisation parameters, set 2 + initparams2 = Ps.PopInitParams(birthphase = Ps.life, initphase = Ps.life, + popsize = 8*8*3, asexual=true) + @test_logs((:info, "Initialised 192 Mermaids."), + Ps.initpopulation!(Ps.Mermaid, initparams2, model)) + @test Ps.countanimals((1,1), model) == Ps.countanimals((6,6), model) == 3 + @test all(a -> a.sex == Ps.hermaphrodite, model.animals) + Ps.killallanimals!(model) + # initialisation parameters, set 3 + initparams3 = Ps.PopInitParams(birthphase = Ps.life, initphase = Ps.life, pairs = true, + habitat = @habitat(@landcover() == Ps.grass)) + @test_logs((:warn, "initpopulation!() called with popsize and popdensity both <= 0"), + (:info, "Initialised 36 Mermaids."), + Ps.initpopulation!(Ps.Mermaid, initparams3, model)) + @test Ps.countanimals((2,2), model, radius=2) == Ps.countanimals((5,3), model, radius=1) == 0 + @test Ps.countanimals((5,5), model) == Ps.countanimals((6,6), model) == 2 + a1, a2 = Ps.nearby_animals((6,6), model, radius=0) + @test a1.sex != a2.sex + Ps.killallanimals!(model) + # initialisation parameters, set 4 + initparams4 = Ps.PopInitParams(birthphase = Ps.life, initphase = Ps.life, popsize = 10, + habitat = @habitat(@landcover() == Ps.water && + @countanimals() < 5)) + @test_logs((:warn, "There are not enough suitable locations for Mermaid in the landscape."), + (:info, "Initialised 5 Mermaids."), + Ps.initpopulation!(Ps.Mermaid, initparams4, model)) + @test Ps.countanimals((1,1), model, radius=4) == 0 + @test Ps.countanimals((6,4), model) == 5 +end -# #TODO test movement macros -# end +@testset "Species macros" begin + model = inittestmodel() + pond = (6,4) + # test individual initialisation + mermaid = Ps.Mermaid(1) + @test typeof(mermaid) <: Animal + @test typeof(Ps.life) <: Function + @test typeof(mermaid.phase) <: Function + @test_logs((:debug, "Created Mermaid 1."), min_level=Logging.Debug, + Ps.create!(mermaid, Ps.withtestlogger(model))) + # test population initialisation + @test_logs((:warn, "initpopulation!() called with popsize and popdensity both <= 0"), + (:info, "Initialised 2 Mermaids."), + Ps.initpopulation!("Mermaid", model)) + @test Ps.nagents(model) == 2 + @test Ps.countanimals((1,1), model, radius=4) == 0 + @test Ps.countanimals(pond, model) == 2 + @test model.animals[1].age == 0 + # test event handling + createevent!(model, [pond], Ps.harvesting) + @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!(Ps.withtestlogger(model))) + @test model.animals[1].age == 1 + @test model.animals[2].phase == Ps.drought + createevent!(model, [pond], Ps.sowing) + @test_logs((:debug, "Mermaid 1 is experiencing drought with 1 neighbour(s)."), + (:debug, "Mermaid 2 is experiencing drought with 1 neighbour(s)."), + min_level=Logging.Debug, match_mode=:any, + stepsimulation!(Ps.withtestlogger(model))) + @test_logs((:debug, "Mermaid 1 is swimming happily in its pond."), + (:debug, "Mermaid 2 is swimming happily in its pond."), + (:debug, "Mermaid 2 has reproduced."), + (:debug, "Created Mermaid 3."), + min_level=Logging.Debug, match_mode=:any, + stepsimulation!(Ps.withtestlogger(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 from poisoning."), + (:debug, "Mermaid 2 is swimming happily in its pond."), + (:debug, "Mermaid 2 has died from poisoning."), + (:debug, "Mermaid 3 is swimming happily in its pond."), + (:debug, "Mermaid 3 has died from poisoning."), + min_level=Logging.Debug, match_mode=:any, + stepsimulation!(Ps.withtestlogger(model))) + @test Ps.countanimals(pond, model) == 0 + # test @rand (this is done more easily outside of @species) + @test typeof(@rand()) == Float64 + @test @rand([true, true]) + #TODO test movement macros +end @testset "Insect submodel" begin # create a set of pixels and dates for testing @@ -231,7 +221,7 @@ end end -#TODO test Wolpertinger/Wyvern? +#XXX test Wolpertinger/Wyvern? @testset "Skylark submodel" begin # set up a modified test landscape diff --git a/test/simulation_tests.jl b/test/simulation_tests.jl index 5b4b42e1ae0b56ab53b7909445fa9cd373a5e301..c37209b312be7ae16312b6d270b142954bc09e34 100644 --- a/test/simulation_tests.jl +++ b/test/simulation_tests.jl @@ -12,7 +12,7 @@ @test typeof(model.logger) == TeeLogger{Tuple{ConsoleLogger, ConsoleLogger}} @test length(model.dataoutputs) == 2 @test model.events == Vector{FarmEvent}() - @test Ps.nagents(model) == 2092+10+28 #FIXME 2412 == 2130 + @test Ps.nagents(model) == 2092+0+321 # farmplots+farmers+animals end @testset "Parameter scanning" begin