Carrying capacity of a meadow
Cows that graze a summer of 25 weeks on a meadow, which shows how many cows the meadow can carry.
- Name:
meadow_capacity - Field:
biology/ecology - Formalism: agent-based model
- References: Hamill and Gilbert 2016
Factory
@register_model(
field='biology/ecology',
tags=('carrying capacity', 'agents', 'grid', 'grazing', 'commons'),
references=('Hamill and Gilbert 2016',),
defaults={'until': 25},
)
def meadow_capacity(
width: int = 40,
height: int = 40,
*,
cows: int = 80,
growth: float = 0.2,
bite: float = 0.75,
threshold: float = 0.9,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return cows that graze a summer of 25 weeks on a meadow, which shows how many cows the meadow can carry.
Every week the grass regrows logistically, each cow eats `bite` of the grass of its patch, and each cow moves to the
nearest patch that no other cow occupies and whose grass reaches `threshold`. A cow that finds none dies. Without
regrowth a cow grazes its first patch and 25 more over the summer, so the meadow feeds about
`width * height / 26` cows, and regrowth raises its carrying capacity.
The cows graze the patches down at the same pace, so a herd above the capacity runs out of grown patches all at
once and dies out within a week, instead of shrinking to the capacity.
Args:
width:
The number of columns of the meadow.
height:
The number of rows of the meadow.
cows:
The initial number of cows.
growth:
The weekly growth rate of the grass.
bite:
The grass a cow eats in a week.
threshold:
The least grass a patch needs for a cow to move there.
random_state:
The seed or generator of the initial cells of the cows.
Returns:
system:
The model, with the groups `pasture` and `cows`.
Examples:
>>> from skcomplex.models.biology.ecology import meadow_capacity
>>> [group.size for group in meadow_capacity(10, 10, cows=5).elements]
[100, 5]
"""
rng = np.random.default_rng(random_state)
cells = [(column, row) for column in range(width) for row in range(height)]
picks = rng.choice(len(cells), size=cows, replace=False)
return System(
elements=[
GroupElement(
'pasture',
element=PastureElement,
size=len(cells),
values={'growth': growth},
variable=True,
location=cells,
),
GroupElement(
'cows',
element=CowElement,
size=cows,
values={'bite': bite},
variable=True,
location=[cells[int(pick)] for pick in picks],
),
],
interactions=[
GrazingInteraction('grazing', cows='cows', pasture='pasture'),
HerdingInteraction('herding', cows='cows', pasture='pasture', threshold=threshold),
],
space=LatticeSpace((width, height), boundary='fixed'),
)
Components
class PastureElement(Element):
"""Patch of grass that regrows logistically every week, `G' = G + g G (1 - G)`.
Args:
label:
The label of the agent or group.
visual:
How the element is drawn, or `None` for `default_visual_`.
grass:
The amount of grass, between 0 and 1.
growth:
The weekly growth rate `g` of the grass.
Examples:
>>> from skcomplex.core.elements import GroupElement
>>> from skcomplex.models.biology.ecology import PastureElement
>>> GroupElement('pasture', element=PastureElement, size=4, values={'growth': 0.1}).size
4
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'grass', 'color_range': (0.0, 1.0)}),
*,
grass: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
growth: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.2,
) -> None:
super().__init__(label, visual=visual)
self.grass = grass
self.growth = growth
@classmethod
def _get_view(cls) -> type[PastureView]:
"""Return the view of a patch of pasture, `PastureView`."""
return PastureView
def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
"""Return the regrowth rule.
Args:
s:
The namespace of the symbols the laws can use.
Returns:
laws:
The laws of the component.
"""
return [RuleLaw(self.regrow)]
def regrow(self, agent: PastureView, model: ModelView) -> None:
"""Grow the grass by one week of logistic growth.
Args:
agent:
The patch.
model:
The model.
"""
del model
agent.grass += agent.growth * agent.grass * (1 - agent.grass)
class CowElement(Element):
"""Cow of the meadow, which the grazing and herding interactions feed and move every week.
Args:
label:
The label of the agent or group.
visual:
How the element is drawn, or `None` for `default_visual_`.
bite:
The grass a cow eats in a week.
Examples:
>>> from skcomplex.core.elements import GroupElement
>>> from skcomplex.models.biology.ecology import CowElement
>>> GroupElement('cows', element=CowElement, size=10).size
10
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = VisualSpec('agents', {'radius': 0.4}),
*,
bite: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.75,
) -> None:
super().__init__(label, visual=visual)
self.bite = bite
@classmethod
def _get_view(cls) -> type[CowView]:
"""Return the view of a cow, `CowView`."""
return CowView
class GrazingInteraction(TransferInteraction):
"""Weekly meal of every cow, which eats its bite of the grass of the patch in its cell.
It is a transfer of the grass of every patch, the bite of the cow in its cell or what grass is left, to the
environment.
Args:
label:
The label of the interaction.
cows:
The path of the group of cows.
pasture:
The path of the group of patches.
Examples:
>>> from skcomplex.models.biology.ecology import GrazingInteraction
>>> GrazingInteraction('grazing', cows='cows', pasture='pasture').pasture
'pasture'
"""
def __init__(
self,
label: str,
*,
cows: Annotated[str, RoleSpec()],
pasture: Annotated[str, RoleSpec()],
) -> None:
super().__init__(
label,
source=pasture,
target=cows,
state='grass',
into=Sink(),
amount=self._get_bite,
pairing='cell',
form='agent',
)
self.cows = cows
self.pasture = pasture
def _get_role(self, role: str) -> str:
"""Return the patches as the source of the grass and the cows as its target."""
return {'source': 'pasture', 'target': 'cows'}.get(role, role)
@staticmethod
def _get_bite(patch: AgentViewProtocol, cow: AgentViewProtocol, model: ModelViewProtocol) -> float:
"""Return the grass a cow eats from the patch of its cell, its bite or what grass is left."""
del model
return min(cast(PastureView, patch).grass, cast(CowView, cow).bite)
class HerdingInteraction(MovementInteraction):
"""Weekly move of every cow, in random order, to the nearest free patch with enough grass, or death.
It is a movement of the cows whose step leads every cow to the patch nearest to it that no other cow occupies and
whose grass reaches `threshold`, and removes a cow that finds none. Every cow moves every week, the probability
of movement fixed to 1 without a parameter.
Args:
label:
The label of the interaction.
cows:
The path of the group of cows.
pasture:
The path of the group of patches.
threshold:
The least grass a patch needs for a cow to move there.
Examples:
>>> from skcomplex.models.biology.ecology import HerdingInteraction
>>> HerdingInteraction('herding', cows='cows', pasture='pasture').threshold
0.9
"""
def __init__(
self,
label: str,
*,
cows: Annotated[str, RoleSpec()],
pasture: Annotated[str, RoleSpec()],
threshold: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.9,
) -> None:
super().__init__(label, target=cows, step=self._herd)
self.cows = cows
self.pasture = pasture
self.threshold = threshold
def _get_role(self, role: str) -> str:
"""Return the cows as the target of the movement."""
return 'cows' if role == 'target' else role
@staticmethod
def _cell(agent: AgentViewProtocol) -> tuple[int, ...]:
"""Return the cell of an agent as a tuple of integers."""
location = agent.location
if not isinstance(location, tuple):
error_msg = (
f'The agent is located at a {type(location).__name__}, not at the coordinates of a lattice cell.'
)
raise TypeError(error_msg)
return tuple(int(item) for item in location)
def _herd(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> tuple[int, ...] | None:
"""Return the cell of the nearest free patch with enough grass, or remove the cow that finds none."""
patches = [cast(PastureView, patch) for patch in model.agents('pasture')]
cells = [self._cell(patch) for patch in patches]
index = {cell: number for number, cell in enumerate(cells)}
occupied = np.zeros(len(cells), dtype=bool)
occupied[[index[self._cell(cow)] for cow in model.agents('cows') if cow.alive and cow.id != agent.id]] = True
free = (np.asarray([patch.grass for patch in patches]) >= model.parameter('threshold')) & ~occupied
if not free.any():
agent.remove()
return None
distances = np.hypot(*(np.asarray(cells, dtype=float) - np.asarray(self._cell(agent), dtype=float)).T)
return cells[int(np.argmin(np.where(free, distances, np.inf)))]