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
1 Answer 1
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.
-
\$\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 usingarray
andndarray
? 2. Do you have any pointers for looking into a vectorised implementation or is better to ask a new question? \$\endgroup\$Athansya– Athansya2023年09月03日 17:41:58 +00:00Commented 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– Reinderien2023年09月03日 19:02:49 +00:00Commented 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– Reinderien2023年09月03日 19:03:58 +00:00Commented 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– Reinderien2023年09月03日 19:07:20 +00:00Commented Sep 3, 2023 at 19:07
Explore related questions
See similar questions with these tags.
4
, but in reality we have a Von Newmann neighborhood of size8
. These and many related details are described in Wolfram's NKS. \$\endgroup\$