From e81536f7042cbc217958ad8a539b3d94a4b9d805 Mon Sep 17 00:00:00 2001
From: Daniel Vedder <daniel.vedder@idiv.de>
Date: Fri, 2 Aug 2024 22:47:59 +0200
Subject: [PATCH] Integrated AnnualDates into the nature submodel

---
 src/core/simulation.jl        |  2 +-
 src/core/utils.jl             | 45 +++++++++++++-------
 src/nature/individuals.jl     |  2 +-
 src/nature/nature.jl          |  2 +-
 src/nature/species/skylark.jl | 79 ++++++++++++++++-------------------
 5 files changed, 69 insertions(+), 61 deletions(-)

diff --git a/src/core/simulation.jl b/src/core/simulation.jl
index ac9a457..420b24d 100644
--- a/src/core/simulation.jl
+++ b/src/core/simulation.jl
@@ -25,7 +25,7 @@ mutable struct AgricultureModel{Tcroptype,Tcropstate} <: SimulationModel
     farmers::Vector{Farmer}
     farmplots::Vector{FarmPlot{Tcropstate}}
     animals::Vector{Union{Animal,Nothing}}
-    migrants::Vector{Pair{Animal,Date}}
+    migrants::Vector{Pair{Animal,AnnualDate}}
     events::Vector{FarmEvent}
 end
 
diff --git a/src/core/utils.jl b/src/core/utils.jl
index d8077c3..26882de 100644
--- a/src/core/utils.jl
+++ b/src/core/utils.jl
@@ -47,20 +47,10 @@ Base.convert(::Type{AnnualDate}, ad::Tuple{Int64,Int64}) = AnnualDate(ad)
 
 # Interface with Dates
 AnnualDate(date::Date) = AnnualDate(month(date), day(date))
+Base.convert(::Type{AnnualDate}, ad::Date) = AnnualDate(ad)
 Dates.month(ad::AnnualDate) = ad.month
 Dates.day(ad::AnnualDate) = ad.day
-
-# Instantiate a recurring date for a given year
-"""
-    thisyear(annualdate, model)
-    nextyear(annualdate, model)
-    lastyear(annualdate, model)
-
-Convert an AnnualDate to a Date, using the current/next/previous year of the simulation run.
-"""
-thisyear(ad::AnnualDate, model::SimulationModel) = Date(year(model.date), ad.month, ad.day)
-nextyear(ad::AnnualDate, model::SimulationModel) = Date(year(model.date)+1, ad.month, ad.day)
-lastyear(ad::AnnualDate, model::SimulationModel) = Date(year(model.date)-1, ad.month, ad.day)
+Date(year::Int64, ad::AnnualDate) = Date(year, ad.month, ad.day)
 
 # Comparison between AnnualDates and with Dates
 Base.:(==)(ad1::AnnualDate, ad2::AnnualDate) = (month(ad1) == month(ad2) && day(ad1) == day(ad2))
