The Ecological Model and Components

The package EcologicalNetworksDynamics represents an ecological network as a julia value of type Model.

m = default_model(Foodweb([:a => :b, :b => :c]))
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 17 components:
  - Species: 3 (:a, :b, :c)
  - Foodweb: 2 links, 1 producer, 2 consumers, 2 preys, 1 top.
  - BodyMass: [100.0, 10.0, 1.0]
  - Metabolic classes: [:invertebrate, :invertebrate, :producer]
  - Growth rate: [·, ·, 1.0]
  - Carrying capacity: [·, ·, 1.0]
  - Producers competition: 1.0
  - <LogisticGrowth>
  - Efficiency: 0.45 to 0.85
  - Maximum consumption: [8.0, 8.0, ·]
  - Hill Exponent: 2.0
  - Consumer preferences: 1.0
  - Intra-specific interference: [·, ·, ·]
  - Half-saturation density: [0.5, 0.5, ·]
  - <BioenergeticResponse>
  - Metabolism: [0.09929551852928711, 0.1765751761097696, 0.0]
  - Mortality: [0.0, 0.0, 0.0]

Values of this type essentially describe a graph, with various nodes compartments representing e.g. species or nutrients and various edges compartments representing e.g. trophic links, or facilitation links. In addition to the network topology, the model also holds data describing the model further, and brought by the various models components. There are three possible "levels" for this data:

  • Graph-level data describe properties of the whole system. e.g. temperature, hill-exponent etc. These are typically scalar values.
  • Node-level data describe properties of particular nodes in the graph: e.g. species body mass, nutrients turnover etc. These are typically vector values.
  • Edge-level data describe properties of particular links: e.g. trophic links efficiency, half-saturation of a producer-to-nutrient links etc. These are typically matrix values.

Model Properties

The data held by the model can be accessed via the various model properties, accessed with julia's m.<P> property accessor:

m.hill_exponent # Graph-level data (a number).
m.body_mass     # Node-level data (a vector with one value per species).
m.efficiency    # Edge-level data (a matrix with one value per species interaction).

Properties are grouped into property spaces:

m.species
Property space for '<internals>': .species

You can navigate the property spaces with successive . accesses:

m.species.number
3
m.trophic.matrix
3×3 EcologicalNetworksDynamics.TrophicMatrix:
 0  1  0
 0  0  1
 0  0  0

Some data can be modified this way with m.<P> = <value>. But not all:

# Okay: this is terminal data.
m.hill_exponent = 2.1

# Not okay: this could make the rest of the model data inconsistent.
m.species.richness = 4
ERROR: In property `species.richness` of '<internals>': This property is read-only.

If you need a model with different values for read-only data, you need to build a new model with the values you desire.

m = default_model(Foodweb([:a => :b, :b => [:c, :d]])) # Re-construct with a 4th species.
m.species.richness # Now the value is what you want.
4

The full list of available model properties can be queried with:

properties(m)

Some properties are just convenience aliases for each other.

m.A == m.trophic.matrix

Model Components

The Model value is very flexible and can represent a variety of different networks. It is made from the combination of various components.

Empty Model and the add! Method

When you start from a default_model, you typically obtain a full-fledged value, with all the components required to simulate the dynamics. Alternately, you can start from an empty model:

m = Model()
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 0 component.

In this situation, you need to add the components one by one. But this gives you full control over the model content.

An empty model cannot be simulated, because the data required for simulation is missing from it.

simulate(m, 0.5, 100)
ERROR: In method '_simulate' for '<internals>': Requires a component <FunctionalResponse>.

Also, an empty model cannot be queried for data, because there is no data inside:

m.richness
ERROR: In property `.richness` of '<internals>': Component <Species> is required to read this property.

The most basic way to add a Species component to your model is to use the add! function:

add!(m, Species(3))
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 1 component:
  - Species: 3 (:s1, :s2, :s3)

Now that the Species component has been added, the related properties can be queried from the model:

m.species.richness
3
m.species.names
3-element EcologicalNetworksDynamics.SpeciesNames:
 :s1
 :s2
 :s3

But the other properties cannot be queried, because the associated components are still missing:

m.trophic.matrix
ERROR: In property `trophic.matrix` of '<internals>': Component <Foodweb> is required to read this property.

Before we add the missing Foodweb component, let us explain that the component addition we did above actually happened in two stages.

Blueprints Expand into Components

