From a48e45b575d36782bd632c8c71d6028d5e3c07be Mon Sep 17 00:00:00 2001
From: Daniel <xo30xoqa@idivnb533.usr.idiv.de>
Date: Tue, 16 Apr 2024 15:21:25 +0200
Subject: [PATCH] Trying to excise Agents.jl (not yet successful)

---
 src/Persefone.jl                   |  23 ++++-
 src/analysis/makieplots.jl         |   4 +-
 src/core/input.jl                  |   2 +-
 src/core/output.jl                 |  12 +--
 src/core/simulation.jl             |  48 ++++++----
 src/crop/crops.jl                  |   2 +
 src/crop/farmplot.jl               |  46 +++++-----
 src/farm/farm.jl                   |  10 +-
 src/nature/ecologicaldata.jl       |  14 +--
 src/nature/insects.jl              |   2 +-
 src/nature/macros.jl               | 141 ++++++++++++++---------------
 src/nature/nature.jl               |  84 +++++++----------
 src/nature/populations.jl          | 117 ++++++++++++------------
 src/nature/species/skylark.jl      | 106 ++++++++++------------
 src/nature/species/wolpertinger.jl |  33 +++----
 src/nature/species/wyvern.jl       | 101 ++++++++++-----------
 src/parameters.toml                |   2 +-
 src/world/landscape.jl             |  18 ++--
 src/world/weather.jl               |  14 +--
 19 files changed, 391 insertions(+), 388 deletions(-)

diff --git a/src/Persefone.jl b/src/Persefone.jl
index cc6fef7..e256782 100644
--- a/src/Persefone.jl
+++ b/src/Persefone.jl
@@ -14,7 +14,6 @@ module Persefone
 
 ## define dependencies
 using
-    Agents,
     ArgParse,
     CairoMakie, #XXX this is a very big dependency :-(
     CSV,
@@ -47,10 +46,12 @@ export
     Pixel,
     Weather,
     FarmEvent,
+    ModelAgent,
     FarmPlot,
     Animal,
     Farmer,
     DataOutput,
+    SimulationModel,
     #macros
     @param,
     @species,
@@ -86,6 +87,22 @@ export
     savemodelobject,
     loadmodelobject
 
+"""
+    SimulationModel
+
+The supertype of [AgricultureModel](@ref). This is needed to avoid circular
+dependencies (most types and functions depend on `SimulationModel`, but the
+definition of the model struct depends on these types).
+"""
+abstract type SimulationModel end
+
+"""
+    ModelAgent
+
+The supertype of all agents in the model (animal species, farmer types, farmplots).
+"""
+abstract type ModelAgent end
+
 ## include all module files (note that the order matters - if file
 ## b references something from file a, it must be included later)
 include("core/input.jl")
@@ -110,7 +127,7 @@ include("nature/ecologicaldata.jl")
 # for f in readdir("nature/species", join=true)
 #     endswith(f, ".jl") && include(f)
 # end
-include("nature/species/skylark.jl")
+#include("nature/species/skylark.jl")
 include("nature/species/wolpertinger.jl")
 include("nature/species/wyvern.jl")
 
@@ -118,6 +135,6 @@ include("core/simulation.jl") #this must be last
 
 # precompile important functions
 precompile(initialise, (String,Int))
-precompile(stepsimulation!, (AgentBasedModel,))
+precompile(stepsimulation!, (SimulationModel,))
 
 end
diff --git a/src/analysis/makieplots.jl b/src/analysis/makieplots.jl
index 97b599c..e6523d7 100644
--- a/src/analysis/makieplots.jl
+++ b/src/analysis/makieplots.jl
@@ -12,7 +12,7 @@ are available. Optionally, you can pass a landcover map image (this is needed
 to reduce the frequency of disk I/O for Persefone Desktop).
 Returns a Makie figure object.
 """
-function visualisemap(model::AgentBasedModel,date=nothing,landcover=nothing)
+function visualisemap(model::SimulationModel,date=nothing,landcover=nothing)
     # load and plot the map
     # Note: if the landcover map is supplied, it needs to be rotr90'ed
     isnothing(landcover) && (landcover = rotr90(load(@param(world.landcovermap))))
@@ -50,7 +50,7 @@ end
 Plot a line graph of population sizes of each species over time.
 Returns a Makie figure object.
 """
-function populationtrends(model::AgentBasedModel)
+function populationtrends(model::SimulationModel)
     pops = model.datatables["populations"]
     ncolors = max(2, length(@param(nature.targetspecies)))
     update_theme!(palette=(color=cgrad(:seaborn_bright, ncolors),), cycle=[:color])
diff --git a/src/core/input.jl b/src/core/input.jl
index 6b8987a..d7011fc 100644
--- a/src/core/input.jl
+++ b/src/core/input.jl
@@ -197,7 +197,7 @@ function loadmodelobject(fullfilename::String)
     end
     object = deserialize(fullfilename)
     # Do basic integrity checks
-    if !(typeof(object) <: Dict && typeof(object["model"]) <: AgentBasedModel)
+    if !(typeof(object) <: Dict && typeof(object["model"]) <: SimulationModel)
         @warn "This file does not contain a model object. Loading failed."
         return
     end
diff --git a/src/core/output.jl b/src/core/output.jl
index 513b140..e4bdf00 100644
--- a/src/core/output.jl
+++ b/src/core/output.jl
@@ -56,7 +56,7 @@ Replace the model logger with the currently active logger. This is intended to b
 in the testsuite to circumvent a [Julia issue](https://github.com/JuliaLang/julia/issues/48456),
 where `@test_logs` doesn't work with local loggers.
 """
-function withtestlogger(model::AgentBasedModel)
+function withtestlogger(model::SimulationModel)
     # copied together from https://github.com/JuliaLang/julia/blob/master/base/logging.jl
     logstate = current_task().logstate
     logstate == nothing ? model.logger = global_logger() : model.logger = logstate.logger
@@ -69,7 +69,7 @@ end
 Copy all input files into the output directory, including the actual parameter
 settings used. This allows replicating a run in future.
 """
-function saveinputfiles(model::AgentBasedModel)
+function saveinputfiles(model::SimulationModel)
     #XXX If this is a parallel run, we should save the global config to the top-level
     # output directory
     @debug "Setting up output directory $(@param(core.outdir))."
@@ -147,7 +147,7 @@ end
 Create and register a new data output. This function must be called by all submodels
 that want to have their output functions called regularly.
 """
-function newdataoutput!(model::AgentBasedModel, name::String, header::Vector{String},
+function newdataoutput!(model::SimulationModel, name::String, header::Vector{String},
                         outputfunction::Function, frequency::String,
                         plotfunction::Union{Function,Nothing}=nothing)
     if !(frequency in ("daily", "monthly", "yearly", "end", "never"))
@@ -178,7 +178,7 @@ Cycle through all registered data outputs and activate them according to their
 configured frequency. If `force` is `true`, activate all outputs regardless
 of their configuration.
 """
-function outputdata(model::AgentBasedModel, force=false)
+function outputdata(model::SimulationModel, force=false)
     #XXX enable output every X days, or weekly?
     #XXX all output functions except for "end" are run on the first update
     # -> should they all be run on the last update, too?
@@ -215,7 +215,7 @@ end
 Cycle through all data outputs and call their respective plot functions,
 saving each figure to file.
 """
-function visualiseoutput(model::AgentBasedModel)
+function visualiseoutput(model::SimulationModel)
     @debug "Visualising output."
     CairoMakie.activate!() # make sure we're using Cairo
     for output in model.dataoutputs
@@ -234,7 +234,7 @@ Includes the current model and Julia versions for compatibility checking.
 
 WARNING: produces large files (>100 MB) and takes a while to execute.
 """
-function savemodelobject(model::AgentBasedModel, filename::String)
+function savemodelobject(model::SimulationModel, filename::String)
     object = Dict("model"=>model,
                   "modelversion"=>pkgversion(Persefone),
                   "juliaversion"=>VERSION)
diff --git a/src/core/simulation.jl b/src/core/simulation.jl
index cbd76be..d139fdf 100644
--- a/src/core/simulation.jl
+++ b/src/core/simulation.jl
@@ -6,13 +6,13 @@
 #XXX How can I make the model output during a parallel run clearer?
 
 """
-    SimulationModel
+    AgricultureModel
 
 This is the heart of the model - a struct that holds all data and state
 for one simulation run. It is created by [`initialise`](@ref) and passed
 as input to most model functions.
 """
-mutable struct SimulationModel
+mutable struct AgricultureModel <: SimulationModel
     settings::Dict{String,Any}
     rng::AbstractRNG
     logger::AbstractLogger
@@ -29,6 +29,14 @@ mutable struct SimulationModel
     events::Vector{FarmEvent}
 end
 
+"""
+    stepagent!(agent, model)
+
+All agent types must define a stepagent!() method that will be called daily.
+"""
+function stepagent!(agent::ModelAgent, model::SimulationModel)
+    @error "Agent type $(typeof(agent)) has not defined a stepagent!() method."
+end
 
 """
     simulate(config=PARAMFILE, seed=nothing)
@@ -50,7 +58,7 @@ end
 
 Carry out a complete simulation run using a pre-initialised model object.
 """
-function simulate!(model::AgentBasedModel)
+function simulate!(model::SimulationModel)
     @info "Simulation run started at $(Dates.now())."
     runtime = Dates.value(@param(core.enddate)-@param(core.startdate))+1
     #TODO remove Agents.jl-related code, reimplement this more cleanly (#72)
@@ -62,7 +70,7 @@ end
     initialise(config=PARAMFILE, seed=nothing)
 
 Initialise the model: read in parameters, create the output data directory,
-and instantiate the AgentBasedModel object(s). Optionally allows specifying the
+and instantiate the SimulationModel object(s). Optionally allows specifying the
 configuration file and overriding the `seed` parameter. This returns a single
 model object, unless the config file contains multiple values for one or more
 parameters, in which case it creates a full-factorial simulation experiment
@@ -96,20 +104,20 @@ function initmodel(settings::Dict{String, Any})
                               settings["core.enddate"])
         crops = readcropparameters(settings["crop.cropfile"],
                                    settings["crop.growthfile"])
