Skip to content

Boids in two dimensions

Reynolds' boids in two dimensions, birds that flock by local rules without a leader.

  • Name: boids
  • Field: biology/collective_behaviour
  • Formalism: agent-based model
  • References: Reynolds 1987; Downey 2018

Factory

@register_model(
    field='biology/collective_behaviour',
    tags=('agents', 'flocking', 'self-organization', 'continuous space'),
    references=('Reynolds 1987', 'Downey 2018'),
    defaults={'until': 100, 'dt': 1},
)
def boids(
    n_boids: int = 40,
    *,
    size: float = 30.0,
    radius: float = 5.0,
    cohesion: float = 0.01,
    separation: float = 0.1,
    alignment: float = 0.1,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return Reynolds' boids in two dimensions, birds that flock by local rules without a leader.

    The boids start at random positions with random headings on a torus. Alignment makes neighbours fly in the same
    direction and cohesion pulls them together, so the flock becomes polarized while separation keeps the birds apart.

    Args:
        n_boids:
            The number of boids.

        size:
            The side of the square torus.

        radius:
            The distance within which a boid sees others.

        cohesion:
            The weight of cohesion.

        separation:
            The weight of separation.

        alignment:
            The weight of alignment.

        random_state:
            The seed or generator of the initial positions and headings.

    Returns:
        system:
            The model, with the group `flock` and the velocities `vx` and `vy` of its boids.

    Examples:
        >>> from skcomplex.models.biology.collective_behaviour import boids
        >>> boids(10).elements[0].check().prepare().size_
        10
    """
    rng = np.random.default_rng(random_state)
    headings = rng.uniform(0.0, 2 * math.pi, n_boids)
    flock = GroupElement(
        'flock',
        element=BoidElement,
        size=n_boids,
        values={
            'vx': np.cos(headings),
            'vy': np.sin(headings),
            'radius': radius,
            'cohesion': cohesion,
            'separation': separation,
            'alignment': alignment,
            'width': size,
            'height': size,
        },
        location=[tuple(point) for point in rng.uniform(0.0, size, (n_boids, 2))],
    )
    interactions = [FlockingInteraction('flocking', target='flock'), FlightInteraction('flight', target='flock')]
    return System(
        elements=[flock],
        interactions=interactions,
        space=ContinuousSpace(extent=(size, size), boundary='periodic'),
    )

Components

class BoidElement(Element):
    """Boid of Reynolds, a bird that steers by three rules toward, away from, and along its neighbours.

    Every step a boid sees the boids within `radius`. `FlockingInteraction` steers it by cohesion toward their center,
    by separation away from those closer than `separation_radius`, and by alignment toward their mean velocity, with
    its speed between `min_speed` and `max_speed`, and `FlightInteraction` moves it on a torus of side `width` by
    `height`.

    Args:
        label:
            The label of the agent or group.

        visual:
            How the element is drawn, or `None` for `default_visual_`.

        vx:
            The initial horizontal velocity.

        vy:
            The initial vertical velocity.

        radius:
            The distance within which a boid sees others.

        separation_radius:
            The distance within which a boid avoids others.

        cohesion:
            The weight of cohesion.

        separation:
            The weight of separation.

        alignment:
            The weight of alignment.

        min_speed:
            The smallest speed.

        max_speed:
            The largest speed.

        width:
            The width of the torus.

        height:
            The height of the torus.

    Examples:
        >>> from skcomplex.core.elements import GroupElement
        >>> from skcomplex.models.biology.collective_behaviour import BoidElement
        >>> GroupElement('flock', element=BoidElement, size=10, location=(5.0, 5.0)).size
        10
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'vx'}),
        *,
        vx: Annotated[float, StateSpec(role='velocity')] = 1.0,
        vy: Annotated[float, StateSpec(role='velocity')] = 0.0,
        radius: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 5.0,
        separation_radius: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
        cohesion: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.01,
        separation: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
        alignment: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
        min_speed: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.5,
        max_speed: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
        width: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 30.0,
        height: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 30.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.vx = vx
        self.vy = vy
        self.radius = radius
        self.separation_radius = separation_radius
        self.cohesion = cohesion
        self.separation = separation
        self.alignment = alignment
        self.min_speed = min_speed
        self.max_speed = max_speed
        self.width = width
        self.height = height

    @classmethod
    def _get_view(cls) -> type[BoidView]:
        """Return the view of a boid, `BoidView`."""
        return BoidView