To add a component to a model, we first need to create a blueprint for the component. A blueprint is a julia value containing all the data needed to expand into a component.

sp = Species([:hen, :fox, :snake]) # This is a blueprint, useful to later expand into a model component.
blueprint for <Species>: Names {
  names: [:hen, :fox, :snake],
}

When you call the add! function, you feed it with a model and a blueprint. The blueprint is read and expanded within the given model:

m = Model() # Empty model.
add!(m, sp) # Expand blueprint `sp` into a `Species` component within `m`.
m           # The result is a model with 1 component inside.
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 1 component:
  - Species: 3 (:hen, :fox, :snake)

As we have seen before: once it has been expanded into the model, you cannot always edit the component data directly. For instance, the following does not work:

m.species.names[2] = "rhino"
ERROR: View error (EcologicalNetworksDynamics.SpeciesNames): This view into graph nodes data is read-only.

However, you can always edit the blueprint, then re-expand it later into other models.

sp.names[2] = :rhino    # Edit one species name within the blueprint.
push!(sp.names, :ficus) # Append a new species to the blueprint.
m2 = Model(sp)          # Create a new model from the modified blueprint.
m2                      # This new model contains the alternate data.
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 1 component:
  - Species: 4 (:hen, :rhino, :snake, :ficus)

Blueprints can get sophisticated. For instance, here are various ways to create blueprints for a Foodweb component.

fw = Foodweb(:niche, S = 5, C = 0.2)         # From a random model.
fw = Foodweb([0 1 0; 1 0 1; 0 0 0])          # From an adjacency matrix.
fw = Foodweb([:fox => :hen, :hen => :snake]) # From an adjacency list.

So, although the value Foodweb represents a component, you can call it to produce blueprints expanding into this component. In Julia linguo: Foodweb is a singleton functor value that forwards its calls to actual blueprint constructors.

Instead of calling the Foodweb component as a functor, you can be more explicit by calling the blueprint constructors directly:

fw = Foodweb.Matrix([0 1 0; 1 0 1; 0 0 0])
fw = Foodweb.Adjacency([:fox => :hen, :hen => :snake])

Here, Foodweb.Matrix and Foodweb.Adjacency are two different types of blueprints providing the component Foodweb. The call to Fooweb(:niche, ...) yields a random Foodweb.Matrix blueprint.

If you want to test the component, but you don't want to loose the original model, you can keep a safe copy of it before you actually expand the blueprint:

base = copy(m) # Keep a safe, basic, incomplete version of the model.
add!(m, fw)    # Expand the foodweb into a new component within `m`: `base` remains unchanged.

A shorter way to do so is to directly use julia's + operator, which always leaves the original model unchanged and creates an augmented copy of it:

m = base + fw # Create a new model `m` with a Foodweb inside, leaving model `base` unchanged.
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 2 components:
  - Species: 3 (:hen, :fox, :snake)
  - Foodweb: 2 links, 1 producer, 2 consumers, 2 preys, 1 top.

Separating blueprints creation from final components expansion gives you flexibility when creating your models. Blueprints can either be thrown after use, or kept around to be modified and reused without limits.

Model Constraints

Of course, you cannot expand blueprints into components that would yield inconsistent models:

base = Model(Species(3)) # A model a with 3-species compartment.
m = base + Foodweb([0 1; 0 0]) # An adjacency matrix with only 2×2 values.
ERROR: Blueprint cannot expand against current system value:
  Invalid size for parameter 'A': expected (3, 3), got (2, 2).
Not all blueprints have been expanded.
This means that the system consistency is still guaranteed, but some components have not been added.
in Foodweb_.Matrix

Components cannot be removed from a model, because it could lead to inconsistent model values. Components cannot either be duplicated or replaced within a model:

m = Model(Foodweb(:niche, S = 5, C = 0.2))
m += Foodweb([:a => :b]) # Nope: already added.
ERROR: Blueprint would expand into component <Foodweb>, which is already in the system.
in Foodweb_.Adjacency

In other terms: models can only be build monotonically. If you ever feel like you need to "change a component" or "remove a component" from a model, the correct way to do so is to construct a new model from the blueprints and/or the other base models you have kept around.

Components also require each other: you cannot specify trophic links efficiency in your model without having first specified what trophic links are:

m = Model(Species(3))
m += Efficiency(4)
ERROR: Component <Efficiency> requires <Foodweb>, neither found in the system nor brought by the blueprints.
in Efficiency_.Flat

