|
| 1 | +""" |
| 2 | +Von Neumann cellular automaton with multi-state fading "heatmap" effect. |
| 3 | +
|
| 4 | +This implementation demonstrates a Von Neumann cellular automaton where cells |
| 5 | +follow custom birth/survive rules and dead cells fade gradually through multiple |
| 6 | +visual states, creating a heatmap-like effect. |
| 7 | +
|
| 8 | +Based on Von Neumann cellular automata architecture: |
| 9 | +https://en.wikipedia.org/wiki/Von_Neumann_cellular_automaton |
| 10 | +
|
| 11 | +Von Neumann neighborhood reference: |
| 12 | +https://en.wikipedia.org/wiki/Von_Neumann_neighborhood |
| 13 | +
|
| 14 | +Requirements: numpy |
| 15 | +""" |
| 16 | + |
| 17 | +import numpy as np |
| 18 | +from typing import Set, Tuple, Dict, Optional |
| 19 | + |
| 20 | + |
| 21 | +def create_random_grid( |
| 22 | + rows: int, columns: int, alive_probability: float, seed: Optional[int] = None |
| 23 | +) -> np.ndarray: |
| 24 | + """ |
| 25 | + Create initial grid with randomly distributed alive cells. |
| 26 | +
|
| 27 | + Args: |
| 28 | + rows: Number of grid rows |
| 29 | + columns: Number of grid columns |
| 30 | + alive_probability: Probability (0.0-1.0) of each cell being initially alive |
| 31 | + seed: Random seed for reproducibility |
| 32 | +
|
| 33 | + Returns: |
| 34 | + 2D numpy array where 1 represents alive cells, 0 represents dead cells |
| 35 | +
|
| 36 | + Raises: |
| 37 | + ValueError: If alive_probability is not between 0 and 1 |
| 38 | + ValueError: If rows or columns are not positive integers |
| 39 | +
|
| 40 | + Examples: |
| 41 | + >>> grid = create_random_grid(3, 3, 0.5, seed=42) |
| 42 | + >>> grid.shape |
| 43 | + (3, 3) |
| 44 | + >>> bool(np.all((grid == 0) | (grid == 1))) |
| 45 | + True |
| 46 | + >>> grid.dtype |
| 47 | + dtype('uint8') |
| 48 | +
|
| 49 | + >>> create_random_grid(0, 3, 0.5) # doctest: +IGNORE_EXCEPTION_DETAIL |
| 50 | + Traceback (most recent call last): |
| 51 | + ValueError: Rows and columns must be positive integers |
| 52 | +
|
| 53 | + >>> create_random_grid(3, 3, 1.5) # doctest: +IGNORE_EXCEPTION_DETAIL |
| 54 | + Traceback (most recent call last): |
| 55 | + ValueError: alive_probability must be between 0.0 and 1.0 |
| 56 | + """ |
| 57 | + if rows <= 0 or columns <= 0: |
| 58 | + raise ValueError("Rows and columns must be positive integers") |
| 59 | + if not 0.0 <= alive_probability <= 1.0: |
| 60 | + raise ValueError("alive_probability must be between 0.0 and 1.0") |
| 61 | + |
| 62 | + rng = np.random.default_rng(seed) |
| 63 | + alive_cells = (rng.random((rows, columns)) < alive_probability).astype(np.uint8) |
| 64 | + return alive_cells |
| 65 | + |
| 66 | + |
| 67 | +def count_von_neumann_neighbors( |
| 68 | + alive_mask: np.ndarray, use_wraparound: bool = True |
| 69 | +) -> np.ndarray: |
| 70 | + """ |
| 71 | + Count Von Neumann neighbors for each cell (4-directional neighborhood). |
| 72 | +
|
| 73 | + The Von Neumann neighborhood consists of the four orthogonally adjacent cells |
| 74 | + (up, down, left, right) but excludes diagonal neighbors. |
| 75 | +
|
| 76 | + Args: |
| 77 | + alive_mask: Binary 2D array where 1 represents alive cells |
| 78 | + use_wraparound: If True, edges wrap around (toroidal topology) |
| 79 | +
|
| 80 | + Returns: |
| 81 | + 2D array with neighbor counts (0-4) for each cell |
| 82 | +
|
| 83 | + Raises: |
| 84 | + ValueError: If alive_mask is not 2D or contains invalid values |
| 85 | +
|
| 86 | + Examples: |
| 87 | + >>> mask = np.array([[1, 0, 1], [0, 1, 0], [1, 0, 1]], dtype=np.uint8) |
| 88 | + >>> counts = count_von_neumann_neighbors(mask, use_wraparound=False) |
| 89 | + >>> int(counts[1, 1]) # center cell has 0 neighbors (all adjacent are 0) |
| 90 | + 0 |
| 91 | + >>> int(counts[0, 1]) # top middle has 3 neighbors (down, left, right are 1) |
| 92 | + 3 |
| 93 | +
|
| 94 | + >>> mask_simple = np.array([[1, 1], [1, 0]], dtype=np.uint8) |
| 95 | + >>> counts_simple = count_von_neumann_neighbors(mask_simple, use_wraparound=False) |
| 96 | + >>> int(counts_simple[0, 0]) # top-left has 2 neighbors (right and down) |
| 97 | + 2 |
| 98 | +
|
| 99 | + >>> invalid_mask = np.array([1, 2, 3]) # doctest: +IGNORE_EXCEPTION_DETAIL |
| 100 | + >>> count_von_neumann_neighbors(invalid_mask) |
| 101 | + Traceback (most recent call last): |
| 102 | + ValueError: alive_mask must be a 2D array |
| 103 | + """ |
| 104 | + if alive_mask.ndim != 2: |
| 105 | + raise ValueError("alive_mask must be a 2D array") |
| 106 | + if not np.all((alive_mask == 0) | (alive_mask == 1)): |
| 107 | + raise ValueError("alive_mask must contain only 0s and 1s") |
| 108 | + |
| 109 | + rows, cols = alive_mask.shape |
| 110 | + neighbor_counts = np.zeros((rows, cols), dtype=np.uint8) |
| 111 | + |
| 112 | + if use_wraparound: |
| 113 | + # Use rolling for wraparound |
| 114 | + up_neighbors = np.roll(alive_mask, -1, axis=0) |
| 115 | + down_neighbors = np.roll(alive_mask, 1, axis=0) |
| 116 | + left_neighbors = np.roll(alive_mask, -1, axis=1) |
| 117 | + right_neighbors = np.roll(alive_mask, 1, axis=1) |
| 118 | + neighbor_counts = up_neighbors + down_neighbors + left_neighbors + right_neighbors |
| 119 | + else: |
| 120 | + # Manually count neighbors without wraparound |
| 121 | + for r in range(rows): |
| 122 | + for c in range(cols): |
| 123 | + count = 0 |
| 124 | + # Check up |
| 125 | + if r > 0 and alive_mask[r-1, c]: |
| 126 | + count += 1 |
| 127 | + # Check down |
| 128 | + if r < rows-1 and alive_mask[r+1, c]: |
| 129 | + count += 1 |
| 130 | + # Check left |
| 131 | + if c > 0 and alive_mask[r, c-1]: |
| 132 | + count += 1 |
| 133 | + # Check right |
| 134 | + if c < cols-1 and alive_mask[r, c+1]: |
| 135 | + count += 1 |
| 136 | + neighbor_counts[r, c] = count |
| 137 | + |
| 138 | + return neighbor_counts |
| 139 | + |
| 140 | + |
| 141 | +def apply_cellular_automaton_rules( |
| 142 | + current_ages: np.ndarray, |
| 143 | + birth_neighbor_counts: Set[int], |
| 144 | + survival_neighbor_counts: Set[int], |
| 145 | + maximum_age: int = 5, |
| 146 | + use_wraparound: bool = True, |
| 147 | +) -> np.ndarray: |
| 148 | + """ |
| 149 | + Apply cellular automaton rules to advance the grid by one generation. |
| 150 | +
|
| 151 | + Cells are born when they have a neighbor count in birth_neighbor_counts. |
| 152 | + Living cells survive when they have a neighbor count in survival_neighbor_counts. |
| 153 | + Dead cells age and eventually disappear completely. |
| 154 | +
|
| 155 | + Args: |
| 156 | + current_ages: 2D array where values represent cell ages (0 = dead, >0 = alive) |
| 157 | + birth_neighbor_counts: Set of neighbor counts that cause cell birth |
| 158 | + survival_neighbor_counts: Set of neighbor counts that allow cell survival |
| 159 | + maximum_age: Maximum age before cell disappears completely |
| 160 | + use_wraparound: Whether to use wraparound boundaries |
| 161 | +
|
| 162 | + Returns: |
| 163 | + New 2D array with updated cell ages after applying rules |
| 164 | +
|
| 165 | + Raises: |
| 166 | + ValueError: If inputs are invalid |
| 167 | +
|
| 168 | + Examples: |
| 169 | + >>> ages = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.uint8) |
| 170 | + >>> new_ages = apply_cellular_automaton_rules( |
| 171 | + ... ages, birth_neighbor_counts={2}, |
| 172 | + ... survival_neighbor_counts={2, 3}, use_wraparound=False |
| 173 | + ... ) |
| 174 | + >>> bool(new_ages[0, 0] > 0) # corner should be born (2 neighbors: right and down) |
| 175 | + True |
| 176 | +
|
| 177 | + >>> # Test aging of dead cells |
| 178 | + >>> dead_aging = np.array([[2, 0, 0]], dtype=np.uint8) # age 2, no survival |
| 179 | + >>> result = apply_cellular_automaton_rules( |
| 180 | + ... dead_aging, birth_neighbor_counts=set(), |
| 181 | + ... survival_neighbor_counts=set(), maximum_age=3 |
| 182 | + ... ) |
| 183 | + >>> bool(result[0, 0] == 3) # should age from 2 to 3 |
| 184 | + True |
| 185 | +
|
| 186 | + >>> apply_cellular_automaton_rules(np.array([1, 2]), {1}, {1}) # doctest: +IGNORE_EXCEPTION_DETAIL |
| 187 | + Traceback (most recent call last): |
| 188 | + ValueError: current_ages must be a 2D array |
| 189 | + """ |
| 190 | + if current_ages.ndim != 2: |
| 191 | + raise ValueError("current_ages must be a 2D array") |
| 192 | + if maximum_age < 1: |
| 193 | + raise ValueError("maximum_age must be at least 1") |
| 194 | + |
| 195 | + alive_cells_mask = current_ages > 0 |
| 196 | + neighbor_counts = count_von_neumann_neighbors( |
| 197 | + alive_cells_mask.astype(np.uint8), use_wraparound |
| 198 | + ) |
| 199 | + |
| 200 | + # Determine which cells are born or survive |
| 201 | + birth_mask = (~alive_cells_mask) & np.isin(neighbor_counts, list(birth_neighbor_counts)) |
| 202 | + survival_mask = alive_cells_mask & np.isin(neighbor_counts, list(survival_neighbor_counts)) |
| 203 | + |
| 204 | + new_ages = current_ages.copy() |
| 205 | + |
| 206 | + # Set ages for newly born cells |
| 207 | + new_ages[birth_mask] = 1 |
| 208 | + |
| 209 | + # Reset age for surviving cells (keeps them visually fresh) |
| 210 | + new_ages[survival_mask] = 1 |
| 211 | + |
| 212 | + # Age cells that neither survive nor get born |
| 213 | + fade_mask = (~birth_mask) & (~survival_mask) |
| 214 | + new_ages[fade_mask & (new_ages > 0)] += 1 |
| 215 | + |
| 216 | + # Remove cells that have exceeded maximum age |
| 217 | + new_ages[new_ages > maximum_age] = 0 |
| 218 | + |
| 219 | + return new_ages |
| 220 | + |
| 221 | + |
| 222 | +def simulate_von_neumann_cellular_automaton( |
| 223 | + grid_rows: int = 20, |
| 224 | + grid_columns: int = 40, |
| 225 | + initial_alive_probability: float = 0.25, |
| 226 | + birth_rules: Set[int] = None, |
| 227 | + survival_rules: Set[int] = None, |
| 228 | + maximum_cell_age: int = 5, |
| 229 | + generations: int = 100, |
| 230 | + random_seed: Optional[int] = None, |
| 231 | + use_wraparound_edges: bool = True, |
| 232 | +) -> list[np.ndarray]: |
| 233 | + """ |
| 234 | + Run a complete Von Neumann cellular automaton simulation. |
| 235 | +
|
| 236 | + This function creates an initial random grid and evolves it through multiple |
| 237 | + generations according to the specified birth and survival rules. |
| 238 | +
|
| 239 | + Args: |
| 240 | + grid_rows: Number of rows in the grid |
| 241 | + grid_columns: Number of columns in the grid |
| 242 | + initial_alive_probability: Initial probability of cells being alive |
| 243 | + birth_rules: Set of neighbor counts that cause birth (default: {3}) |
| 244 | + survival_rules: Set of neighbor counts that allow survival (default: {1, 2}) |
| 245 | + maximum_cell_age: Maximum age before cells disappear (default: 5) |
| 246 | + generations: Number of generations to simulate |
| 247 | + random_seed: Seed for random number generation |
| 248 | + use_wraparound_edges: Whether to use toroidal topology |
| 249 | +
|
| 250 | + Returns: |
| 251 | + List of 2D numpy arrays representing each generation |
| 252 | +
|
| 253 | + Raises: |
| 254 | + ValueError: If parameters are invalid |
| 255 | +
|
| 256 | + Examples: |
| 257 | + >>> result = simulate_von_neumann_cellular_automaton( |
| 258 | + ... grid_rows=5, grid_columns=5, generations=3, random_seed=42 |
| 259 | + ... ) |
| 260 | + >>> len(result) == 3 |
| 261 | + True |
| 262 | + >>> all(grid.shape == (5, 5) for grid in result) |
| 263 | + True |
| 264 | +
|
| 265 | + >>> simulate_von_neumann_cellular_automaton(generations=0) # doctest: +IGNORE_EXCEPTION_DETAIL |
| 266 | + Traceback (most recent call last): |
| 267 | + ValueError: generations must be positive |
| 268 | + """ |
| 269 | + if birth_rules is None: |
| 270 | + birth_rules = {3} |
| 271 | + if survival_rules is None: |
| 272 | + survival_rules = {1, 2} |
| 273 | + |
| 274 | + if generations <= 0: |
| 275 | + raise ValueError("generations must be positive") |
| 276 | + if grid_rows <= 0 or grid_columns <= 0: |
| 277 | + raise ValueError("grid dimensions must be positive") |
| 278 | + |
| 279 | + # Initialize grid |
| 280 | + current_grid = create_random_grid( |
| 281 | + grid_rows, grid_columns, initial_alive_probability, random_seed |
| 282 | + ) |
| 283 | + |
| 284 | + generation_history = [] |
| 285 | + |
| 286 | + # Run simulation for specified number of generations |
| 287 | + for _ in range(generations): |
| 288 | + generation_history.append(current_grid.copy()) |
| 289 | + current_grid = apply_cellular_automaton_rules( |
| 290 | + current_grid, |
| 291 | + birth_rules, |
| 292 | + survival_rules, |
| 293 | + maximum_cell_age, |
| 294 | + use_wraparound_edges, |
| 295 | + ) |
| 296 | + |
| 297 | + return generation_history |
| 298 | + |
| 299 | + |
| 300 | +if __name__ == "__main__": |
| 301 | + import doctest |
| 302 | + doctest.testmod(verbose=True) |
0 commit comments