From 2db97204a8b33a5dd7005d379c5aa4c498750dc9 Mon Sep 17 00:00:00 2001
From: Daniel Vedder <daniel.vedder@idiv.de>
Date: Wed, 17 Jul 2024 10:43:01 +0200
Subject: [PATCH] Implemented animal territories.

(Not yet tested.)
---
 src/nature/macros.jl      | 45 +++++++++++++++++++++++++++++--
 src/nature/nature.jl      |  1 +
 src/nature/populations.jl | 56 ++++++++++++++++++++++++++++++++++++++-
 src/world/landscape.jl    | 37 +++++++++++++++++---------
 test/nature_tests.jl      | 17 ++++++------
 test/runtests.jl          |  4 +--
 6 files changed, 134 insertions(+), 26 deletions(-)

diff --git a/src/nature/macros.jl b/src/nature/macros.jl
index 237782a..264b1f6 100644
--- a/src/nature/macros.jl
+++ b/src/nature/macros.jl
@@ -45,8 +45,9 @@ macro species(name, body)
             pos::Tuple{Int64,Int64}
             phase::Function = (self,model)->nothing
             age::Int = 0
-            energy::Union{EnergyBudget,Nothing} = nothing # DEB is optional
+            energy::Union{EnergyBudget,Nothing} = nothing # DEB is optional #TODO remove?
             offspring::Vector{Int64} = Vector{Int64}()
+            territory::Vector{Tuple{Int64,Int64}} = Vector{Tuple{Int64,Int64}}()
             $(body.args...)
         end
         # define a constructor giving the minimum necessary arguments as positional arguments
@@ -106,7 +107,7 @@ variables:
 Many macros are available to make the code within the body of `@phase` more succinct.
 Some of the most important of these are: [`@setphase`](@ref), [`@respond`](@ref),
 [`@kill`](@ref), [`@reproduce`](@ref), [`@neighbours`](@ref), [`@migrate`](@ref),
-[`@move`](@ref), [`@rand`](@ref).
+[`@move`](@ref), [`@occupy`](@ref), [`@rand`](@ref).
 """
 macro phase(species, phase, body)
     quote
@@ -237,6 +238,46 @@ macro migrate(arrival)
     :(migrate!($(esc(:self)), $(esc(:model)), $(esc(arrival))))
 end
 
+"""
+    @occupy(position)
+
+Add the given position to this animal's territory. Use [`@vacate`](@ref) to
+remove positions from the territory again. This can only be used nested within [`@phase`](@ref).
+"""
+macro occupy(position)
+    :(occupy!($(esc(:self)), $(esc(:model)), $(esc(position))))
+end
+
+"""
+    @isoccupied(position)
+
+Test whether this position is already occupied by an animal of this species.
+This can only be used nested within [`@phase`](@ref).
+"""
+macro isoccupied(position)
+    :(isoccupied($(esc(:model)), speciesof($(esc(:self))), $(esc(position))))
+end
+
+"""
+    @vacate(position)
+
+Remove the given position from this animal's territory.
+This can only be used nested within [`@phase`](@ref).
+"""
+macro vacate(position)
+    :(vacate!($(esc(:self)), $(esc(:model)), $(esc(position))))
+end
+
+"""
+    @vacate()
+
+Remove this animal's complete territory.
+This can only be used nested within [`@phase`](@ref).
+"""
+macro vacate()
+    :(vacate!($(esc(:self)), $(esc(:model))))
+end
+
 """
     @habitat
 
diff --git a/src/nature/nature.jl b/src/nature/nature.jl
index 85e753d..d236343 100644
--- a/src/nature/nature.jl
+++ b/src/nature/nature.jl
@@ -24,6 +24,7 @@ fields, all species contain the following fields:
 - `phase` The update function to be called during the individual's current life phase.
 - `energy` A [DEBparameters](@ref) struct for calculating energy budgets.
 - `offspring` A vector containing the IDs of an individual's children.
+- `territory` A vector of coordinates that comprise the individual's territory.
 """
 abstract type Animal <: ModelAgent end
 
diff --git a/src/nature/populations.jl b/src/nature/populations.jl
index 187157e..1b2799a 100644
--- a/src/nature/populations.jl
+++ b/src/nature/populations.jl
@@ -4,6 +4,8 @@
 ### reproduction, and mortality.
 ###
 
+##TODO move the life-history functions into a new file `individuals.jl`
+
 """
     PopInitParams
 