Bringing Blueprints

To help you not hit the above problem too often, some blueprints take advantage of the fact that they contain the information needed to also expand into some of the components they require. Conceptually, they carry the information needed to bring smaller blueprints within them.

Imply

For instance, the following blueprint for a foodweb contains enough information to expand into both a Foodweb component, and the associated Species component if needed:

fw = Foodweb([1 => 2, 2 => 3]) # Species nodes can be inferred from this blueprint..
m = Model(fw) # .. so a blank model given only this blueprint becomes equiped with the 2 components.
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 2 components:
  - Species: 3 (:s1, :s2, :s3)
  - Foodweb: 2 links, 1 producer, 2 consumers, 2 preys, 1 top.

As a consequence, it is not an error to expand the Foodweb.Adjacency blueprint into a model not already having a Species compartment. We say that the Foodweb.Adjacency blueprint implies a blueprint for component Species.

If you need more species in your model than appear in your foodweb blueprint, you can still explicitly provide a larger Species blueprint before you add the foodweb:

m = Model(Species(5), Foodweb([1 => 2, 2 => 3])) # A model with 2 isolated species.
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 2 components:
  - Species: 5 (:s1, :s2, :s3, :s4, :s5)
  - Foodweb: 2 links, 3 producers, 2 consumers, 2 preys, 3 tops.

In other words, implied blueprints are only expanded if needed.

Embed

Some blueprints, on the other hand, explicitly embed other blueprints. For instance, the LinearResponse embeds both ConsumptionRate and ConsumersPreference "sub-blueprints":

lin = LinearResponse()
blueprint for <LinearResponse>: LinearResponse_ {
  alpha: <embedded blueprint for <ConsumptionRate>: Flat {
    alpha: 1.0,
  }>,
  w: <embedded blueprint for <ConsumersPreferences>: Homogeneous {}>,
}

So a model given this single blueprint can expand with 3 additional components.

m += lin
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 5 components:
  - Species: 5 (:s1, :s2, :s3, :s4, :s5)
  - Foodweb: 2 links, 3 producers, 2 consumers, 2 preys, 3 tops.
  - Consumption rate: [1.0, 1.0, ·, ·, ·]
  - Consumer preferences: 1.0
  - <LinearResponse>

The difference between embedding and implying is that the embedded sub-blueprints are always expanded. The direct consequence is that they do conflict with existing components:

m = Model(fw, ConsumptionRate(2)) # This model already has a consumption rate.
m += lin # So it is an error to bring another consumption rate with this blueprint.
ERROR: Blueprint would expand into component <ConsumptionRate>, which is already in the system.
in ConsumptionRate_.Flat
embedded within: LinearResponse_

This protects you from obtaining a model value with ambiguous consumption rates.

To prevent the ConsumptionRate from being brought, you need to explicitly remove it from the blueprint embedding it:

lin.alpha = nothing # Remove the brought sub-blueprint.
lin = LinearResponse(alpha = nothing) # Or create directly without the embedded blueprint.
m += lin # Consistent model obtained.
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 5 components:
  - Species: 3 (:s1, :s2, :s3)
  - Foodweb: 2 links, 1 producer, 2 consumers, 2 preys, 1 top.
  - Consumption rate: [2.0, 2.0, ·]
  - Consumer preferences: 1.0
  - <LinearResponse>

Using the Default Model

Building a model from scratch can be tedious, because numerous components are required for the eventual simulation to take place.

Here is how you could do it with only temporary blueprints immediately dismissed:

m = Model(
  Foodweb([:a => :b, :b => :c]),
  BodyMass(1),
  MetabolicClass(:all_invertebrates),
  BioenergeticResponse(),
  LogisticGrowth(),
  Metabolism(:Miele2019),
  Mortality(0),
)

Here is how you could do it with blueprints that you would keep around to later reassemble into other models:

# Basic blueprints saved into variables for later edition.
fw = Foodweb([:a => :b, :b => :c])
bm = BodyMass(1)
mc = MetabolicClass(:all_invertebrates)
be = BioenergeticResponse()
lg = LogisticGrowth()
mb = Metabolism(:Miele2019)
mt = Mortality(0)

# One model with all the associated components.
m = Model() + fw + bm + mc + be + lg + mb + mt