class FlockingInteraction(CouplingInteraction):
    """Reynolds' steering of every boid toward, away from, and along the boids it sees, with a bounded speed.

    The velocity of a boid gains the mean offset to the boids within `radius` weighted by `cohesion`, loses the summed
    offset to the boids within `separation_radius` weighted by `separation`, and gains the mean difference of their
    velocities from its own weighted by `alignment`. Its speed is then clipped between `min_speed` and `max_speed`.
    Offsets are the shortest ones on the torus of side `width` by `height`. It is the agent form of coupling over two
    radii of one priority, with a clamp of the norm.

    Args:
        label:
            The label of the interaction, unique among the parts of its system.

        target:
            The path of the group of boids.

        phase:
            The phase of the step the steering runs in, or `None` for the default phase.

    Examples:
        >>> from skcomplex.models.biology.collective_behaviour import FlockingInteraction
        >>> [spec.reduce for spec in FlockingInteraction('flocking', target='flock').radii]
        ['mean', 'sum', 'mean']
    """

    def __init__(self, label: str, *, target: Annotated[str, RoleSpec()], phase: str | None = None) -> None:
        super().__init__(
            label,
            target=target,
            write=('vx', 'vy'),
            form='agent',
            radii=(
                RadiusSpec('radius', self._get_cohesion, reduce='mean'),
                RadiusSpec('separation_radius', self._get_separation),
                RadiusSpec('radius', self._get_alignment, reduce='mean'),
            ),
            clamp=ClampSpec(min_norm='min_speed', max_norm='max_speed'),
            phase=phase,
        )

    @staticmethod
    def _get_offset(agent: BoidView, other: BoidView) -> Array:
        """Return the shortest offset from a boid to another on the torus."""
        size = np.array([agent.width, agent.height])
        offset = np.asarray(other.location, dtype=float) - np.asarray(agent.location, dtype=float)
        return (offset + size / 2) % size - size / 2

    def _get_cohesion(self, agent: BoidView, other: BoidView, model: ModelView) -> Array:
        """Return the pull of a boid toward another."""
        return agent.cohesion * self._get_offset(agent, other)

    def _get_separation(self, agent: BoidView, other: BoidView, model: ModelView) -> Array:
        """Return the push of a boid away from a close one."""
        return -agent.separation * self._get_offset(agent, other)

    @staticmethod
    def _get_alignment(agent: BoidView, other: BoidView, model: ModelView) -> Array:
        """Return the turn of a boid toward the velocity of another."""
        return agent.alignment * np.array([other.vx - agent.vx, other.vy - agent.vy])

class FlightInteraction(MovementInteraction):
    """Flight of every boid by its velocity `(vx, vy)` in one step, on the torus of side `width` by `height`.

    Every boid moves in its own activation, after the rules and member interactions listed before it.

    Args:
        label:
            The label of the interaction, unique among the parts of its system.

        target:
            The path of the group of boids.

        probability:
            The probability that a boid moves in a step.

    Examples:
        >>> from skcomplex.models.biology.collective_behaviour import FlightInteraction
        >>> FlightInteraction('flight', target='flock').schedule
        'member'
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        probability: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 1.0,
    ) -> None:
        super().__init__(label, target=target, step=self._fly, probability=probability, schedule='member')

    @staticmethod
    def _fly(agent: AgentViewProtocol, model: ModelViewProtocol) -> tuple[float, ...]:
        """Return the position of a boid after one step along its velocity."""
        boid = cast(BoidView, agent)
        position = np.asarray(boid.location, dtype=float) + np.array([boid.vx, boid.vy])
        return tuple(float(item) for item in np.mod(position, np.array([boid.width, boid.height])))