@@ -175,9 +177,14 @@ Returns true if the animal dies, false if not.
 function kill!(animal::Animal, model::SimulationModel,
                probability::Float64=1.0, cause::String="")
     if @rand() < probability
+        # remove the animal's location and territory pointers from the landscape
+        filter!(x -> x!=animal.id, model.landscape[animal.pos...].animals)
+        for pos in animal.territory
+            filter!(x -> x!=animal.id, model.landscape[pos...].territories)
+        end
+        # print the epitaph and remove the animal from the model
         postfix = isempty(cause) ? "." : " from $cause."
         @debug "$(animalid(animal)) has died$(postfix)"
-        filter!(x -> x!=animal.id, model.landscape[animal.pos...].animals)
         model.animals[animal.id] = nothing
         return true
     end
@@ -204,6 +211,53 @@ function migrate!(animal::Animal, model::SimulationModel, arrival::Date)
     @debug "$(animalid(animal)) has migrated."
 end
 
+"""
+    occupy!(animal, model, position)
+
+Add the given location to the animal's territory.
+"""
+function occupy!(animal::Animal, model::SimulationModel, position::Tuple{Int64,Int64})
+    if isoccupied(model, speciesof(animal), position) #XXX should this be an error?
+        @warn "Position $position is already occupied by a $(speciesof(animal))."
+    end
+    push!(animal.territories, position)
+    push!(model.landscape[position...], animal.id)
+end
+
+"""
+    isoccupied(model, position, species)
+
+Test whether this location is part of the territory of an animal of the given species.
+"""
+function isoccupied(model::SimulationModel, species::String, position::Tuple{Int64,Int64})
+    for terr in model.landscape[position...].territories
+        (speciesof(model.animals[terr]) == species) && return true
+    end
+    return false
+end
+
+"""
+    vacate!(animal, model, position)
+
+Remove this position from the animal's territory.
+"""
+function vacate!(animal::Animal, model::SimulationModel, position::Tuple{Int64,Int64})
+    filter!(x -> x!=position, animal.territory)
+    filter!(x -> x!=animal.id, model.landscape[position...].territories)
+end
+
+"""
+    vacate!(animal, model)
+
+Remove the animal's complete territory.
+"""
+function vacate!(animal::Animal, model::SimulationModel)
+    for pos in animal.territory
+        filter!(x -> x!=animal.id, model.landscape[pos...].territories)
+    end
+    animal.territory = Vector{Tuple{Int64,Int64}}()
+end
+
 """
    isalive(id, model)
 
diff --git a/src/world/landscape.jl b/src/world/landscape.jl
index 4f6a930..7d356a8 100644
--- a/src/world/landscape.jl
+++ b/src/world/landscape.jl
@@ -9,7 +9,6 @@
 
 "The types of management event that can be simulated"
 @enum Management tillage sowing fertiliser pesticide harvesting
-#XXX rename to Management or similar?
 
 """
     Pixel
@@ -19,12 +18,17 @@ in a single object. The model landscape consists of a matrix of pixels.
 (Note: further landscape information may be added here in future.)
 """
 mutable struct Pixel
-    landcover::LandCover
-    fieldid::Union{Missing,Int64}
-    events::Vector{Management}
-    animals::Vector{Int64}
+    landcover::LandCover          # land cover class at this position
+    fieldid::Union{Missing,Int64} # ID of the farmplot (if any) at this position
+    events::Vector{Management}    # management events that have been applied to this pixel
+    animals::Vector{Int64}        # IDs of animals currently at this position
+    territories::Vector{Int64}    # IDs of animals that claim this pixel as part of their territory
 end
 
+Pixel(landcover::LandCover, fieldid::Union{Missing,Int64}) =
+    Pixel(landcover, fieldid, Vector{Management}(), Vector{Int64}(), Vector{Int64}())
+Pixel(landcover::LandCover) = Pixel(landcover, missing)
+
 """
     FarmEvent
 