If this is too tedious, you can use the default_model function instead to automatically create a model with all (or most) components required for simulation. The only mandatory argument to default_model is a Foodweb blueprint:

fw = Foodweb([:a => :b, :b => :c])
m = default_model(fw)
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 17 components:
  - Species: 3 (:a, :b, :c)
  - Foodweb: 2 links, 1 producer, 2 consumers, 2 preys, 1 top.
  - BodyMass: [100.0, 10.0, 1.0]
  - Metabolic classes: [:invertebrate, :invertebrate, :producer]
  - Growth rate: [·, ·, 1.0]
  - Carrying capacity: [·, ·, 1.0]
  - Producers competition: 1.0
  - <LogisticGrowth>
  - Efficiency: 0.45 to 0.85
  - Maximum consumption: [8.0, 8.0, ·]
  - Hill Exponent: 2.0
  - Consumer preferences: 1.0
  - Intra-specific interference: [·, ·, ·]
  - Half-saturation density: [0.5, 0.5, ·]
  - <BioenergeticResponse>
  - Metabolism: [0.09929551852928711, 0.1765751761097696, 0.0]
  - Mortality: [0.0, 0.0, 0.0]

But you can feed other blueprints into it to fine-tweak just the parameters you want.

m = default_model(fw, BodyMass(Z = 1.5), Efficiency(.2))
(m.body_mass, m.efficiency)
([2.25, 1.5, 1.0], [0.0 0.2 0.0; 0.0 0.0 0.2; 0.0 0.0 0.0])

The function default_model tries hard to figure the default model you expect based on the only few blueprints you input. For instance, it assumes that you need a different type of functional response if you input a Temperature component, and temperature-dependent allometry rates:

m = default_model(fw, Temperature(220))
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 18 components:
  - Species: 3 (:a, :b, :c)
  - Foodweb: 2 links, 1 producer, 2 consumers, 2 preys, 1 top.
  - Temperature: 220.0K
  - BodyMass: [100.0, 10.0, 1.0]
  - Metabolic classes: [:invertebrate, :invertebrate, :producer]
  - Growth rate: [·, ·, 2.4457111255767084e-12]
  - Carrying capacity: [·, ·, 34342.667662374224]
  - Producers competition: 1.0
  - <LogisticGrowth>
  - Efficiency: 0.45 to 0.85
  - Hill Exponent: 2.0
  - Consumer preferences: 1.0
  - Intra-specific interference: [·, ·, ·]
  - Handling time: 170440.9238100917 to 178473.55707771532
  - Attack rate: 6.894034926202549e-9 to 2.4460958976605684e-8
  - <ClassicResponse>
  - Metabolism: [1.7880778943692315e-12, 3.650786484958455e-12, 0.0]
  - Mortality: [0.0, 0.0, 0.0]

Or if you wish to explicitly represent Nutrients as a separate nodes compartment in your ecological network:

m = default_model(fw, Nutrients.Nodes(2))
Model (alias for EcologicalNetworksDynamics.Framework.System{<internals>}) with 20 components:
  - Species: 3 (:a, :b, :c)
  - Foodweb: 2 links, 1 producer, 2 consumers, 2 preys, 1 top.
  - Nutrients: 2 (:n1, :n2)
  - BodyMass: [100.0, 10.0, 1.0]
  - Metabolic classes: [:invertebrate, :invertebrate, :producer]
  - Growth rate: [·, ·, 1.0]
  - Nutrients turnover: [0.25, 0.25]
  - Nutrients supply: [4.0, 4.0]
  - Nutrients concentration: 0.5
  - Nutrients half-saturation: 0.15
  - <NutrientIntake>
  - Efficiency: 0.45 to 0.85
  - Maximum consumption: [8.0, 8.0, ·]
  - Hill Exponent: 2.0
  - Consumer preferences: 1.0
  - Intra-specific interference: [·, ·, ·]
  - Half-saturation density: [0.5, 0.5, ·]
  - <BioenergeticResponse>
  - Metabolism: [0.09929551852928711, 0.1765751761097696, 0.0]
  - Mortality: [0.0, 0.0, 0.0]

/!\ Find fresh example snippets /!\

This page of the manual is up-to-date with the latest version of the package, but not the other pages yet. However, you will already (and always) find up-to-date examples of manipulating models/blueprints/components under the test/user folder. Have a look in there to get familiar with all the package features ;)

If you were accustomed to a former version of the package, also take a look at our CHANGELOG.md.