diff --git a/docs/src/index.md b/docs/src/index.md index 2c1f722ed4f9a1d056b0e6a89854dd5ecb400dea..19f8c7572ba30d5f52b30730205f17b8b2c741a1 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -40,4 +40,4 @@ optional arguments: *TODO: describe config & map files* -*Last updated: 2023-02-02 (commit 577ff86)* +*Last updated: 2023-02-02 (commit 6bcd56b)* diff --git a/docs/src/species-dsl.md b/docs/src/species-dsl.md index bedb5d8551a630a0aa418ccc7d3b70e4fe5ef5a2..6efd1d0e8ca10639432a4147c1816fd9a2ba9aa9 100644 --- a/docs/src/species-dsl.md +++ b/docs/src/species-dsl.md @@ -1,10 +1,104 @@ # Defining new species -*TODO* - ## The Persephone species DSL +In order to make implementing new species as easy as possible, Persephone includes +a domain-specific language (DSL) built from a collection of macros and functions. + +Here is an example (using a hypothetical mermaid species) of what this looks like: + +```julia +@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 `@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 `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 Persephone, 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](https://juliadynamics.github.io/Agents.jl/stable/performance_tips/#Avoid-Unions-of-many-different-agent-types-(temporary!)-1) +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 `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.