4
\$\begingroup\$

I've been working on creating a simple Class for a Cellular Automata. It generates a grid of NxM dimensions populated by Cell objects - Pretty straightforward class to save the state of the cell-. It allows to update the world grid using two sets of rules a the moment: solidification rules and Conway's game of life rules, I will implement a way of using custom rules in the future. It also has the capability of printing the world grid current state or saving it as an image file. I'm no expert when it comes to writing code. I would like the code to be as clear and maintainable as possible, while following good practices. I'm not interested at the moment in optimizing it. I just want to make great Python code so I can use it, extend it or publish it in the future.

"""
File: CA.py
Project: Cellular_automata
File Created: Wednesday, 30th August 2023 10:20:17 am
Author: Athansya
-----
License: MIT License
-----
Description: Simple cellular automata class.
"""

from copy import deepcopy
from dataclasses import dataclass, field
import matplotlib.pyplot as plt
from numpy import array, ndarray


@dataclass()
class Cell:
    """Class for storing the state of a cell
    Args:
        state (int): state of the cell. Default 0.
    """

    state: int = 0

    def __add__(self, other) -> int:
        return self.state + other.state

    def __repr__(self) -> str:
        return str(self.state)


@dataclass
class CA:
    """Class for creating a cellular automata
    Args:
        world_dim (tuple[int, int]): Dimensions MxN of the world grid.
        states (dict[str, int]): Valid states for the cell
    """
    world_dim: tuple[int, int]
    states: dict[str, int] = field(default_factory=lambda: {"0": 0, "1": 1})
    gen: int = field(init=False, default=0)

    def __post_init__(self):
        self.world = [
            [Cell() for _ in range(self.world_dim[1] + 1)]
            for _ in range(self.world_dim[0] + 1)
        ]
        self.new_world = deepcopy(self.world)

    def set_cell_value(self, row_index: int, col_index: int, value: int):
        """Sets the state of a cell.

        Args:
            row_index (int): row position of cell in world grid.
            col_index (int): column position of cell in world grid. 
            value (int): new state value. 
        """
        self.world[row_index][col_index].state = value

    def show_world(self):
        """Prints the world grid"""
        for row in self.world:
            print(*row)

    def show_world_pretty(self):
        """Pretty print of world grid"""
        state_to_char = {
            0: " ",
            1: "#"
        }

        for row in self.world:
            print(*[state_to_char[cell.state] for cell in row])

    def apply_rules(self, row_index: int, col_index: int) -> int:
        """Applies solidification rules for a 2D cellular automata.

        Args:
            row_index (int): row position in world grid. 
            col_index (int): col position in world grid. 

        Returns:
            int: new cell's state.
        """
        # Solidification rules
        # Case 1. State == 1 -> 1
        if self.world[row_index][col_index].state == self.states["1"]:
            return 1
        # Case 2. State == 0 and && neighorhood sum == 1 or 2 -> 1
        # Init sum without taking central into account
        neighborhood_sum = 0 - self.world[row_index][col_index].state
        # Walk through Von Newmann Neighborhood
        for row in self.world[row_index - 1 : row_index + 2]:
            neighborhood_sum += sum(
                cell.state for cell in row[col_index - 1 : col_index + 2]
            )
        if neighborhood_sum == 1 or neighborhood_sum == 2:
            return self.states["1"]
        else:
            return self.states["0"]

    def game_of_life_rules(self, row_index: int, col_index: int) -> int:
        """Applies Conway's game of life rules for 2D cellular automata.

        Args:
            row_index (int): _description_
            col_index (int): _description_

        Returns:
            int: _description_
        """
        # Conway's rules
        # 1 with 2 or 3 -> 1 else -> 0
        # 0 with 3 -> 1 else 0

        neighborhood_sum = 0 - self.world[row_index][col_index].state
        # Live cell
        if self.world[row_index][col_index].state == self.states['1']:
            for row in self.world[row_index - 1 : row_index + 2]:
                neighborhood_sum += sum(
                    cell.state for cell in row[col_index - 1 : col_index + 2]
                )
            if neighborhood_sum == 2 or neighborhood_sum == 3:
                # Keeps living
                return self.states["1"]
            else:
                # Dies
                return self.states["0"]
        else:  # Dead cell
            for row in self.world[row_index - 1 : row_index + 2]:
                neighborhood_sum += sum(
                    cell.state for cell in row[col_index - 1 : col_index + 2]
                )
            if neighborhood_sum == 3:
                # Revives
                return self.states["1"]
            else:
                # Still dead
                return self.states["0"]


    def update_world(self, generations: int = 10):
        """Updates world grid using a set of rules

        Args:
            generations (int, optional): Number of generations. Defaults to 10.
        """
        for _ in range(1, generations + 1):
            for row_index in range(1, self.world_dim[0]):
                for col_index in range(1, self.world_dim[1]):
                    # Solidification rules
                    self.new_world[row_index][col_index].state = self.apply_rules(
                        row_index, col_index
                    )
                    # Game of life rules
                    # self.new_world[row_index][col_index].state = self.game_of_life_rules(
                        # row_index, col_index
                    # )
            # Update worlds!
            self.world = deepcopy(self.new_world)
            self.gen += 1  # Update gen counter

    def world_to_numpy(self) -> ndarray:
        """Converts world grid to numpy array.

        Returns:
            ndarray: converted world grid.
        """
        return array([[cell.state for cell in row] for row in self.world])

    def save_world_to_image(self, title: str = None, filename: str = None):
        """Saves the world state as 'png' image.

        Args:
            title (str, optional): Image title. Defaults to None.
            filename (str, optional): file name. Defaults to None.
        """
        img = self.world_to_numpy()
        plt.imshow(img, cmap="binary")
        plt.axis("off")
        if title is not None:
            plt.title(f"{title}")
        else:
            plt.title(f"Cellular Automata - Gen {self.gen}")
        if filename is not None:
            plt.savefig(f"{filename}.png")
        else:
            plt.savefig(f"ca_{self.gen}.png")

