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