@@ -68,9 +58,32 @@ Base.:(==)(ad::AnnualDate, d::Date) = (month(d) == month(ad) && day(d) == day(ad
 Base.:(==)(d::Date, ad::AnnualDate) = (ad == d)
 Base.:(<)(ad1::AnnualDate, ad2::AnnualDate) = (month(ad1) < month(ad2) ||
                                                (month(ad1) == month(ad2) && day(ad1) < day(ad2)))
-Base.:(<)(ad::AnnualDate, d::Date) = (thisyear(ad) < d)
-Base.:(<)(d::Date, ad::AnnualDate) = (d < thisyear(ad))
+Base.:(<)(ad::AnnualDate, d::Date) = ad < AnnualDate(d)
+Base.:(<)(d::Date, ad::AnnualDate) = AnnualDate(d) < ad
 
 # Addition and subtraction of date periods
-Base.:(+)(ad::AnnualDate, time::DatePeriod) = AnnualDate(thisyear(ad) + time)
-Base.:(-)(ad::AnnualDate, time::DatePeriod) = AnnualDate(thisyear(ad) - time)
+Base.:(+)(ad::AnnualDate, time::DatePeriod) = AnnualDate(Date(2022, ad) + time)
+Base.:(-)(ad::AnnualDate, time::DatePeriod) = AnnualDate(Date(2022, ad) - time)
+Base.:(-)(ad1::AnnualDate, ad2::AnnualDate) = ad1 > ad2 ?
+    Date(2022, ad1) - Date(2022, ad2) :
+    Date(2022, ad1) - Date(2021, ad2)
+
+# Taking ranges
+Base.:(:)(start::AnnualDate, stop::AnnualDate) =
+    if start < stop # normal case, e.g. Easter:Christmas
+        AnnualDate.(Date(2022, start):Date(2022, stop))
+    else # handle wrap-around, e.g. Christmas:Easter #XXX ignores leap years
+        AnnualDate.(Date(2021, start):Date(2022, stop))
+    end
+
+# Instantiate a recurring date for a given year
+"""
+    thisyear(annualdate, model)
+    nextyear(annualdate, model)
+    lastyear(annualdate, model)
+
+Convert an AnnualDate to a Date, using the current/next/previous year of the simulation run.
+"""
+thisyear(ad::AnnualDate, model::SimulationModel) = Date(year(model.date), ad)
+nextyear(ad::AnnualDate, model::SimulationModel) = Date(year(model.date)+1, ad)
+lastyear(ad::AnnualDate, model::SimulationModel) = Date(year(model.date)-1, ad)
diff --git a/src/nature/individuals.jl b/src/nature/individuals.jl
index 5a056e7..681e29c 100644
--- a/src/nature/individuals.jl
+++ b/src/nature/individuals.jl
@@ -60,7 +60,7 @@ end
 Remove this animal from the map and add it to the migrant species pool.
 It will be returned to its current location at the specified `arrival` date.
 """
-function migrate!(animal::Animal, model::SimulationModel, arrival::Date)
+function migrate!(animal::Animal, model::SimulationModel, arrival::AnnualDate)
     # keep model.migrants sorted by inserting the new migrant after migrants
     # that will return earlier than it
     i = findfirst(m -> m.second >= arrival, model.migrants)
diff --git a/src/nature/nature.jl b/src/nature/nature.jl
index 8344bd1..50bb104 100644
--- a/src/nature/nature.jl
+++ b/src/nature/nature.jl
@@ -117,7 +117,7 @@ function updatenature!(model::SimulationModel)
     end
     # The migrant pool is sorted by date of return, so we can simply look at the top
     # of the stack to check whether any animals are returning today.
-    while !isempty(model.migrants) && model.migrants[1].second <= model.date
+    while !isempty(model.migrants) && model.migrants[1].second == model.date
         returnee = model.migrants[1].first
         model.animals[returnee.id] = returnee
         @debug "$(animalid(returnee)) has returned."
diff --git a/src/nature/species/skylark.jl b/src/nature/species/skylark.jl
index 5a75f7e..8f511df 100644
--- a/src/nature/species/skylark.jl
+++ b/src/nature/species/skylark.jl
@@ -4,11 +4,11 @@
 ###
 
 ##TODO
+## - first-year mortality
 ## - habitat-dependent juvenile predation mortality
 ## - habitat-dependent dispersal movement
 ## - mid-season territory adjustment
 ## - temperature-dependent migration, breeding begin, and mortality
-## - first-year mortality
 
 
 """
@@ -50,6 +50,10 @@
     const nestlingpredationmortality::Float64 = 0.03 # per-day nestling mortality from predation
     const fledglingpredationmortality::Float64 = 0.01 # per-day fledgling mortality from predation
     const firstyearmortality::Float64 = 0.38 # total mortality in the first year after independence
+
+    const migrationdeparture::Tuple{AnnualDate,AnnualDate} = ((September, 15), (November, 1))
+    const migrationarrival::Tuple{AnnualDate,AnnualDate} = ((February, 15), (March, 1))
+    const migrationdelayfemales::Day = Day(15)
     const migrationmortality::Float64 = 0.33 # chance of dying during the winter
 
     const minimumterritory = 5000m² # size of territory under ideal conditions
@@ -60,11 +64,11 @@
     const nestingcover = (0.2, 0.5) # min and max preferred vegetation cover for nesting
 
     const matefaithfulness = 0.5 # chance of a female retaining her previous partner
-    const nestingbegin::Tuple{Int64,Int64} = (April, 10) # begin nesting in the middle of April
+    const nestingbegin::AnnualDate = (April, 10) # begin nesting in the middle of April
     const nestbuildingtime::UnitRange{Int64} = 4:5 # 4-5 days needed to build a nest (doubled for first nest)
     const eggsperclutch::UnitRange{Int64} = 2:5 # eggs laid per clutch
-    const nestingend::Int64 = July # last month of nesting
-
+    const nestingend::AnnualDate = (August, 15) # end of the nesting period
+    
     # individual variables
     timer::Int64 = 0 # a counter that can be used for different purposes
     migrationdates::Tuple = () # is defined by each individual in @create(Skylark)
@@ -88,14 +92,13 @@ Non-breeding adults move around with other individuals and check for migration.
         @walk("random", self.movementrange) :
         @follow(@rand(neighbours), 30m) #XXX magic number
     # check if the bird migrates
-    leave, arrive = self.migrationdates
-    if leave <= model.date < arrive
+    arrive, depart = self.migrationdates
+    if model.date >= depart
         @kill(self.migrationmortality, "migration")
         self.sex == male ?
             @setphase(territorysearch) :
             @setphase(matesearch)
         @migrate(arrive)
-        self.migrationdates = self.migrationdates .+ Year(1)
     end
 end
 
@@ -105,8 +108,8 @@ Males returning from migration move around to look for suitable habitats to esta
 @phase Skylark territorysearch begin
     if !isempty(self.territory)
         @setphase(occupation) # If the male still has a territory, it is occupied again
-    elseif month(model.date) > self.nestingend
-        @setphase(nonbreeding) # If the breeding season is over, give up looking
+    elseif model.date > self.nestingend - Month(1)
+        @setphase(nonbreeding) # If the breeding season is almost over, give up looking
     else # otherwise check whether this is a suitable location
         newterritory = findterritory(self, model)
         if isempty(newterritory)
@@ -127,9 +130,10 @@ adjusting it to new conditions when and as necessary.
 @phase Skylark occupation begin
     # move to a random location in the territory
     @move(@rand(self.territory))
-    if month(model.date) > self.nestingend
+    if model.date > self.nestingend
         # once the breeding season is over and all the young have left the nest, stop breeding
-        if self.mate == -1 || @animal(self.mate).clutch == 0
+        #FIXME under which conditions would @animal(self.mate) == nothing?
+        if self.mate == -1 || !isnothing(@animal(self.mate)) || @animal(self.mate).clutch == 0
             @setphase(nonbreeding)
         end
     end
@@ -141,7 +145,7 @@ Females returning from migration move around to look for a suitable partner with
 """
 @phase Skylark matesearch begin
     if self.mate != -1 # if the female already had a partner last year...
-        if isalive(@animal(self.mate))
+        if @isalive(self.mate)
             if @chance(self.matefaithfulness)
                 # ...check if he is still alive and the female wants to stay with him
                 #XXX is mate-faithfulness decided by the female when she returns,
@@ -168,8 +172,8 @@ Females returning from migration move around to look for a suitable partner with
         end
     end
     #@debug("$(animalid(self)) didn't find a mate.")
-    if month(model.date) > self.nestingend # stop trying to find a mate if it's too late
-        @setphase(nonbreeding)
+    if model.date > self.nestingend - Month(1)
+        @setphase(nonbreeding) # Stop trying to find a mate if it's too late
     else
         @walk("random", self.movementrange)
     end
@@ -179,10 +183,10 @@ end
 Females that have found a partner build a nest and lay eggs in a suitable location.
 """
 @phase Skylark nesting begin
-    if model.date < Date(year(model.date), self.nestingbegin...)
+    if model.date < self.nestingbegin
         # wait for nesting to begin, moving around in the territory
         @move(@rand(@animal(self.mate).territory))
-    elseif month(model.date) > self.nestingend
+    elseif model.date > self.nestingend - Month(1)
         @setphase(nonbreeding) # stop trying to nest if it's too late (should be rare)
     elseif isempty(self.nest)
         # choose site, build nest & lay eggs
@@ -193,7 +197,7 @@ Females that have found a partner build a nest and lay eggs in a suitable locati
                 self.clutch = @rand(self.eggsperclutch)
                 # time to build + 1 day per egg laid
                 self.timer = @rand(self.nestbuildingtime) + self.clutch
-                if month(model.date) == self.nestingbegin[1] 
+                if month(model.date) == month(self.nestingbegin)
                     # the first nest takes twice as long to build
                     #XXX this may affect the first two nests
                     self.timer += self.timer*2-self.clutch
@@ -201,9 +205,11 @@ Females that have found a partner build a nest and lay eggs in a suitable locati
                 break
             end
         end
+        #FIXME happens quite often (probably because the crop models don't work yet, so
+        # all agricultural areas have height == cover == 0)
         isempty(self.nest) && @warn("$(animalid(self)) didn't find a nesting location.")
     elseif self.timer == 0
-        @debug("$(animalid(self)) has laid $(self.clutch) eggs.")
+        @debug("$(animalid(self)) has laid $(self.clutch) eggs.") #FIXME 0 eggs laid?
         @setphase(breeding)
     else
         self.timer -= 1
@@ -240,9 +246,9 @@ chicks are independent or in case of brood loss.
         @respond(harvesting, destroynest!(self, "harvesting"))        
     else # restart breeding cycle if there is time
         self.timer = 0
-        if month(model.date) <= self.nestingend
+        if model.date <= self.nestingend - Month(1)
             @setphase(nesting)
-        elseif month(model.date) > self.nestingend
+        else
             @setphase(nonbreeding)
         end
     end
@@ -251,23 +257,6 @@ end
 
 ## SUPPORTING FUNCTIONS
 
-"""
-    migrationdates(skylark, model)
-
-Select the dates on which this skylark will leave for / return from its migration,
-based on observed migration patterns.
-"""
-function migrationdates(skylark::Skylark, model::SimulationModel)
-    #TODO this ought to be temperature-dependent and dynamic
-    minleave = skylark.sex == female ? (September, 15) : (October, 1)
-    minarrive = skylark.sex == male ? (February, 15) : (March, 1)
-    deltaleave = @rand(0:45) #XXX ought to be normally distributed
-    deltaarrive = @rand(0:15) #XXX ought to be normally distributed
-    leave = Date(year(model.date), minleave[1], minleave[2]) + Day(deltaleave)
-    arrive = Date(year(model.date)+1, minarrive[1], minarrive[2]) + Day(deltaarrive)
-    (leave, arrive)
-end
-
 """
     findterritory(skylark, model)
 
@@ -344,7 +333,7 @@ function allowsnesting(skylark::Skylark, model::SimulationModel, pos::Tuple{Int6
     #TODO is this condition correct? -> needs validation!
     (@landcover() == grass ||
      (@landcover() == agriculture &&
-      (skylark.nestingheight[1] <= (@cropheight()) <= skylark.nestingheight[2]) &&
+      (skylark.nestingheight[1] <= @cropheight() <= skylark.nestingheight[2]) &&
       (skylark.nestingcover[1] <= @cropcover() <= skylark.nestingcover[2]))) #&&
     #FIXME if we add the distance requirement, females don't find a nesting spot?
       #(@distanceto(forest) < skylark.mindistancetoedge) &&
@@ -370,14 +359,19 @@ should currently be on migration. Also sets other individual-specific variables.
 """
 @create Skylark begin
     @debug "Added $(animalid(self)) at $(self.pos)"
-    self.migrationdates = migrationdates(self, model) .- Year(1) #XXX should be dynamic
-    leave, arrive = self.migrationdates
-    if leave <= model.date < arrive
+    #XXX migration dates should be temperature-dependent and dynamic
+    arrive = @rand(self.migrationarrival[1]:self.migrationarrival[2])
+    depart = @rand(self.migrationdeparture[1]:self.migrationdeparture[2])
+    if self.sex == female
+        arrive += self.migrationdelayfemales
+        depart += self.migrationdelayfemales
+    end
+    self.migrationdates = (arrive, depart)
+    if model.date < arrive || model.date >= depart
         self.sex == male ?
             @setphase(territorysearch) :
             @setphase(matesearch)
         @migrate(arrive)
-        self.migrationdates = self.migrationdates .+ Year(1)
     end
 end
 
@@ -394,3 +388,4 @@ land, keeping a distance of 60m to vertical structures and giving each pair an a
     indarea = 3ha #XXX magic number
     pairs = true
 end
+
-- 
GitLab