@@ -63,7 +67,7 @@ function initlandscape(directory::String, landcovermap::String, farmfieldsmap::S
             lcv = LandCover(Int(landcover[x,y][1]/10))
             ff = Int64(farmfields[x,y][1])
             (iszero(ff)) && (ff = missing)
-            landscape[x,y] = Pixel(lcv, ff, Vector{Symbol}(), [])
+            landscape[x,y] = Pixel(lcv, ff)
         end
     end
     return landscape
@@ -236,6 +240,18 @@ function randomdirection(model::SimulationModel, distance::Length)
     Tuple(@rand(-range:range, 2))
 end
 
+"""
+    bounds(x; max=Inf, min=0)
+
+A utility function to make sure that a number is within a given set of bounds.
+Returns `max`/`min` if `x` is greater/less than this.
+"""
+function bounds(x::Number; max::Number=Inf, min::Number=0)
+    x > max ? max :
+        x < min ? min :
+        x
+end
+
 """
     inbounds(pos, model)
 
@@ -252,11 +268,6 @@ end
 Make sure that a given position is within the bounds of the model landscape.
 """
 function safebounds(pos::Tuple{Int64,Int64}, model::SimulationModel)
-    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)
+    width, height = size(model.landscape)
+    (bounds(pos[1], max=width, min=1), bounds(pos[2], max=height, min=1))
 end
diff --git a/test/nature_tests.jl b/test/nature_tests.jl
index 4d2d308..afeab5e 100644
--- a/test/nature_tests.jl
+++ b/test/nature_tests.jl
@@ -47,7 +47,7 @@ end) # end eval
 @testset "Habitat macros" begin
     # set up the testing landscape
     model = inittestmodel()
-    model.landscape[6,6] = Pixel(Ps.agriculture, 1, [], [])
+    model.landscape[6,6] = Pixel(Ps.agriculture, 1)
     push!(model.farmplots,
           FarmPlot(1, [(6,6)], model.crops["winter wheat"], Ps.janfirst,
                    0.0, 0.0, 0.0, 0.0, Vector{Ps.Management}()))
@@ -165,6 +165,7 @@ end
     @test typeof(@rand()) == Float64
     @test @rand([true, true])
     #TODO test movement macros
+    #TODO test territory macros
 end
 
 #XXX I'm unsure whether to keep the insect submodel at all, so rather than
@@ -175,12 +176,12 @@ end
 #     date1 = Date("2023-05-08") # day 128 (season begin)
 #     date2 = Date("2023-07-06") # day 187 (insect max)
 #     date3 = Date("2023-09-27") # day 270 (season end)
-#     p1 = Pixel(Ps.agriculture, 1, [], [])
-#     p2 = Pixel(Ps.agriculture, 1, [Ps.pesticide], [])
-#     p3 = Pixel(Ps.grass, 1, [], [])
-#     p4 = Pixel(Ps.soil, 1, [Ps.fertiliser, Ps.pesticide], [])
-#     p5 = Pixel(Ps.forest, 1, [], [])
-#     p6 = Pixel(Ps.water, 1, [], [])
+#     p1 = Pixel(Ps.agriculture, 1)
+#     p2 = Pixel(Ps.agriculture, 1, [Ps.pesticide], [], [])
+#     p3 = Pixel(Ps.grass, 1)
+#     p4 = Pixel(Ps.soil, 1, [Ps.fertiliser, Ps.pesticide], [], [])
+#     p5 = Pixel(Ps.forest, 1)
+#     p6 = Pixel(Ps.water, 1)
 #     # check whether the model calculates the same numbers I did by hand
 #     model.date = date1
 #     #FIXME these tests are still broken because insectbiomass throws a unit error
@@ -239,7 +240,7 @@ end
     # as no individuals are initialised
     for x in 1:6
         for y in 5:6
-            model.landscape[x,y] = Pixel(Ps.agriculture, missing, [], [])
+            model.landscape[x,y] = Pixel(Ps.agriculture)
         end
     end
     # test migration
diff --git a/test/runtests.jl b/test/runtests.jl
index ab6290f..b36a792 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -84,10 +84,10 @@ function smalltestlandscape()
                 (x in (1:2)) ? lc = Ps.forest :
                 (y == 8) ? lc = Ps.builtup :
                 lc = Ps.grass
-            landscape[x,y] = Pixel(lc, missing, [], [])
+            landscape[x,y] = Pixel(lc)
         end
     end
-    landscape[6,4] = Pixel(Ps.water, 0, [], [])
+    landscape[6,4] = Pixel(Ps.water, 0)
     landscape
 end
 
-- 
GitLab