-        model = SimulationModel(settings,
-                                StableRNG(settings["core.seed"]),
-                                logger,
-                                Vector{DataOutput}(),
-                                Dict{String, DataFrame}(),
-                                settings["core.startdate"],
-                                landscape,
-                                weather,
-                                crops,
-                                Vector{Farmer}(),
-                                Vector{FarmPlot}(),
-                                Vector{Union{Animals,Nothing}}(),
-                                Vector{Pair{Animal, Date}}(),
-                                Vector{FarmEvent}())
+        model = AgricultureModel(settings,
+                                 StableRNG(settings["core.seed"]),
+                                 logger,
+                                 Vector{DataOutput}(),
+                                 Dict{String, DataFrame}(),
+                                 settings["core.startdate"],
+                                 landscape,
+                                 weather,
+                                 crops,
+                                 Vector{Farmer}(),
+                                 Vector{FarmPlot}(),
+                                 Vector{Union{Animals,Nothing}}(),
+                                 Vector{Pair{Animal, Date}}(),
+                                 Vector{FarmEvent}())
         saveinputfiles(model)
         initfields!(model)
         initfarms!(model)
@@ -151,7 +159,7 @@ end
 
 Execute one update of the model.
 """
-function stepsimulation!(model::AgentBasedModel)
+function stepsimulation!(model::SimulationModel)
     #TODO catch exceptions and print them to the log file
     with_logger(model.logger) do
         @info "Simulating day $(model.date)."
@@ -175,7 +183,7 @@ end
 
 Wrap up the simulation. Currently doesn't do anything except print some information.
 """
-function finalise!(model::AgentBasedModel)
+function finalise!(model::SimulationModel)
     with_logger(model.logger) do
         @info "Simulated $(model.date-@param(core.startdate))."
         @info "Simulation run completed at $(Dates.now()),\nwrote output to $(@param(core.outdir))."
diff --git a/src/crop/crops.jl b/src/crop/crops.jl
index d0495d7..927b237 100644
--- a/src/crop/crops.jl
+++ b/src/crop/crops.jl
@@ -34,6 +34,8 @@ The type struct for all crops. Currently follows the crop growth model as
 implemented in ALMaSS.
 """
 struct CropType
+    #TODO make this into an abstract type and create subtypes for different
+    # crop submodels (#70)
     name::String
     minsowdate::Union{Missing,Date}
     maxsowdate::Union{Missing,Date}
diff --git a/src/crop/farmplot.jl b/src/crop/farmplot.jl
index 200a106..1160596 100644
--- a/src/crop/farmplot.jl
+++ b/src/crop/farmplot.jl
@@ -11,8 +11,8 @@
 This represents one field, i.e. a collection of pixels with the same management.
 This is the spatial unit with which the crop growth model and the farm model work.
 """
-@agent FarmPlot GridAgent{2} begin
-    #TODO remove Agents.jl-related code, reimplement this more cleanly (#72)
+mutable struct FarmPlot
+    const id::Int64
     pixels::Vector{Tuple{Int64, Int64}}
     croptype::CropType
     phase::GrowthPhase
@@ -29,27 +29,28 @@ end
 
 Initialise the model with its farm plots.
 """
-function initfields!(model::AgentBasedModel)
+function initfields!(model::SimulationModel)
     n = 0
     convertid = Dict{Int64,Int64}()
     width, height = size(model.landscape)
     for x in 1:width
         for y in 1:height
-            # for each pixel, we need to extract the field ID given by the map input file,
-            # and convert it into the internal object ID used by Agents.jl, creating a new
-            # agent object if necessary
+            # for each pixel, we need to extract the field ID given by the map input
+            # file, and convert it into the internal object ID used by Agents.jl,
+            # creating a new agent object if necessary
             rawid = model.landscape[x,y].fieldid
             (ismissing(rawid)) && continue
             if rawid in keys(convertid)
                 objectid = convertid[rawid]
                 model.landscape[x,y].fieldid = objectid
-                push!(model[objectid].pixels, (x,y))
+                push!(model.farmplots[objectid].pixels, (x,y))
             else
                 #XXX does this phase calculation work out?
                 month(model.date) < 3 ? phase = janfirst : phase = marchfirst
-                fp = add_agent!((x,y), FarmPlot, model, [(x,y)],
+                fp = FarmPlot(length(model.farmplots)+1, [(x,y)],
                                 model.crops["natural grass"], phase,
-                                0.0, 0.0, 0.0, 0.0, Vector{EventType}())
+                              0.0, 0.0, 0.0, 0.0, Vector{EventType}())
+                push!(model.farmplots, fp)
                 model.landscape[x,y].fieldid = fp.id
                 convertid[rawid] = fp.id
                 n += 1
@@ -64,7 +65,7 @@ end
 
 Update a farm plot by one day.
 """
-function stepagent!(farmplot::FarmPlot, model::AgentBasedModel)
+function stepagent!(farmplot::FarmPlot, model::SimulationModel)
     # update growing degree days
     # if no crop-specific base temperature is given, default to 5°C
     # (https://www.eea.europa.eu/publications/europes-changing-climate-hazards-1/heat-and-cold/heat-and-cold-2014-mean)
@@ -87,7 +88,7 @@ end
 
 Sow the specified crop on this farmplot.
 """
-function sow!(cropname::String, farmplot::FarmPlot, model::AgentBasedModel)
+function sow!(cropname::String, farmplot::FarmPlot, model::SimulationModel)
     createevent!(model, farmplot.pixels, sowing)
     farmplot.croptype = model.crops[cropname]
     farmplot.phase = sow
@@ -99,7 +100,7 @@ end
 
 Harvest the crop on this farmplot.
 """
-function harvest!(farmplot::FarmPlot, model::AgentBasedModel)
+function harvest!(farmplot::FarmPlot, model::SimulationModel)
     createevent!(model, farmplot.pixels, harvesting)
     farmplot.phase in [harvest1, harvest2] ?
         farmplot.phase = harvest2 :
@@ -118,7 +119,7 @@ end
 Apply the relevant crop growth model to update the plants on this farm plot.
 Currently only supports the ALMaSS crop growth model by Topping et al.
 """
-function growcrop!(farmplot::FarmPlot, model::AgentBasedModel)
+function growcrop!(farmplot::FarmPlot, model::SimulationModel)
     fertiliser in farmplot.events ?
         curve = farmplot.croptype.lownutrientgrowth :
         curve = farmplot.croptype.highnutrientgrowth
@@ -157,12 +158,11 @@ end
 
 Calculate the average field size in hectares for the model landscape.
 """
-function averagefieldsize(model::AgentBasedModel)
+function averagefieldsize(model::SimulationModel)
     conversionfactor = 100 #our pixels are currently 10x10m, so 100 pixels per hectare
     sizes::Vector{Float64} = []
-    for a in allagents(model)
-        (typeof(a) != FarmPlot) && continue
-        push!(sizes, size(a.pixels)[1]/conversionfactor)
+    for fp in model.farmplots
+        push!(sizes, size(fp.pixels)[1]/conversionfactor)
     end
     round(sum(sizes)/size(sizes)[1], digits=2)
 end
@@ -172,9 +172,9 @@ end
 
 Return the crop at this position, or nothing if there is no crop here (utility wrapper).
 """
-function croptype(pos::Tuple{Int64,Int64}, model::AgentBasedModel)
+function croptype(pos::Tuple{Int64,Int64}, model::SimulationModel)
     ismissing(model.landscape[pos...].fieldid) ? nothing :
-              model[model.landscape[pos...].fieldid].croptype
+              model.farmplots[model.landscape[pos...].fieldid].croptype
 end
 
 """
@@ -183,9 +183,9 @@ end
 Return the name of the crop at this position, or nothing if there is no crop here
 (utility wrapper).
 """
-function cropname(pos::Tuple{Int64,Int64}, model::AgentBasedModel)
+function cropname(pos::Tuple{Int64,Int64}, model::SimulationModel)
     ismissing(model.landscape[pos...].fieldid) ? nothing :
-              model[model.landscape[pos...].fieldid].croptype.name
+              model.farmplots[model.landscape[pos...].fieldid].croptype.name
 end
 
 """
