Skip to content

Boids in three dimensions

Boids in three dimensions, birds that flock by local rules in a periodic cube.

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

Select this system

from skcomplex.systems import create_simulation, create_system, describe

system = create_system('boids_3d')
entry = describe('boids_3d')
trajectory = create_simulation('boids_3d').prepare().run().trajectory_

Factory

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

    The boids start at random positions with random headings. Every step a boid steers by the boids within `radius` and
    then flies along its velocity, so with alignment alone the flock turns to one heading and keeps its spread in depth.

    Args:
        n_boids:
            The number of boids.

        size:
            The side of the periodic cube.

        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 system, with the group `flock` and the vector state `velocity` of its boids.

    Examples:
        >>> from skcomplex.systems.biology.collective_behaviour import boids_3d
        >>> boids_3d(10).space.extent
        (20.0, 20.0, 20.0)
    """
    rng = np.random.default_rng(random_state)
    headings = rng.normal(size=(n_boids, 3))
    flock = GroupElement(
        'flock',
        element=SpatialBoidElement,
        size=n_boids,
        values={
            'velocity': headings / np.linalg.norm(headings, axis=1, keepdims=True),
            'radius': radius,
            'cohesion': cohesion,
            'separation': separation,
            'alignment': alignment,
        },
        location=[tuple(point) for point in rng.uniform(0.0, size, (n_boids, 3))],
    )
    interactions = [
        HeadingInteraction('heading', target='flock'),
        SpatialFlightInteraction('flight', target='flock'),
    ]
    return System(
        elements=[flock],
        interactions=interactions,
        space=ContinuousSpace(extent=(size, size, size), boundary='periodic'),
    )

Components

Elements:

  • SpatialBoidElement, from Element

Interactions:

  • HeadingInteraction, from MapInteraction
  • SpatialFlightInteraction, from MovementInteraction
class SpatialBoidElement(Element):
    """Boid in three dimensions, a bird that steers by the velocity and the position of the boids it sees.

    Every step `HeadingInteraction` steers a boid by cohesion, separation, and alignment with the boids within `radius`,
    with its speed between `min_speed` and `max_speed`, and `SpatialFlightInteraction` moves it along its velocity.

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

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

        velocity:
            The initial velocity, one component per dimension of the space.

        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.

    Examples:
        >>> from skcomplex.core.components.elements import GroupElement
        >>> from skcomplex.systems.biology.collective_behaviour import SpatialBoidElement
        >>> GroupElement('flock', element=SpatialBoidElement, size=10).size
        10
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = VisualSpec('agents'),
        *,
        velocity: Annotated[npt.ArrayLike, StateSpec(vector=True, 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,
    ) -> None:
        super().__init__(label, visual=visual)
        self.velocity = velocity
        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

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

class HeadingInteraction(MapInteraction):
    """Steering of the velocity of every boid by cohesion, separation, and alignment, 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 of the space. It is the agent form of a map of the velocity under the `'member'`
    schedule.

    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.systems.biology.collective_behaviour import HeadingInteraction
        >>> HeadingInteraction('heading', target='flock').schedule
        'member'
    """

    def __init__(self, label: str, *, target: Annotated[str, RoleSpec()], phase: str | None = None) -> None:
        super().__init__(
            label,
            target=target,
            state='velocity',
            function=self._steer,
            schedule='member',
            form='agent',
            phase=phase,
        )

    @staticmethod
    def _get_steering(boid: SpatialBoidView, others: list[SpatialBoidView]) -> Array:
        """Return the change of the velocity of a boid from the boids it sees."""
        offsets = np.array([boid.displacement_to(other) for other in others])
        velocities = np.array([other.velocity for other in others])
        close = np.linalg.norm(offsets, axis=1) < boid.separation_radius
        return (
            boid.cohesion * offsets.mean(axis=0)
            - boid.separation * offsets[close].sum(axis=0)
            + boid.alignment * (velocities.mean(axis=0) - boid.velocity)
        )

    @staticmethod
    def _steer(agent: AgentViewProtocol, system: SystemViewProtocol) -> Array:
        """Return the velocity of a boid after one step of steering, with its speed clipped."""
        boid = cast(SpatialBoidView, agent)
        others = [cast(SpatialBoidView, other) for other in boid.neighbors(boid.radius)]
        velocity = np.asarray(boid.velocity, dtype=float)
        if others:
            velocity = velocity + HeadingInteraction._get_steering(boid, others)
        speed = float(np.linalg.norm(velocity))
        return velocity * np.clip(speed, boid.min_speed, boid.max_speed) / speed if speed else velocity

class SpatialFlightInteraction(MovementInteraction):
    """Flight of every boid by its velocity in one step, with the boundary of its space acting on the new position.

    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.systems.biology.collective_behaviour import SpatialFlightInteraction
        >>> SpatialFlightInteraction('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, system: SystemViewProtocol) -> Array:
        """Return the position of a boid after one step along its velocity."""
        boid = cast(SpatialBoidView, agent)
        return np.asarray(boid.location, dtype=float) + np.asarray(boid.velocity, dtype=float)