### Persefone.jl - a model of agricultural landscapes and ecosystems in Europe. ### ### This file contains a set of utility functions for species, including initialisation, ### reproduction, and mortality. ### ##TODO move the life-history functions into a new file `individuals.jl` """ PopInitParams A set of parameters used by [`initpopulation`](@ref) to initialise the population of a species at the start of a simulation. Define these parameters for each species using [`@populate`](@ref). - `initphase` determines which life phase individuals will be assigned to at model initialisation (required). - `birthphase` determines which life phase individuals will be assigned to at birth (required). - `habitat` is a function that determines whether a given location is suitable or not (create this using [`@habitat`](@ref)). By default, every cell will be occupied. - `popsize` determines the number of individuals that will be created, dispersed over the suitable locations in the landscape. If this is zero or negative, one individual will be created in every suitable location. If it is greater than the number of suitable locations, multiple individuals will be created per location. Alternately, use `indarea`. - `indarea`: if this is greater than zero, it determines the habitat area allocated to each individual or pair. To be precise, the chance of creating an individual (or pair of individuals) at a suitable location is 1/indarea. Use this as an alternative to `popsize`. - If `pairs` is true, a male and a female individual will be created in each selected location, otherwise, only one individual will be created at a time. (default: false) - If `asexual` is true, all created individuals are assigned the sex `hermaphrodite`, otherwise, they are randomly assigned `male` or `female`. If `pairs` is true, `asexual` is ignored. (default: false) """ @kwdef struct PopInitParams initphase::Function birthphase::Function habitat::Function = @habitat(true) popsize::Int64 = -1 indarea::Area = -1m² pairs::Bool = false asexual::Bool = false end """ populationparameters(type) A function that returns a [`PopInitParams`](@ref) object for the given species type. Parametric methods for each species are defined with [`@populate`](@ref). This is the catch-all method, which throws an error if no species-specific function is defined. """ populationparameters(t::Type) = @error("No population parameters defined for $(t), use @populate.") """ initpopulation!(speciesname, model) 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) species = speciestype(speciesname) p = populationparameters(species) 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) #XXX this is a pretty complicated function - can we make it simpler? (p.popsize <= 0 && p.indarea <= 0m²) && # can be legit if a habitat descriptor is provided @warn("initpopulation!() called with popsize and indarea both <= 0") (p.popsize > 0 && p.indarea > 0m²) && #XXX not sure what this would do @warn("initpopulation!() called with popsize and indarea both > 0") # create as many individuals as necessary in the landscape n = 0 lastn = 0 width, height = size(model.landscape) if p.indarea > 0m² pixelsperind = Int(round(p.indarea / @param(world.mapresolution)^2)) end while n == 0 || n < p.popsize for x in @shuffle!(Vector(1:width)) for y in @shuffle!(Vector(1:height)) if p.habitat((x,y), model) && (p.indarea <= 0m² || n == 0 || @chance(1/pixelsperind)) #XXX what if ppi==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 indarea - # do we want this? n += initindividuals!(species, (x,y), p, model) end #XXX break randomly to avoid initialising all individuals in a single column? (p.popsize > 0 && n >= p.popsize) && break end (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 $(speciesof(species)) in the landscape." break end lastn = n end @info "Initialised $(n) $(speciesof(species))s." end """ initindividuals!(species, pos, popinitparams, model) Initialise one or two individuals (depending on the `pairs` parameter) in the given location. Returns the number of created individuals. (Internal helper function for `initpopulation!()`.) """ function initindividuals!(species::Type, pos::Tuple{Int64,Int64}, p::PopInitParams, model::SimulationModel) if p.pairs a1 = species(length(model.animals)+1, male, (-1, -1), pos, p.initphase) a2 = species(length(model.animals)+2, female, (-1, -1), pos, p.initphase) push!(model.animals, a1, a2) push!(model.landscape[pos...].animals, a1.id, a2.id) create!(a1, model) create!(a2, model) return 2 else sex = p.asexual ? hermaphrodite : @rand([male, female]) a = species(length(model.animals)+1, sex, (-1, -1), pos, p.initphase) push!(model.animals, a) push!(model.landscape[pos...].animals, a.id) create!(a, model) return 1 end end #XXX initpopulation with dispersal from an original source? #XXX initpopulation based on known occurences in real-life? """ reproduce!(animal, model, mate, n=1) Produce one or more offspring for the given animal at its current location. The `mate` argument gives the ID of the reproductive partner. """ function reproduce!(animal::Animal, model::SimulationModel, n::Int64=1, mate::Int64=-1) (animal.sex == male) && @warn "Male $(animalid(animal)) is reproducing." for i in 1:n if animal.sex == hermaphrodite sex = hermaphrodite else sex = @rand([male, female]) end bphase = populationparameters(typeof(animal)).birthphase #TODO add DEB? child = typeof(animal)(length(model.animals)+1, sex, (animal.id, mate), animal.pos, bphase) push!(model.animals, child) push!(animal.offspring, child.id) mate > 0 && push!(model.animals[mate].offspring, child.id) push!(model.landscape[child.pos...].animals, child.id) create!(child, model) end @debug "$(animalid(animal)) has reproduced." end """ kill!(animal, model, probability=1.0, cause="") Kill this animal, optionally with a given percentage probability. 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)" model.animals[animal.id] = nothing return true end return false end """ migrate!(animal, model, arrival) 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) # 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) if isnothing(i) push!(model.migrants, Pair(animal, arrival)) else insert!(model.migrants, i, Pair(animal, arrival)) end filter!(x -> x!=animal.id, model.landscape[animal.pos...].animals) model.animals[animal.id] = nothing @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) Test whether the animal with the given ID is still alive. """ function isalive(animalid::Int64, model::SimulationModel) !isnothing(model.animals[animalid]) || any(m->m.first.id==animalid, model.migrants) end """ nearby_ids(pos, model, radius) Return a list of IDs of the animals within a given radius of the position. """ function nearby_ids(pos::Tuple{Int64,Int64}, model::SimulationModel, radius::Length) ids = [] msize = size(model.landscape) radius = Int(floor(radius / @param(world.mapresolution))) for x in (pos[1]-radius):(pos[1]+radius) (x < 1 || x > msize[1]) && continue for y in (pos[2]-radius):(pos[2]+radius) (y < 1 || y > msize[2]) && continue append!(ids, model.landscape[x,y].animals) end end ids end """ nearby_animals(pos, model; radius= 0, species="") 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::Length=0m, species="") #XXX add type for species neighbours = nearby_ids(pos, model, radius) isempty(neighbours) && return neighbours if isempty(species) model.animals[neighbours] else filter(x -> speciesof(x) == species, model.animals[neighbours]) 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::Length=0m, species="") #XXX add type for species length(nearby_animals(pos, model, radius=radius, species=species)) end """ neighbours(animal, model, radius=0, conspecifics=true) 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::Length=0m, conspecifics::Bool=true) filter(a -> a.id != animal.id, nearby_animals(animal.pos, model, radius = radius, species = conspecifics ? speciesof(animal) : "")) end """ directionto(pos, model, animal) Calculate the direction from the given position to the animal. """ function directionto(pos::Tuple{Int64,Int64}, model::SimulationModel, animal::Animal) # have to use a coordinate as first argument rather than an animal because of @directionto animal.pos - pos end """ distanceto(pos, model, animal) Calculate the distance from the given position to the animal. """ function distanceto(pos::Tuple{Int64,Int64}, model::SimulationModel, animal::Animal) # have to use a coordinate as first argument rather than an animal because of @distanceto #XXX this is very imprecise because diagonal distances are not calculated trigonometrically maximum(abs.(animal.pos .- pos)) * @param(world.mapresolution) end """ followanimal!(follower, leader, model, distance=0) Move the follower animal to a location near the leading animal. """ function followanimal!(follower::Animal, leader::Animal, model::SimulationModel, distance::Length=0m) #TODO test function dist = Int(floor(distance / @param(world.mapresolution))) spacing = Tuple(@rand(-dist:dist, 2)) targetposition = safebounds(spacing .+ leader.pos, model) move!(follower, model, targetposition) end """ move!(animal, model, position) Move the animal to the given position, making sure that this is in-bounds. If the position is out of bounds, the animal stops at the map edge. """ function move!(animal::Animal, model::SimulationModel, position::Tuple{Int64,Int64}) #XXX should this function give some sort of warning (e.g. return false) # if the original position is not reachable? filter!(x -> x != animal.id, model.landscape[animal.pos...].animals) animal.pos = safebounds(position, model) push!(model.landscape[animal.pos...].animals, animal.id) end """ walk!(animal, model, direction, distance=1pixel) Let the animal move a given number of steps in the given direction ("north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest", "random"). """ function walk!(animal::Animal, model::SimulationModel, direction::String, distance::Length=-1m) distance < 0m ? steps = 1 : steps = Int(floor(distance/@param(world.mapresolution))) if direction == "north" shift = (0,-steps) elseif direction == "northeast" shift = (steps,-steps) elseif direction == "east" shift = (steps,0) elseif direction == "southeast" shift = (steps,steps) elseif direction == "south" shift = (0,steps) elseif direction == "southwest" shift = (-steps,steps) elseif direction == "west" shift = (-steps,0) elseif direction == "northwest" shift = (-steps,-steps) elseif direction == "random" shift = Tuple(@rand([-steps,0,steps], 2)) else @error "Invalid direction in @walk: "*direction end walk!(animal, model, shift) end """ walk!(animal, model, direction, distance=-1) Let the animal move in the given direction, where the direction is defined by an (x, y) tuple to specify the shift in coordinates. If maxdist >= 0, move no further than the specified distance. """ function walk!(animal::Animal, model::SimulationModel, direction::Tuple{Int64,Int64}, maxdist::Length=-1m) #TODO test distance = Int(floor(maxdist/@param(world.mapresolution))) if distance >= 0 direction[1] > distance && (direction[1] = distance) direction[2] > distance && (direction[2] = distance) direction[1] < -distance && (direction[1] = -distance) direction[2] < -distance && (direction[2] = -distance) end newpos = animal.pos .+ direction move!(animal, model, newpos) end #TODO add a walk function with a habitat descriptor ##TODO add walktoward or similar function (incl. pathfinding?)