@@ -194,7 +194,7 @@ end
 Return the height of the crop at this position, or nothing if there is no crop here
 (utility wrapper).
 """
-function cropheight(pos::Tuple{Int64,Int64}, model::AgentBasedModel)
+function cropheight(pos::Tuple{Int64,Int64}, model::SimulationModel)
     ismissing(model.landscape[pos...].fieldid) ? nothing :
-              model[model.landscape[pos...].fieldid].height
+              model.farmplots[model.landscape[pos...].fieldid].height
 end
diff --git a/src/farm/farm.jl b/src/farm/farm.jl
index 42c5a42..391b825 100644
--- a/src/farm/farm.jl
+++ b/src/farm/farm.jl
@@ -8,8 +8,10 @@
 
 This is the agent type for the farm ABM. (Not yet implemented.)
 """
-@agent Farmer GridAgent{2} begin
-    #TODO remove Agents.jl-related code, reimplement this more cleanly (#72)
+mutable struct Farmer <: ModelAgent
+    #XXX make this into an abstract type and create subtypes for different
+    # farm submodels? (#69)
+    const id::Int64
     fields::Vector{FarmPlot}
     croprotation::Vector{CropType}
     #TODO add AES
@@ -20,7 +22,7 @@ end
 
 Update a farmer by one day.
 """
-function stepagent!(farmer::Farmer, model::AgentBasedModel)
+function stepagent!(farmer::Farmer, model::SimulationModel)
     #TODO
 end
 
@@ -29,6 +31,6 @@ end
 
 Initialise the model with a set of farm agents.
 """
-function initfarms!(model::AgentBasedModel)
+function initfarms!(model::SimulationModel)
     #TODO
 end
diff --git a/src/nature/ecologicaldata.jl b/src/nature/ecologicaldata.jl
index 02c646a..554f8ae 100644
--- a/src/nature/ecologicaldata.jl
+++ b/src/nature/ecologicaldata.jl
@@ -8,7 +8,7 @@
 
 Create output files for each data group collected by the nature model.
 """
-function initecologicaldata(model::AgentBasedModel)
+function initecologicaldata(model::SimulationModel)
     newdataoutput!(model, "populations", ["Date", "Species", "Abundance"],
                    savepopulationdata, @param(nature.popoutfreq), populationtrends)
     newdataoutput!(model, "individuals", ["Date","ID","X","Y","Species","Sex","Age"],
@@ -23,10 +23,10 @@ the current date and population size for each animal species. May be called neve
 daily, monthly, yearly, or at the end of a simulation, depending on the parameter
 `nature.popoutfreq`.
 """
-function savepopulationdata(model::AgentBasedModel)
+function savepopulationdata(model::SimulationModel)
     pops = Dict{String,Int}(s=>0 for s = @param(nature.targetspecies))
-    for a in allagents(model)
-        (typeof(a) != Animal) && continue
+    for a in model.animals
+        isnothing(a) && continue
         pops[a.name] += 1
     end
     for a in model.migrants
@@ -47,11 +47,11 @@ all properties of all animal individuals in the model. May be called never, dail
 monthly, yearly, or at the end of a simulation, depending on the parameter
 `nature.indoutfreq`. WARNING: Produces very big files!
 """
-function saveindividualdata(model::AgentBasedModel)
+function saveindividualdata(model::SimulationModel)
     #XXX doesn't include migrants!
     data = []
-    for a in allagents(model)
-        (typeof(a) != Animal) && continue
+    for a in model.animals
+        isnothing(a) && continue
         push!(data, [model.date,a.id,a.pos[1],a.pos[2],a.traits["name"],a.sex,a.age])
     end
     data
diff --git a/src/nature/insects.jl b/src/nature/insects.jl
index c2b81b7..d9c156d 100644
--- a/src/nature/insects.jl
+++ b/src/nature/insects.jl
@@ -24,7 +24,7 @@ estimation of likely insect biomass in a given location.
 - Paquette et al. (2013). Seasonal patterns in Tree Swallow prey (Diptera) abundance are affected by agricultural intensification. Ecological Applications, 23(1), 122–133. https://doi.org/10.1890/12-0068.1
 - Püttmanns et al. (2022). Habitat use and foraging parameters of breeding Skylarks indicate no seasonal decrease in food availability in heterogeneous farmland. Ecology and Evolution, 12(1), e8461. https://doi.org/10.1002/ece3.8461
 """
-function insectbiomass(pixel::Pixel, model::AgentBasedModel)::Float64
+function insectbiomass(pixel::Pixel, model::SimulationModel)::Float64
 
     ## if no factors are configured, insect abundance defaults to 300 mg/m²,
     ## a value in the upper range of insect biomass density in agricultural landscapes
diff --git a/src/nature/macros.jl b/src/nature/macros.jl
index ccf9386..e507290 100644
--- a/src/nature/macros.jl
+++ b/src/nature/macros.jl
@@ -4,15 +4,22 @@
 ### for defining species.
 ###
 
-## Note that this DSL consists of lots of deeply nested macro calls, which is a
-## known tricky issue in Julia (https://github.com/JuliaLang/julia/issues/23221,
+##XXX does it make sense to have all of the macros in one place,
+## or shouldn't they rather be next to the functions they wrap?
+
+## Note that this DSL consists of lots of deeply nested macro calls, which
+## is a known tricky issue in Julia (https://github.com/JuliaLang/julia/issues/23221,
 ## https://github.com/p-i-/MetaGuideJulia/wiki#example-swap-macro-to-illustrate-esc).
-## Hence all the `esc`apes in the following code - be careful when modifying!
+## Hence all the `esc`apes in the following code - take care when modifying!
+
+##TODO update documentation
 
 """
     @species(name, body)
 
 A macro used to create new species definitions for the nature model.
+All species include the standard fields described for the [Animal](@ref) type.
+
 This is effectively a simple domain-specific language, establishing a
 custom syntax to describe species' biology:
 
@@ -37,27 +44,25 @@ of the species during various parts of its life cycle. (See the documentation
 to [`@initialise`](@ref) and [`@phase`](@ref) for details).
 
 Code in a species definition block can access the rest of the model using
-the `model` variable (an object of type `AgentBasedModel`).
+the `model` variable (an object of type `SimulationModel`).
 """
 macro species(name, body)
     quote
-        Core.@__doc__ function $(esc(name))($(esc(:model))::AgentBasedModel)
-            # create internal variables and execute the definition body
-            $(esc(:name)) = string($(QuoteNode(name)))
-            $(esc(:phase)) = ""
+        Core.@__doc__ @kwdef mutable struct $(esc(name)) <: Animal
+            const id::Int64
+            const sex::Sex
+            const parents::Tuple{Int64,Int64} #XXX assumes sexual reprod.
+            pos::Tuple{Int64,Int64}
+            phase::Function
+            age::Int = 0
+            energy::Union{EnergyBudget,Nothing} = nothing # DEB is optional
+            offspring::Vector{Int64} = Vector{Int64}()
             $(esc(body))
-            # extract and process the local variables into a species dict
-            vardict = Base.@locals
-            speciesdict = Dict{String,Any}()
-            for k in keys(vardict)
-                speciesdict[string(k)] = vardict[k]
-            end
-            delete!(speciesdict, "model")
-            delete!(speciesdict, $(string(name)))
-            return speciesdict
         end
-        # allow species to be defined outside of the Persefone module, but still available
-        # inside it (needed by `initnature!()` and `reproduce!()`)
+        # define a zero-argument constructor to access default field values
+        $(esc(name))() = $(esc(name))(-1, hermaphrodite, (-1, -1), (-1, -1), ()->0)
+        # 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 = $(esc(name)))
     end
 end
@@ -72,12 +77,10 @@ up the simulation.
 Note: if this macro is not used, the variable `initialise!` must be set manually in the
 species definition.
 """
-macro initialise(habitatdescriptor, kwargs...)
-    :($(esc(:initialise!)) = initpopulation($(esc(habitatdescriptor)); $(map(esc, kwargs)...)))
+macro initialise(phase, kwargs...)
+    :($(esc(:initialise!)) = initpopulation($(esc(phase)); $(map(esc, kwargs)...)))
 end
 
-#TODO add an individual-level initialisation function!
-
 """
     @phase(name, body)
 
@@ -95,57 +98,42 @@ variables:
     `animal.sex`, and `animal.<trait>` (where <trait> is a variable that was defined
     in the top part of the species definition body).
 - `pos` gives the animal's current position as a coordinate tuple.
-- `model` a reference to the model world (an object of type `AgentBasedModel`).
+- `model` a reference to the model world (an object of type `SimulationModel`).
     This allows access to `model.date` (the current simulation date) and
     `model.landscape` (a two-dimensional array of pixels containing geographic
     information).
 
