Skip to content

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)))]