if __name__ == "__main__":
    # CA init
    ROWS, COLS = 101, 101
    ca = CA(world_dim=(ROWS, COLS))
    ca.set_cell_value(ROWS // 2, COLS // 2, 1)
    # Updates CA and saves images
    for _ in range(8):
        ca.update_world()
        ca.save_world_to_image(filename=f"ca_solification_rules_gen_{ca.gen}.png")

Here are some sample outputs following the solidification rules: enter image description here enter image description here enter image description here

\$\endgroup\$
2
  • \$\begingroup\$ The "Van Moore" comment is not helpful. It seems to refer to a Moore neighborhood of size 4, but in reality we have a Von Newmann neighborhood of size 8. These and many related details are described in Wolfram's NKS. \$\endgroup\$
    – J_H
    Sep 3, 2023 at 1:57
  • \$\begingroup\$ @J_H You are right, I mixed them up and didn't edit it a the end. \$\endgroup\$
    – Athansya
    Sep 3, 2023 at 17:26

1 Answer 1

4
\$\begingroup\$

It's important that when you use dataclass, you almost always pass slots=True. Other than having performance benefits, it catches type consistency errors. Your world and new_world are missing declarations in CA.

You should refactor your code so that Cell is immutable and its dataclass attribute accepts frozen=True.

It's a strange contract indeed for Cell.__add__ to accept another Cell but produce an integer. Why shouldn't this just return a new Cell whose state is the sum?

You're missing some type hints, e.g. __post_init__(self) -> None.

Don't from numpy import array, ndarray; do the standard import numpy as np and then refer to np.array.

Since state_to_char has keys of contiguous, zero-based integers, just write it as a tuple - or even a string, ' #'.

update_world should accept a method reference, or at least a boolean, to switch between Conway and Solidification modes; you shouldn't have to change the commenting.

title: str = None, filename: str = None is incorrect. Either use title: str | None = None, filename: str | None = None or invoke Optional.

Everything in your __main__ guard is still in global scope, so move it into a main function.

I'm not interested at the moment in optimizing it. I just want to make great Python code

OK, but... Great code that's extremely slow even for the trivial cases isn't great code. It's good that you have a proof of concept, but you should now restart and write a vectorised implementation.

\$\endgroup\$
4
  • \$\begingroup\$ Okay, that seems like good advice, I just have a couple of questions: 1. Why do I need to import numpy as np if I'm only using array and ndarray? 2. Do you have any pointers for looking into a vectorised implementation or is better to ask a new question? \$\endgroup\$
    – Athansya
    Sep 3, 2023 at 17:41
  • 1
    \$\begingroup\$ 1. array in particular is a problematic symbol to have floating around and is ripe for conflict. Even if this weren't the case, np import is standard and helps to limit namespace pollution. \$\endgroup\$
    – Reinderien
    Sep 3, 2023 at 19:02
  • 2
    \$\begingroup\$ 2. That's not really a question you can ask on any SE forum; "how do I vectorise this (medium-complexity project)" is vastly too broad. You're going to have to invest your own research and effort and come to SO once something doesn't work, or here once something does work. \$\endgroup\$
    – Reinderien
    Sep 3, 2023 at 19:03
  • 2
    \$\begingroup\$ There's a (certified metric tonne) of crap blogs and copy-and-pasted SEO content out there; especially avoid geeksforgeeks. Start with: numpy.org/doc/stable/user/absolute_beginners.html \$\endgroup\$
    – Reinderien
    Sep 3, 2023 at 19:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.