-Several utility macros can be used within the body of `@phase` as a short-hand for
-common expressions: [`@trait`](@ref), [`@setphase`](@ref), [`@respond`](@ref),
-[`@kill`](@ref), [`@reproduce`](@ref), [`@neighbours`](@ref), [`@rand`](@ref),
-[`@shuffle!`](@ref), [`@move`](@ref), [`@walk`](@ref), [`@follow`](@ref).
+Several utility macros can be used within the body of `@phase` as a
+short-hand for common expressions: [`@setphase`](@ref), [`@respond`](@ref),
+[`@kill`](@ref), [`@reproduce`](@ref), [`@neighbours`](@ref),
+[`@rand`](@ref),[`@shuffle!`](@ref), [`@move`](@ref), [`@walk`](@ref),
+[`@follow`](@ref).
 
-Note that the first phase that is defined in a species definition block will be
-the phase that animals are assigned at birth, unless the variable `phase` is
-explicitly defined by the user in the species definition block.
+Note that the first phase that is defined in a species definition block will
+be the phase that animals are assigned at birth, unless the variable `phase`
+is explicitly defined by the user in the species definition block.
 """
-macro phase(name, body)
-    #TODO the docstrings give a lot of warnings in the log - can I fix that?
+macro phase(phaseid, body)
+    species = String(phaseid.args[1])
+    phase = String(phaseid.args[2].value)
     quote
-        Core.@__doc__ function $(esc(name))($(esc(:animal))::Animal, $(esc(:model))::AgentBasedModel)
-            #TODO add `self` as a synonym for `animal`
-            $(esc(:pos)) = $(esc(:animal)).pos #XXX does this make sense?
-            #$(esc(:date)) = $(esc(:model)).date #XXX does this make sense?
+        Core.@__doc__ function $(esc(phase))(self::$(esc(species)),
+                                             model::SimulationModel)
+            $(esc(:pos)) = $(esc(:self)).pos # needed for landscape macros
             $(esc(body))
         end
-        ($(esc(:phase)) == "") && ($(esc(:phase)) = $(String(name)))
     end
 end
 
-"""
-    @trait(traitname)
-
-A utility macro to quickly access an animal's trait value.
-This can only be used nested within [`@phase`](@ref).
-"""
-macro trait(traitname)
-    #FIXME actually, we can get rid of this altogether if we add a Python-style `self`
-    #XXX This would error if called in the first part of a species definition block
-    # (i.e. outside of a @phase block). Although this is specified in the documentation,
-    # it is unexpected and liable to be overlooked. Can we add a third clause to
-    # compensate for that?
-    :($(esc(:animal)).$(traitname))
-end
-
 """
     @animal(id)
 
-Return the animal/agent object associated with this ID number.
+Return the animal object associated with this ID number.
 This can only be used in a context where the `model` object is available
 (e.g. nested within [`@phase`](@ref)).
 """
 macro animal(id)
-    :($(esc(:model))[$(esc(id))])
+    :($(esc(:model)).animals[$(esc(id))])
 end
 
 """
@@ -165,8 +153,7 @@ end
 Switch this animal over to a different phase. This can only be used nested within [`@phase`](@ref).
 """
 macro setphase(newphase)
-    #XXX make this usable in the top part of a species definition?
-    :($(esc(:animal)).phase = $(String(newphase)))
+    :($(esc(:self)).phase = $(newphase))
 end
 
 """
@@ -177,7 +164,7 @@ This can only be used nested within [`@phase`](@ref).
 """
 macro respond(eventname, body)
     quote
-        if $(esc(eventname)) in $(esc(:model)).landscape[$(esc(:animal)).pos...].events
+        if $(esc(eventname)) in $(esc(:model)).landscape[$(esc(:self)).pos...].events
             $(esc(body))
         end
     end
@@ -191,7 +178,7 @@ thin wrapper around [`kill!`](@ref), and passes on any arguments. This can only
 used nested within [`@phase`](@ref).
 """
 macro kill(args...)
-    :(kill!($(esc(:animal)), $(esc(:model)), $(map(esc, args)...)) && return)
+    :(kill!($(esc(:self)), $(esc(:model)), $(map(esc, args)...)) && return)
 end
 
 """
@@ -201,7 +188,7 @@ Let this animal reproduce. This is a thin wrapper around [`reproduce!`](@ref), a
 passes on any arguments. This can only be used nested within [`@phase`](@ref).
 """
 macro reproduce(args...)
-    :(reproduce!($(esc(:animal)), $(esc(:model)), $(map(esc, args)...)))
+    :(reproduce!($(esc(:self)), $(esc(:model)), $(map(esc, args)...)))
 end
 
 """
@@ -212,7 +199,7 @@ It will be returned to its current location at the specified `arrival` date.
 This can only be used nested within [`@phase`](@ref).
 """
 macro migrate(arrival)
-    :(migrate!($(esc(:animal)), $(esc(:model)), $(esc(arrival))))
+    :(migrate!($(esc(:self)), $(esc(:model)), $(esc(arrival))))
 end
 
 """
@@ -222,8 +209,9 @@ Return an iterator over all animals in the given radius around this animal, excl
 This can only be used nested within [`@phase`](@ref).
 """
 macro neighbours(radius)
+    #FIXME remove Agents.jl code
     #TODO enable filtering by species
-    :(nearby_animals($(esc(:animal)), $(esc(:model)), $(esc(radius))))
+    :(nearby_animals($(esc(:self)), $(esc(:model)), $(esc(radius))))
 end
 
 """
@@ -255,8 +243,9 @@ For more complex habitat suitability checks, the use of this macro can be
 circumvented by directly creating an equivalent function.
 """
 macro habitat(body)
-    #XXX I suspect that I may have less problems with macro expansion and module
-    # scoping if @habitat did not create a new function. But is there a different way?
+    #XXX I suspect that I may have less problems with macro expansion and
+    # module scoping if @habitat did not create a new function. But is
+    # there a different way?
     quote
         function($(esc(:pos)), $(esc(:model)))
             if $(esc(body))
@@ -328,9 +317,10 @@ end
 """
     @randompixel(range, habitatdescriptor)
 
-Find a random pixel within a given `range` of the animal's location that matches the
-habitatdescriptor (create this using [`@habitat`](@ref)). This is a utility wrapper
-that can only be used nested within [`@phase`](@ref).
+Find a random pixel within a given `range` of the animal's location that
+matches the habitatdescriptor (create this using [`@habitat`](@ref)).
+This is a utility wrapper that can only be used nested within
+[`@phase`](@ref).
 """
 macro randompixel(args...)
     :(randompixel($(esc(:pos)), $(esc(:model)), $(map(esc, args)...)))
@@ -340,11 +330,11 @@ end
     @countanimals(species="", radius=0)
 
 Count the number of animals of the given species in this location.
-This is a utility wrapper that can only be used nested within [`@phase`](@ref)
-or [`@habitat`](@ref).
+This is a utility wrapper that can only be used nested within
+[`@phase`](@ref) or [`@habitat`](@ref).
 """
 macro countanimals(args...)
-    :(countanimals($(esc(:pos)), $(esc(:model))))
+    :(countanimals($(esc(:pos)), $(esc(:model)), $(map(esc, args)...)))
 end
 
 ##TODO test movement macros
@@ -356,7 +346,8 @@ Move the current individual to a new position.
 This is a utility wrapper that can only be used nested within [`@phase`](@ref).
 """
 macro move(position)
-    :(move_agent!($(esc(:animal)), $(esc(position)), $(esc(:model))))
+    #FIXME remove Agents.jl code
+    :(move_agent!($(esc(:self)), $(esc(position)), $(esc(:model))))
 end
 
 """
@@ -368,8 +359,9 @@ by 2 and decrements the Y coordinate by 3.) This is a utility wrapper that can o
 used nested within [`@phase`](@ref).
 """
 macro walk(direction)
+    #FIXME remove Agents.jl code
     #XXX add `ifempty` keyword?
-    :(walk!($(esc(:animal)), $(esc(direction)), $(esc(:model))))
+    :(walk!($(esc(:self)), $(esc(direction)), $(esc(:model))))
 end
 
 """
@@ -379,8 +371,9 @@ Walk in a random direction for a specified number of steps.
 This is a utility wrapper that can only be used nested within [`@phase`](@ref).
 """
 macro randomwalk(distance)
+    #FIXME remove Agents.jl code
     #XXX add `ifempty` keyword?
-    :(randomwalk!($(esc(:animal)), $(esc(:model)), $(esc(distance))))
+    :(randomwalk!($(esc(:self)), $(esc(:model)), $(esc(distance))))
 end
 #TODO add own walking functions that respect habitat descriptors
 
@@ -391,6 +384,6 @@ Move to a location within the given distance of the leading animal.
 This is a utility wrapper that can only be used nested within [`@phase`](@ref).
 """
 macro follow(leader, distance)
-    :(followanimal!($(esc(:animal)), $(esc(leader)), $(esc(:model)), $(esc(distance))))
+    :(followanimal!($(esc(:self)), $(esc(leader)), $(esc(:model)), $(esc(distance))))
 end
 
diff --git a/src/nature/nature.jl b/src/nature/nature.jl
index 2edc9fa..b50dd8f 100644
--- a/src/nature/nature.jl
+++ b/src/nature/nature.jl
@@ -12,50 +12,28 @@
 """
     Animal
 
