Defining new species

The Persefone species DSL

In order to make implementing new species as easy as possible, Persefone includes a domain-specific language (DSL) built from a collection of macros and functions.

Here is an example of what this looks like, using a hypothetical mermaid species:

@species Mermaid begin
    ageofmaturity = 2
    pesticidemortality = 1.0
    @initialise(@habitat(@landcover() == water), pairs=true)
	
    @phase life begin
        @debug "$(animalid(animal)) is swimming happily in its pond."
        @respond pesticide @kill(@trait(pesticidemortality), "poisoning")
        @respond harvest @setphase(drought)
        @debug "Animal: $animal"
        if @trait(sex) == female && @countanimals() < 3 &&
            @trait(age) >= @trait(ageofmaturity) && @landcover() == water
            @reproduce()
        end
    end
	
    @phase drought begin
        n = sum(1 for a in @neighbours(0))
        @debug "$(animalid(animal)) is experiencing drought with $n neighbour(s)."
        @respond sowing @setphase(life)
    end
end

The two most important macros are @species and @phase, followed by @initialise, @trait, @respond, and @habitat. Other macros provide convenience wrappers for common functions. (See src/nature/nature.jl for details.)

The top-level macro is @species. This takes two arguments: a species name and a definition block (enclosed in begin and end tags). At the start of the definition block, species-specific variables can be defined that should be available throughout a species' lifetime. Code in this section has access to the model object and can thus reference the current model state. In this section, the user also has to call the @initialise macro. This wraps the Persefone.initpopulation function, and takes a habitat descriptor (see @habitat below) and several options to specify how the species' population should be distributed in the landscape during model initialisation.

Following this section, each species must define one or more @phase blocks. The concept behind this is that species show different behaviours at different phases of their lifecycle. Each @phase block defines the behaviour in one of these phases. (Technically, it defines a function that will be called daily, so long as the species' phase variable is set to the name of this phase.) Code in this section has access to the model object as well as an animal object, which is the currently active animal agent. Properties of the animal agent, regardless of whether they were defined by the user or by Persefone, can be accessed using the @trait macro. Within a phase block, @respond can be used to define the species' response to a FarmEvent that affects the species' current location, while a variety of other macros provide wrappers to ecological process functions from src/nature/populations.jl.

Another important macro is @habitat. This defines a "habitat descriptor", i.e. a predicate function that tests whether or not a given landscape pixel is suitable for a specified purpose. Such habitat descriptors are used as arguments to various functions, for example for population initialisation or movement. The argument to @habitat consists of a logical expression, which has access to the animal's current position (the pos tuple variable) and the model. Various macros are available to easily reference information about the current location, such as @landcover or @distancetoedge.

Implementation details

Due to a known performance problem with multi-agent models, the underlying implementation of species is rather complicated (see src/nature/nature.jl for details.)

Rather than creating a new type/struct for each species, all Animal agents have the same type. Instead, they are differentiated by a traits dict, which stores both species-specific parameters and run-time variables. Note that due to a redefinition of the getproperty()/setproperty!() methods, variables from the trait dict can be accessed and modified just like normal struct fields (i.e. although phase is defined in the dict, not the struct, animal.phase = "newphase" works just fine - one does not have to use animal.traits["phase"] = "newphase".)

Under the hood, the @species macro generates a function (with the name of the species), which in turn creates the trait dict when called. Thus, adding a new animal agent to the model involves instantiating an Animal object, then calling the relevant species function and attaching the returned dict to the agent object.

Similarly, the @phase macro too works by defining a new function, which is stored in the species' trait dict. These functions take an animal object and the model object as input, and define what the species does during its daily update.

Once again, @habitat creates a function that takes model and pos as input and returns a boolean response. Functions that require a habitat descriptor thus take in this (anonymous) function and call it internally.

Finally, the @initialise macro is a wrapper around Persefone.initpopulation, which (yet again) creates a function that specifies how a species' population is to be initialised at the beginning of a simulation run. This function is stored in the species trait dict and accessed during model setup.