Skip to content

Iterated function systems by the chaos game

Points that draw the attractor of an iterated function system by the chaos game.

  • Name: chaos_game
  • Field: mathematics/fractals
  • Formalism: discrete map
  • References: Barnsley 1988; Dilão 2023

Factory

@register_model(
    field='mathematics/fractals',
    tags=('fractal', 'iterated function system', 'chaos game', 'discrete map'),
    references=('Barnsley 1988', 'Dilão 2023'),
    defaults={'until': 30, 'dt': 1},
)
def chaos_game(
    preset: str = 'sierpinski',
    *,
    maps: Sequence[AffineMap] | None = None,
    weights: Sequence[float] | None = None,
    n_points: int = 20000,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return points that draw the attractor of an iterated function system by the chaos game.

    At every step each point moves by one of the affine maps of the system, drawn with probabilities proportional to
    the weights. The maps contract, so after a few steps the cloud of points covers the attractor, the unique compact
    set that the union of the maps leaves unchanged. The Sierpiński triangle of three maps of ratio 1/2 has the
    box-counting dimension `log 3 / log 2`, and the Barnsley fern is the attractor of four maps.

    Args:
        preset:
            `'sierpinski'` or `'barnsley'`, used when `maps` is `None`.

        maps:
            The coefficients `(a, b, c, d, e, f)` of every map `(x, y) -> (a x + b y + e, c x + d y + f)`, or `None` for
            the preset.

        weights:
            The weight of every map, or `None` for the weights of the preset, or equal weights for given maps.

        n_points:
            The number of points, started uniformly in the unit square.

        random_state:
            The seed or generator of the initial points.

    Returns:
        system:
            The model, with the positions `points.p`.

    Raises:
        ValidationError:
            The preset is unknown.

    Examples:
        >>> from skcomplex.models.mathematics.fractals import chaos_game
        >>> len(chaos_game('barnsley', n_points=10).interactions[0].maps)
        4
    """
    if maps is None and preset not in PRESETS:
        error_msg = f'Unknown chaos game preset {preset!r}. Use one of {", ".join(PRESETS)}, or give the maps.'
        raise ValidationError(error_msg)
    chosen_maps, preset_weights = PRESETS[preset] if maps is None else (maps, (1.0,) * len(maps))
    return _system_of(chosen_maps, weights if weights is not None else preset_weights, n_points, random_state)

Components

class ChaosPointElement(Element):
    """Point of the plane whose position a random affine map moves at every step.

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

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

        p:
            The initial position.

    Examples:
        >>> from skcomplex.models.mathematics.fractals import ChaosPointElement
        >>> ChaosPointElement('point', p=(0.5, 0.5)).p
        (0.5, 0.5)
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = VisualSpec('points', {'position': 'p', 'radius': 0.004, 'states': ()}),
        *,
        p: Annotated[object, StateSpec(vector=True)] = 0.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.p = p

class RandomAffineMapInteraction(MapInteraction):
    """Map of a point of the plane by one affine map of an iterated function system, drawn anew at every step.

    Map `k` takes `(x, y)` to `(a x + b y + e, c x + d y + f)` for its coefficients `(a, b, c, d, e, f)`, and is drawn
    with a probability proportional to its weight, independently for every member of a bound group.

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

        target:
            The path of the element or group whose position the map sets.

        state:
            The name of the vector state of the target that holds the position.

        maps:
            The coefficients `(a, b, c, d, e, f)` of every affine map.

        weights:
            The weight of every map.

    Raises:
        ValidationError:
            The number of weights differs from the number of maps, or a map does not have six coefficients.

    Examples:
        >>> from skcomplex.models.mathematics.fractals import RandomAffineMapInteraction
        >>> game = RandomAffineMapInteraction('game', target='points', maps=[(0.5, 0, 0, 0.5, 0, 0)], weights=[1.0])
        >>> game.check().maps
        [(0.5, 0, 0, 0.5, 0, 0)]
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        state: str = 'p',
        maps: Sequence[AffineMap] = SIERPINSKI_MAPS,
        weights: Sequence[float] = (1.0, 1.0, 1.0),
    ) -> None:
        super().__init__(label, target=target, state=state, function=self._get_image)
        self.maps = maps
        self.weights = weights

    def _check(self) -> None:
        """Raise an error for weights that do not match the maps, or maps without six coefficients.

        Raises:
            ValidationError:
                The number of weights differs from the number of maps, or a map does not have six coefficients.
        """
        super()._check()
        if len(self.maps) != len(self.weights) or any(len(item) != AFFINE_COEFFICIENTS for item in self.maps):
            error_msg = (
                f'The random affine map {self.label!r} takes one weight per map and six coefficients per map, but got '
                f'{len(self.maps)} maps and {len(self.weights)} weights.'
            )
            raise ValidationError(error_msg)

    def _get_image(self, namespace: SimpleNamespace) -> FreeModuleElement:
        """Return the image of the position by the map drawn among the maps."""
        chosen = categorical('map', list(self.weights))
        position = getattr(namespace.target, self.state)
        x, y = position[0], position[1]
        images: list[tuple[Expression, Expression]] = [
            (a * x + b * y + e, c * x + d * y + f) for a, b, c, d, e, f in self.maps
        ]
        new_x, new_y = SR(images[-1][0]), SR(images[-1][1])
        for index in range(len(images) - 2, -1, -1):
            new_x = where(chosen == index, images[index][0], new_x)
            new_y = where(chosen == index, images[index][1], new_y)
        return vector(SR, [new_x, new_y])

Equations

points.p[i, 0]: p_points_0(t + 1) == where(where(3.0*categorical_map_game >= 2.0, 1, 0) + where(3.0*categorical_map_game >= 1.0, 1, 0) == 0, 0.5*p_points_0(t), where(where(3.0*categorical_map_game >= 2.0, 1, 0) + where(3.0*categorical_map_game >= 1.0, 1, 0) == 1, 0.5*p_points_0(t) + 0.5, 0.5*p_points_0(t) + 0.25))
points.p[i, 1]: p_points_1(t + 1) == where(where(3.0*categorical_map_game >= 2.0, 1, 0) + where(3.0*categorical_map_game >= 1.0, 1, 0) == 0, 0.5*p_points_1(t), where(where(3.0*categorical_map_game >= 2.0, 1, 0) + where(3.0*categorical_map_game >= 1.0, 1, 0) == 1, 0.5*p_points_1(t), 0.5*p_points_1(t) + 0.5))