-This is the generic agent type for all animals. Species are differentiated
-by trait dictionaries passed by them during initialisation. (Note that each
-trait variable can still be accessed as if it were a normal field name,
-i.e. the trait `phase` can be accessed and modified with `animal.phase`.)
+This is the generic agent type for all animals. Individual species are created
+using the [`@species`](@ref) macro. All species contain the following fields:
+
+- `id` An integer unique identifier for this individual.
+- `sex` male, female, or hermaphrodite.
+- `parents` The IDs of the individual's parents.
+- `pos` An (x, y) coordinate tuple.
+- `age` The age of the individual in days.
+- `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.
 """
-@agent Animal GridAgent{2} begin
-    #TODO remove Agents.jl-related code, reimplement this more cleanly (#72)
-    #XXX is it (performance-)wise to use a dict for the traits?
-    # Doesn't that rather obviate the point of having an agent struct?
-    # If I could move the mutable traits to the struct, I wouldn't need
-    # to deepcopy the speciesdict.
-    #XXX add `phase` and `species` (rather than `name`) directly to the struct?
-    traits::Dict{String,Any}
-    energy::Union{EnergyBudget,Nothing} # DEB is optional
-    parents::Tuple{Int64,Int64} #XXX two parents for hermaphrodites?
-    sex::Sex
-    age::Int
-end
-
-# DEB is optional
-Animal(id::Int64, pos::Tuple{Int64,Int64}, traits::Dict{String,Any},
-       parents::Tuple{Int64,Int64}, sex::Sex, age::Int) =
-           Animal(id, pos, traits, nothing, parents, sex, age)
-
-# Overloading the `getproperty` and `setproperty!` methods for `Animal` allows
-# us to write `animal.property` instead of `animal.traits["property"]`.
-# (This is inspired by Agents.jl in `model.jl`.)
+abstract type Animal <: ModelAgent end
 
-function Base.getproperty(animal::Animal, s::Symbol)
-    if s in fieldnames(Animal)
-        return getfield(animal, s)
-    else
-        return animal.traits[String(s)]
-    end
-end
+"""
+    speciesof(animal)
 
-function Base.setproperty!(animal::Animal, s::Symbol, x)
-    if s in fieldnames(Animal)
-        setfield!(animal, s, x)
-    else
-        animal.traits[String(s)] = x
-    end
+Return the species name of this animal as a string.
+"""
+function speciesof(a::Animal)
+    string(typeof(a))
 end
-
     
 """
     animalid(animal)
@@ -63,7 +41,7 @@ end
 A small utility function to return a string with the species name and ID of an animal.
 """
 function animalid(a::Animal)
-    return "$(a.name) $(a.id)"
+    return "$(speciesof(a)) $(a.id)"
 end
 
 """
@@ -71,9 +49,9 @@ end
 
 Update an animal by one day, executing it's currently active phase function.
 """
-function stepagent!(animal::Animal, model::AgentBasedModel)
+function stepagent!(animal::Animal, model::SimulationModel)
     animal.age += 1
-    animal.traits[animal.phase](animal,model) #FIXME -> note to self: why, what's wrong?
+    animal.phase(animal, model)
 end
 
 """
@@ -81,11 +59,11 @@ end
 
 Initialise the model with all simulated animal populations.
 """
-function initnature!(model::AgentBasedModel)
+function initnature!(model::SimulationModel)
     # The config file determines which species are simulated in this run
     for speciesname in @param(nature.targetspecies)
-        species = @eval $(Symbol(speciesname))($model)
-        species["initialise!"](species, model)
+        # Call each species' initialisation function
+        @eval $(Symbol(speciesname))().initialise!($(Symbol(speciesname)), model)
     end
     # Initialise the data output
     initecologicaldata(model)
@@ -96,13 +74,19 @@ end
 
 Run processes that affect all animals.
 """
-function updatenature!(model::AgentBasedModel)
+function updatenature!(model::SimulationModel)
+    # Update all animals. Dead animals are replaced by `nothing`, we can skip these.
+    #XXX I assume that keeping all those `nothing`s in the animal vector won't lead
+    # to memory bloat, but I will have to check that.
+    for a in model.animals
+        !isnothing(a) && stepagent!(a, model)
+    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
-        add_agent_pos!(model.migrants[1].first, model)
-        @debug "$(animalid(model.migrants[1].first)) has returned."
+        returnee = model.migrants[1].first
+        model.animals[returnee.id] = returnee
+        @debug "$(animalid(returnee)) has returned."
         deleteat!(model.migrants, 1)
     end
-    #XXX what else needs to go here?
 end
diff --git a/src/nature/populations.jl b/src/nature/populations.jl
index 2288ecd..d218973 100644
--- a/src/nature/populations.jl
+++ b/src/nature/populations.jl
@@ -31,88 +31,93 @@ This can be used to create the `initialise!` variable in a species definition bl
 - If `asexual` is true, all created individuals are assigned the sex `hermaphrodite`,
     otherwise, they are randomly assigned male of female. (If `pairs` is true, `asexual`
     is ignored.)
-
-- `initfunction` is an optional function that takes an animal object and the model,
-    and performs individual-specific initialisation tasks. It is called after the
-    individual has been created and placed.
 """
-function initpopulation(habitatdescriptor::Function; phase::Union{String,Nothing}=nothing,
+function initpopulation(phase::Function; habitat::Function=@habitat(true),
                         popsize::Int64=-1, popdensity::Int64=-1, pairs::Bool=false,
-                        asexual::Bool=false, initfunction::Function=(a,m)->nothing)
-    #TODO add a constructor function/macro for the individual
-    function(species::Dict{String,Any}, model::AgentBasedModel)
+                        asexual::Bool=false)
+    (popsize <= 0 && popdensity <= 0) && #XXX not sure what this would do
+        @warn("initpopulation() called with popsize and popdensity both <= 0")
+    function(species::Type, model::SimulationModel)
         n = 0
         lastn = 0
-        specname = species["name"]
-        (!isnothing(phase)) && (species["phase"] = phase)
         width, height = size(model.landscape)
         while n == 0 || n < popsize
             for x in @shuffle!(Vector(1:width))
                 for y in @shuffle!(Vector(1:height))
-                    if habitatdescriptor((x,y), model) &&
+                    if habitat((x,y), model) &&
                         (popdensity <= 0 || @chance(1/popdensity)) #XXX what if pd==0?
                         if pairs
-                            a1 = add_agent!((x,y), Animal, model, deepcopy(species),
-                                            (-1,-1), female, 0)
-                            a2 = add_agent!((x,y), Animal, model, deepcopy(species),
-                                            (-1, -1), male, 0)
-                            initfunction(a1, model)
-                            initfunction(a2, model)
+                            a1 = @eval($(species))(length(model.animals)+1, male,
+                                                   (-1, -1), (x,y), phase)
+                            a2 = @eval($(species))(length(model.animals)+1, female,
+                                                   (-1, -1), (x,y), phase)
+                            initindividual(a1, model)
+                            initindividual(a2, model)
+                            push!(model.animals, a1)
+                            push!(model.animals, a2)
                             n += 2
                         else
                             sex = asexual ? hermaphrodite : @rand([male, female])
-                            a = add_agent!((x,y), Animal, model, deepcopy(species),
-                                           (-1, -1), sex,0)
-                            initfunction(a, model)
+                            a = @eval($(species))(length(model.animals)+1, sex,
+                                                  (-1, -1), (x,y), phase)
+                            initindividual(a, model)
+                            push!(model.animals, a)
                             n += 1
                         end
                     end
+                    #XXX break randomly to avoid initialising all individuals in a single column?
                     (popsize > 0 && n >= popsize) && break
                 end
                 (popsize > 0 && n >= popsize) && break
             end
             if lastn == n # prevent an infinite loop - we don't have a Cray...
-                @warn "There are not enough suitable locations for $(specname) in the landscape."
+                @warn "There are not enough suitable locations for $(string(species)) in the landscape."
                 break
             end
             lastn = n
         end
-        @info "Initialised $(n) $(specname)s."
+        @info "Initialised $(n) $(string(species))s."
     end
 end
 
+#XXX initpopulation with dispersal from an original source?
+#XXX initpopulation based on known occurences in real-life?
+
 """
-    initrandompopulation(popsize; kwargs...)
+    initindividual(animal, model)
+
+The function that is called when a new animal object is created.
+Has access to the `model` object and carries out any necessary
+initialisation routines.
 
-A simplified version of [`initpopulation`](@ref). Creates a function that initialises
-`popsize` individuals, spread at random across the landscape.
+Note that this is a stump method. Each species should implement its
+own version by defining a [`@phase`](@ref) called `initindividual`.
 """
-function initrandompopulation(popsize::Int64; kwargs...)
-    #XXX How should this be called if users are supposed to use @initialise?
-    initpopulation(@habitat(true); popsize=popsize, kwargs...)
+function initindividual(animal::Animal, model::SimulationModel)
+    @debug "No initindividual() method for species $(typeof(animal))."
 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::AgentBasedModel, mate::Int64, n::Int64=1)
+function reproduce!(animal::Animal, model::SimulationModel,
+                    n::Int64=1, mate::Int64=-1)
     (animal.sex == male) && @warn "Male $(animalid(animal)) is reproducing."
-    offspring = []
     for i in 1:n
-        sex = (animal.sex == hermaphrodite) ? hermaphrodite : @rand([male, female])
-        # We need to generate a fresh species dict here
-        species = @eval $(Symbol(animal.name))($model)
-        a = add_agent!(animal.pos, Animal, model, species, (animal.id, mate), sex, 0)
-        push!(offspring, a.id)
+        sex = (animal.sex == hermaphrodite) ?
+            hermaphrodite :
+            @rand([male, female])
+        child = @eval($(typeof(animal)))(length(model.animals)+1, sex,
+                                         (animal.id, mate), animal.pos)
+        initindividual(child, model)
+        push!(model.animals, child)
+        push!(animal.offspring, child.id)
+        mate > 0 && push!(models.animals[mate].offspring, child.id)
     end
     @debug "$(animalid(animal)) has reproduced."
-    offspring
 end
 
 """
@@ -121,11 +126,12 @@ end
 Kill this animal, optionally with a given percentage probability.
 Returns true if the animal dies, false if not.
 """
-function kill!(animal::Animal, model::AgentBasedModel, probability::Float64=1.0, cause::String="")
+function kill!(animal::Animal, model::SimulationModel,
+               probability::Float64=1.0, cause::String="")
     if @rand() < probability
         postfix = isempty(cause) ? "." : " from $cause."
         @debug "$(animalid(animal)) has died$(postfix)"
-        kill_agent!(animal, model)
+        model.animals[animal.id] = nothing
         return true
     end
     return false
@@ -137,15 +143,14 @@ 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::AgentBasedModel, arrival::Date)
-    i = 1
-    while i <= length(model.migrants) && model.migrants[i].second < arrival
-        i += 1
-    end
-    i <= length(model.migrants) ?
-        insert!(model.migrants, i, Pair(animal, arrival)) :
-        push!(model.migrants, Pair(animal, arrival))
-    remove_agent!(animal, model)
+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)
+    isnothing(i) ?
+        push!(model.migrants, Pair(animal, arrival)) :
+        insert!(model.migrants, i, Pair(animal, arrival))
+    model.animals[animal.id] = nothing
     @debug "$(animalid(animal)) has migrated."
 end
 
@@ -154,8 +159,8 @@ end
 
 Test whether the animal with the given ID is still alive.
 """
-function isalive(animalid::Int64, model::AgentBasedModel)
-    animalid in allids(model)
+function isalive(animalid::Int64, model::SimulationModel)
+    !isnothing(model.animals[animalid]) || any(m->m.first.id==animalid, model.migrants)
 end
 
 """
@@ -163,7 +168,7 @@ end
 
 Return an iterator over all animals in the given radius around this position.
 """
-function nearby_animals(pos::Tuple{Int64,Int64}, model::AgentBasedModel, radius::Int64)
+function nearby_animals(pos::Tuple{Int64,Int64}, model::SimulationModel, radius::Int64)
     #TODO enable filtering by species
     neighbours = (model[id] for id in nearby_ids(pos, model, radius))
     Iterators.filter(a -> typeof(a) == Animal, neighbours)
@@ -174,7 +179,7 @@ end
 
 Return an iterator over all animals in the given radius around this animal, excluding itself.
 """
-function nearby_animals(animal::Animal, model::AgentBasedModel, radius::Int64)
+function nearby_animals(animal::Animal, model::SimulationModel, radius::Int64)
     #TODO enable filtering by species
     neighbours = (model[id] for id in nearby_ids(animal.pos, model, radius))
     Iterators.filter(a -> typeof(a) == Animal && a.id != animal.id, neighbours)
@@ -185,7 +190,7 @@ end
 
 Count the number of animals in this location (optionally supplying a species name and radius).
 """
-function countanimals(pos::Tuple{Int64,Int64}, model::AgentBasedModel;
+function countanimals(pos::Tuple{Int64,Int64}, model::SimulationModel;
                       species::String="", radius::Int64=0)
     n = 0
     #XXX can we ignore capitalisation in the spelling of `species`?
@@ -200,7 +205,7 @@ end
 
 Move the follower animal to a location near the leading animal.
 """
-function followanimal!(follower::Animal, leader::Animal, model::AgentBasedModel,
+function followanimal!(follower::Animal, leader::Animal, model::SimulationModel,
                        distance::Int64=0)
     #TODO test function
     spacing = Tuple(@rand(-distance:distance, 2))
diff --git a/src/nature/species/skylark.jl b/src/nature/species/skylark.jl
index a7c7191..592b526 100644
--- a/src/nature/species/skylark.jl
+++ b/src/nature/species/skylark.jl
@@ -62,10 +62,10 @@ At the moment, this implementation is still in development.
     As an egg, simply check for mortality and hatching.
     """
     @phase egg begin
-        @kill(@trait(eggpredationmortality), "predation")
-        @respond(harvesting, @kill(@trait(nestharvestmortality), "harvest"))
+        @kill(self.eggpredationmortality, "predation")
+        @respond(harvesting, @kill(self.nestharvestmortality, "harvest"))
 
-        if @trait(age) == @trait(eggtime)
+        if self.age == self.eggtime
             @setphase(nestling)
         end
     end
@@ -75,9 +75,9 @@ At the moment, this implementation is still in development.
     """
     @phase nestling begin
         #TODO add feeding & growth
-        @kill(@trait(nestlingpredationmortality), "predation")
-        @respond(harvesting, @kill(@trait(nestharvestmortality), "harvest"))
-        if @trait(age) == @trait(nestlingtime)+@trait(eggtime)
+        @kill(self.nestlingpredationmortality, "predation")
+        @respond(harvesting, @kill(self.nestharvestmortality, "harvest"))
+        if self.age == self.nestlingtime+self.eggtime)
             @setphase(fledgling)
         end
     end
@@ -88,10 +88,10 @@ At the moment, this implementation is still in development.
     """
     @phase fledgling begin
         #TODO add feeding & growth
-        @kill(@trait(fledglingpredationmortality), "predation")
+        @kill(self.fledglingpredationmortality, "predation")
         @randomwalk(1) #TODO add movement following the parents
-        if @trait(age) == @trait(fledglingtime)+@trait(eggtime)
-            @kill(@trait(firstyearmortality), "first year mortality") #XXX mechanistic?
+        if self.age == self.fledglingtime+self.eggtime)
+            @kill(self.firstyearmortality, "first year mortality") #XXX mechanistic?
             @setphase(nonbreeding)
         end
     end
@@ -103,19 +103,16 @@ At the moment, this implementation is still in development.
         # flocking behaviour - follow a random neighbour or move randomly
         #TODO add feeding and mortality, respect habitat when moving
         neighbours = map(a->a.id, @neighbours(10)) #FIXME
-        #isempty(neighbours) ? @randomwalk(5) : @follow(@rand(neighbours), 2)
-        if isempty(neighbours)
-            @randomwalk(5)
-        else
-            @follow(model[@rand(neighbours)], 2)
-        end
+        isempty(neighbours) ?
+            @randomwalk(5) :
+            @follow(@animal(@rand(neighbours)), 2)
         # check if the bird migrates
         leave, arrive = animal.migrationdates
         m, d = monthday(model.date)
         migrate = (((m < arrive[1]) || (m == arrive[1] && d < arrive[2])) ||
                    ((m > leave[1]) || (m == leave[1] && d >= leave[2])))
         if migrate #FIXME not all migrate?
-            @kill(@trait(migrationmortality), "migration")
+            @kill(self.migrationmortality, "migration")
             returndate = Date(year(model.date), arrive[1], arrive[2])
             model.date != @param(core.startdate) && (returndate += Year(1))
             @setphase(mating)
@@ -128,22 +125,22 @@ At the moment, this implementation is still in development.
     """
     @phase mating begin
         # if we've found a mate, wait for nesting begin and then go to the next phase
-        if @trait(mate) != -1
-            if !@isalive(@trait(mate))
-                @trait(mate) = -1
+        if self.mate != -1
+            if !@isalive(self.mate)
+                self.mate = -1
                 return
             end
             m, d = monthday(model.date)
-            nest = ((m == @trait(nestingbegin)[1] && d >= @trait(nestingbegin)[2]
-                     && @chance(0.05)) || (m > @trait(nestingbegin)[1]))
+            nest = ((m == self.nestingbegin[1] && d >= self.nestingbegin[2]
+                     && @chance(0.05)) || (m > self.nestingbegin[1]))
             nest && @setphase(nestbuilding)
             return
         end
         # look for a mate among the neighbouring birds, or move randomly
         for n in @neighbours(50)
-            if n.sex != @trait(sex) && n.phase == "mating" && n.mate == -1
-                @trait(mate) = n.id
-                n.mate = @trait(id)
+            if n.sex != self.sex && n.phase == "mating" && n.mate == -1
+                self.mate = n.id
+                n.mate = self.id
                 @debug "$(animalid(animal)) and $(animalid(n)) have mated."
                 return
             end
@@ -155,42 +152,38 @@ At the moment, this implementation is still in development.
     Females select a location and build a nest. Males do nothing. (Sound familiar?)
     """
     @phase nestbuilding begin
-        if !@isalive(@trait(mate))
+        if !@isalive(self.mate)
             @setphase(nonbreeding)
             return
         end
-        if @trait(sex) == female
-            if isempty(@trait(nest))
+        if self.sex == female
+            if isempty(self.nest)
                 # try to find a nest in the neighbourhood, or move on
-                nestlocation = @randompixel(10, @trait(habitats))
+                nestlocation = @randompixel(10, self.habitats)
                 if isnothing(nestlocation)
                     @randomwalk(20)
                 else
                     # if we've found a location, start the clock on the building time
                     # (building time doubles for the first nest of the year)
-                    @trait(nest) = nestlocation
-                    @trait(nestcompletion) = @rand(nestbuildingtime)
-                    month(model.date) == 4 && (@trait(nestcompletion) *= 2)
+                    self.nest = nestlocation
+                    self.nestcompletion = @rand(nestbuildingtime)
+                    month(model.date) == 4 && (self.nestcompletion *= 2)
                     @debug "$(animalid(animal)) is building a nest."
                 end
             else
                 # wait while nest is being built, then lay eggs and go to next phase
-                if @trait(nestcompletion) > 0
-                    @trait(nestcompletion) -= 1
+                if self.nestcompletion > 0
+                    self.nestcompletion -= 1
                 else
                     #XXX more accurately, a female lays one egg per day, not all at once
-                    @trait(clutch) = @reproduce(@trait(mate), @rand(eggsperclutch))
-                    @animal(@trait(mate)).clutch = @trait(clutch)
-                    for c in @trait(clutch) #FIXME find a cleaner solution for this
-                        initskylark(@animal(c), model)
-                    end
+                    @reproduce(@rand(self.eggsperclutch),self.mate)
                     @setphase(breeding)
                 end
             end
         else
             # males stay near the female
-            @follow(model[@trait(mate)], 5)
-            @animal(@trait(mate)).phase == "breeding" && @setphase(breeding)
+            @follow(model[self.mate], 5)
+            @animal(self.mate).phase == "breeding" && @setphase(breeding)
         end
     end
 
@@ -199,32 +192,31 @@ At the moment, this implementation is still in development.
     """
     @phase breeding begin
         #TODO forage (move random)
-        for offspring in @trait(clutch)
+        for offspring in self.clutch
             # check if offspring are still alive and juvenile, else remove from clutch
             if !@isalive(offspring) || @animal(offspring).phase == "nonbreeding"
-                deleteat!(@trait(clutch), findfirst(x->x==offspring, @trait(clutch)))
+                deleteat!(self.clutch, findfirst(x->x==offspring, self.clutch))
             end
         end
         # if all young have fledged, move to nonbreeding (if it's July) or breed again
-        if isempty(@trait(clutch))
-            @trait(nest) = ()
-            month(model.date) >= 7 ? @setphase(nonbreeding) : @setphase(nestbuilding)
+        if isempty(self.clutch)
+            self.nest = ()
+            month(model.date) >= 7 ?
+                @setphase(nonbreeding) :
+                @setphase(nestbuilding)
         end
     end
 end
 
 """
-    initskylark(skylark, model)
-
-Initialise a skylark individual. Selects migration dates and checks if the
-bird should currently be on migration. Also sets other individual-specific
-variables. Called at model initialisation and when an egg is laid.
+Initialise a skylark individual. Selects migration dates and checks if the bird
+should currently be on migration. Also sets other individual-specific variables.
 """
-function initskylark(animal::Animal, model::AgentBasedModel)
-    @debug "Added $(animalid(animal)) at $(animal.pos)"
+@phase Skylark.initindividual begin
+    @debug "Added $(animalid(self)) at $(self.pos)"
     # calculate migration dates for this individual
-    animal.migrationdates = migrationdates(animal, model)
-    leave, arrive = animal.migrationdates
+    self.migrationdates = migrationdates(self, model)
+    leave, arrive = self.migrationdates
     m, d = monthday(model.date)
     migrate = (((m < arrive[1]) || (m == arrive[1] && d < arrive[2])) ||
                ((m > leave[1]) || (m == leave[1] && d >= leave[2])))
@@ -234,8 +226,8 @@ function initskylark(animal::Animal, model::AgentBasedModel)
         @migrate(returndate)
     end
     # set individual life-history parameters that are defined as ranges for the species
-    @trait(nestlingtime) = @rand(@trait(nestlingtime)) #FIXME no effect?
-    @trait(fledglingtime) = @rand(@trait(fledglingtime))
+    self.nestlingtime = @rand(self.nestlingtime)
+    self.fledglingtime = @rand(self.fledglingtime)
     #TODO other stuff?
 end
 
@@ -245,7 +237,7 @@ end
 Select the dates on which this skylark will leave for / return from its migration,
 based on observed migration patterns.
 """
-function migrationdates(skylark::Animal, model::AgentBasedModel)
+function migrationdates(skylark::Animal, model::SimulationModel)
     #TODO this ought to be temperature-dependent and dynamic
     #XXX magic numbers!
     minleave = skylark.sex == female ? (9, 15) : (10, 1)
diff --git a/src/nature/species/wolpertinger.jl b/src/nature/species/wolpertinger.jl
index 4f5c14c..0348d58 100644
--- a/src/nature/species/wolpertinger.jl
+++ b/src/nature/species/wolpertinger.jl
@@ -11,29 +11,30 @@ It is purported to have the body of a hare, the wings of a bird, and the antlers
 of a deer.
 """
 @species Wolpertinger begin
-    popsize = Int(round(1/100000*reduce(*, size(model.landscape))))
+    popdensity = 100000
     fecundity = 0.02
     mortality = 0.015
     maxspeed = 5
     crowding = maxspeed*2
 
-    initialise! = initrandompopulation(popsize)
+    @initialise(popdensity=popdensity)
+end
 
-    """
-    Wolpertingers are rather stupid creatures, all they do is move around randomly
-    and occasionally reproduce by spontaneous parthogenesis...
-    """
-    @phase lifephase begin
-        direction = Tuple(@rand([-1,1], 2))
-        for i in 1:@rand(1:@trait(maxspeed))
-            walk!(animal, direction, model; ifempty=false)
-        end
 
-        if @rand() < @trait(fecundity) &&
-            @countanimals(species="Wolpertinger") < @trait(crowding)
-            @reproduce(-1)
-        end
+"""
+Wolpertingers are rather stupid creatures, all they do is move around randomly
+and occasionally reproduce by spontaneous parthogenesis...
+"""
+@phase Wolpertinger.lifephase begin
+    direction = Tuple(@rand([-1,1], 2))
+    for i in 1:@rand(1:self.maxspeed)
+        walk!(animal, direction, model; ifempty=false)
+    end
 
-        @kill @trait(mortality)
+    if @rand() < self.fecundity &&
+        @countanimals(species="Wolpertinger") < self.crowding
+        @reproduce(-1)
     end
+
+    @kill self.mortality
 end
diff --git a/src/nature/species/wyvern.jl b/src/nature/species/wyvern.jl
index 7becb71..9fa3d7e 100644
--- a/src/nature/species/wyvern.jl
+++ b/src/nature/species/wyvern.jl
@@ -18,64 +18,63 @@ legs, but that doesn't make it any less dangerous...
     aggression = 0.2
     huntsuccess = 0.8
 
-    @initialise(@habitat(@landcover() in (grass, soil, agriculture, builtup)), popsize=popsize)
-    phase = "winter"
+    @initialise(winter,
+                habitat=@habitat(@landcover() in (grass, soil, agriculture, builtup)),
+                popsize=popsize)
+end
 
-    """
-    Wyverns are ferocious hunters, scouring the landscape for their favourite
-    prey: wolpertingers...
-    """
-    @phase summer begin
-        for a in @neighbours(@trait(speed))
-            # check if a wolpertinger is in pouncing distance
-            if a.traits["name"] == "Wolpertinger"
-                move_agent!(animal, a.pos, model)
-                if @rand() < @trait(huntsuccess)
-                    @debug "$(animalid(animal)) killed $(animalid(a))."
-                    kill_agent!(a, model)
-                    @goto reproduce
-                end
-            elseif a.traits["name"] == "Wyvern" && @rand() < @trait(aggression)
-                # wyverns also fight against each other if they get too close
-                move_agent!(animal, a.pos, model)
-                outcome = @rand()
-                if outcome < 0.4
-                    @debug "$(animalid(animal)) killed $(animalid(a)) in a fight."
-                    kill_agent!(a, model)
-                elseif outcome < 0.8
-                    @kill 1.0 "wounds sustained in a fight"
-                end
+"""
+Wyverns are ferocious hunters, scouring the landscape for their favourite
+prey: wolpertingers...
+"""
+@phase Wyvern.summer begin
+    for a in @neighbours(self.speed)
+        # check if a wolpertinger is in pouncing distance
+        if typeof(a) == Wolpertinger
+            move_agent!(self, a.pos, model)
+            if @rand() < self.huntsuccess
+                @debug "$(animalid(self)) killed $(animalid(a))."
+                kill!(a, model)
                 @goto reproduce
             end
-        end
-        # check if a wolpertinger is in seeing distance, or walk in a random direction
-        direction = Tuple(@rand([-1,1], 2))
-        for a in @neighbours(@trait(vision))
-            if a.traits["name"] == "Wolpertinger"
-                direction = get_direction(animal.pos, a.pos, model)
-                break
+        elseif typeof(a) == Wyvern && @rand() < self.aggression
+            # wyverns also fight against each other if they get too close
+            move_agent!(self, a.pos, model)
+            outcome = @rand()
+            if outcome < 0.4
+                @debug "$(animalid(self)) killed $(animalid(a)) in a fight."
+                kill_agent!(a, model)
+            elseif outcome < 0.8
+                @kill 1.0 "wounds sustained in a fight"
             end
-        end    
-        for i in 1:@trait(speed)
-            walk!(animal, direction, model; ifempty=false)
+            @goto reproduce
         end
-        # reproduce every once in a blue moon
-        @label reproduce
-        (@rand() < @trait(fecundity)) && @reproduce(-1)
-        # hibernate from November to March
-        if monthday(model.date) == (11,1)
-            @trait(phase) = "winter"
+    end
+    # check if a wolpertinger is in seeing distance, or walk in a random direction
+    direction = Tuple(@rand([-1,1], 2))
+    for a in @neighbours(self.vision)
+        if typeof(a) == Wolpertinger
+            direction = get_direction(self.pos, a.pos, model)
+            break
         end
-        (@trait(age) == maxage) && @kill(1.0, "old age")
+    end    
+    for i in 1:self.speed
+        walk!(animal, direction, model; ifempty=false)
     end
+    # reproduce every once in a blue moon
+    @label reproduce
+    (@rand() < self.fecundity) && @reproduce(-1)
+    # hibernate from November to March
+    month(model.date) >= 11 && (@setphase(winter))
+    (self.age == maxage) && @kill(1.0, "old age")
+end
 
-    """
-    Fortunately, wyverns hibernate in winter.
-    """
-    @phase winter begin
-        # hibernate from November to March
-        if monthday(model.date) == (3,1)
-            @trait(phase) = "summer"
-        end
+"""
+Fortunately, wyverns hibernate in winter.
+"""
+@phase Wyvern.winter begin
+    # hibernate from November to March
+    if month(model.date) >= 3
+        @setphase(summer)
     end
 end
diff --git a/src/parameters.toml b/src/parameters.toml
index e80a55c..c3d9f57 100644
--- a/src/parameters.toml
+++ b/src/parameters.toml
@@ -30,7 +30,7 @@ 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"] # list of target species to simulate
+targetspecies = ["Wolpertinger", "Wyvern"]#["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/src/world/landscape.jl b/src/world/landscape.jl
index 220e51f..50d93ad 100644
--- a/src/world/landscape.jl
+++ b/src/world/landscape.jl
@@ -70,7 +70,7 @@ end
 
 Cycle through the list of events, removing those that have expired.
 """
-function updateevents!(model::AgentBasedModel)
+function updateevents!(model::SimulationModel)
     expiredevents = []
     for e in 1:length(model.events)
         event = model.events[e]
@@ -91,7 +91,7 @@ end
 
 Add a farm event to the specified pixels (a vector of position tuples) for a given duration.
 """
-function createevent!(model::AgentBasedModel, pixels::Vector{Tuple{Int64,Int64}},
+function createevent!(model::SimulationModel, pixels::Vector{Tuple{Int64,Int64}},
                      name::EventType, duration::Int64=1)
     push!(model.events, FarmEvent(name, pixels, duration))
     for p in pixels
@@ -104,7 +104,7 @@ end
 
 Return the land cover class at this position (utility wrapper).
 """
-function landcover(pos::Tuple{Int64,Int64}, model::AgentBasedModel)
+function landcover(pos::Tuple{Int64,Int64}, model::SimulationModel)
     model.landscape[pos...].landcover
 end
 
@@ -113,7 +113,7 @@ end
 
 Return the farm plot at this position, or nothing if there is none (utility wrapper).
 """
-function farmplot(pos::Tuple{Int64,Int64}, model::AgentBasedModel)
+function farmplot(pos::Tuple{Int64,Int64}, model::SimulationModel)
     ismissing(model.landscape[pos...].fieldid) ? nothing :
               model[model.landscape[pos...].fieldid]
 end
@@ -125,7 +125,7 @@ end
 Calculate the distance from the given location to the closest location matching the
 habitat descriptor function. Caution: can be computationally expensive!
 """
-function distanceto(pos::Tuple{Int64,Int64}, model::AgentBasedModel, habitatdescriptor::Function)
+function distanceto(pos::Tuple{Int64,Int64}, model::SimulationModel, habitatdescriptor::Function)
     #XXX allow testing for multiple habitat types?
     (habitatdescriptor(pos, model)) && (return 0)
     dist = 1
@@ -160,7 +160,7 @@ end
 Calculate the distance from the given location to the closest habitat of the specified type.
 Caution: can be computationally expensive!
 """
-function distanceto(pos::Tuple{Int64,Int64}, model::AgentBasedModel, habitattype::LandCover)
+function distanceto(pos::Tuple{Int64,Int64}, model::SimulationModel, habitattype::LandCover)
     # can't use @habitat here because macros.jl is loaded later than this file
     distanceto(pos, model, function(p,m) landcover(p,m) == habitattype end)
 end
@@ -171,7 +171,7 @@ end
 Calculate the distance from the given location to the closest neighbouring habitat.
 Caution: can be computationally expensive!
 """
-function distancetoedge(pos::Tuple{Int64,Int64}, model::AgentBasedModel)
+function distancetoedge(pos::Tuple{Int64,Int64}, model::SimulationModel)
     # can't use @habitat here because macros.jl is loaded later than this file
     distanceto(pos, model, function(p,m) landcover(p,m) != landcover(pos, model) end)
 end
@@ -182,7 +182,7 @@ end
 Find a random pixel within a given `range` of the `position` that matches the
 habitatdescriptor (create this using [`@habitat`](@ref)).
 """
-function randompixel(pos::Tuple{Int64,Int64}, model::AgentBasedModel, range::Int64=1,
+function randompixel(pos::Tuple{Int64,Int64}, model::SimulationModel, range::Int64=1,
                      habitatdescriptor::Function=(pos,model)->nothing)
     for x in @shuffle!(collect((pos[1]-range):(pos[1]+range)))
         for y in @shuffle!(collect((pos[2]-range):(pos[2]+range)))
@@ -208,7 +208,7 @@ end
 
 Make sure that a given position is within the bounds of the model landscape.
 """
-function safebounds(pos::Tuple{Int64,Int64}, model::AgentBasedModel)
+function safebounds(pos::Tuple{Int64,Int64}, model::SimulationModel)
     dims = size(model.landscape)
     x, y = pos
     x <= 0 && (x = 1)
diff --git a/src/world/weather.jl b/src/world/weather.jl
index 694586f..8905303 100644
--- a/src/world/weather.jl
+++ b/src/world/weather.jl
@@ -53,7 +53,7 @@ end
 
 Return today's average windspeed in m/s.
 """
-function windspeed(model::AgentBasedModel)
+function windspeed(model::SimulationModel)
     model.weather[model.date].windspeed
 end
 
@@ -62,7 +62,7 @@ end
 
 Return today's total precipitation in mm.
 """
-function precipitation(model::AgentBasedModel)
+function precipitation(model::SimulationModel)
     model.weather[model.date].precipitation
 end
 
@@ -71,7 +71,7 @@ end
 
 Return today's sunshine duration in hours.
 """
-function sunshine(model::AgentBasedModel)
+function sunshine(model::SimulationModel)
     model.weather[model.date].sunshine
 end
 
@@ -80,7 +80,7 @@ end
 
 Return today's average vapour pressure in hPa.
 """
-function vapourpressure(model::AgentBasedModel)
+function vapourpressure(model::SimulationModel)
     model.weather[model.date].vapourpressure
 end
 
@@ -89,7 +89,7 @@ end
 
 Return today's mean temperature in °C.
 """
-function meantemp(model::AgentBasedModel)
+function meantemp(model::SimulationModel)
     model.weather[model.date].meantemp
 end
 
@@ -98,7 +98,7 @@ end
 
 Return today's maximum temperature in °C.
 """
-function maxtemp(model::AgentBasedModel)
+function maxtemp(model::SimulationModel)
     model.weather[model.date].maxtemp
 end
 
@@ -107,6 +107,6 @@ end
 
 Return today's minimum temperature in °C.
 """
-function mintemp(model::AgentBasedModel)
+function mintemp(model::SimulationModel)
     model.weather[model.date].mintemp
 end
-- 
GitLab