models.CA
Cellular Automaton Framework
This module provides the base cellular automaton class and the Predator-Prey (PP) implementation with Numba-accelerated kernels.
Classes
CA: Abstract base class for spatial cellular automata.
PP: Predator-Prey model with configurable hunting behavior.
Example
from models.CA import PP
# Basic usage
model = PP(rows=100, cols=100, densities=(0.3, 0.15), seed=42)
model.run(steps=1000)
# With evolution enabled
model = PP(rows=100, cols=100, seed=42)
model.evolve("prey_death", sd=0.05, min_val=0.01, max_val=0.15)
model.run(steps=500)
# With directed hunting
model = PP(rows=100, cols=100, directed_hunting=True, seed=42)
1#!/usr/bin/env python3 2""" 3Cellular Automaton Framework 4============================ 5 6This module provides the base cellular automaton class and the 7Predator-Prey (PP) implementation with Numba-accelerated kernels. 8 9Classes 10------- 11CA: Abstract base class for spatial cellular automata. 12 13PP: Predator-Prey model with configurable hunting behavior. 14 15Example 16------- 17```python 18from models.CA import PP 19 20# Basic usage 21model = PP(rows=100, cols=100, densities=(0.3, 0.15), seed=42) 22model.run(steps=1000) 23 24# With evolution enabled 25model = PP(rows=100, cols=100, seed=42) 26model.evolve("prey_death", sd=0.05, min_val=0.01, max_val=0.15) 27model.run(steps=500) 28 29# With directed hunting 30model = PP(rows=100, cols=100, directed_hunting=True, seed=42) 31``` 32""" 33 34from typing import Tuple, Dict, Optional 35 36import numpy as np 37import logging 38import sys 39from pathlib import Path 40 41# Add parent directory to path for imports 42sys.path.insert(0, str(Path(__file__).parent.parent)) 43 44from models.numba_optimized import PPKernel, set_numba_seed 45 46# Module logger 47logger = logging.getLogger(__name__) 48 49 50class CA: 51 """ 52 Base cellular automaton class for spatial simulations. 53 54 This class provides a framework for multi-species cellular automata with 55 support for global parameters, per-cell evolving parameters, and 56 grid initialization based on density. 57 58 Attributes 59 ---------- 60 grid : np.ndarray 61 2D numpy array containing integers in range [0, n_species]. 62 params : Dict[str, Any] 63 Global parameters shared by all cells. 64 cell_params : Dict[str, Any] 65 Local per-cell parameters, typically stored as numpy arrays matching the grid shape. 66 neighborhood : str 67 The adjacency rule used ('neumann' or 'moore'). 68 generator : np.random.Generator 69 The random number generator instance for reproducibility. 70 species_names : Tuple[str, ...] 71 Human-readable names for each species state. 72 """ 73 74 # Default colormap spec (string or sequence); resolved in `visualize` at runtime 75 _default_cmap = "viridis" 76 77 # Read-only accessors for size/densities (protected attributes set in __init__) 78 @property 79 def rows(self) -> int: 80 """int: Number of rows in the grid.""" 81 return getattr(self, "_rows") 82 83 @property 84 def cols(self) -> int: 85 """int: Number of columns in the grid.""" 86 return getattr(self, "_cols") 87 88 @property 89 def densities(self) -> Tuple[float, ...]: 90 """Tuple[float, ...]: Initial density fraction for each species.""" 91 return tuple(getattr(self, "_densities")) 92 93 # make n_species protected with read-only property 94 @property 95 def n_species(self) -> int: 96 """int: Number of distinct species states (excluding empty state 0).""" 97 return int(getattr(self, "_n_species")) 98 99 def __init__( 100 self, 101 rows: int, 102 cols: int, 103 densities: Tuple[float, ...], 104 neighborhood: str, 105 params: Dict[str, object], 106 cell_params: Dict[str, object], 107 seed: Optional[int] = None, 108 ) -> None: 109 """ 110 Initialize the cellular automaton grid and configurations. 111 112 Parameters 113 ---------- 114 rows : int 115 Number of rows in the grid (must be > 0). 116 cols : int 117 Number of columns in the grid (must be > 0). 118 densities : Tuple[float, ...] 119 Initial density for each species. Length defines `n_species`. 120 Values must sum to <= 1.0. 121 neighborhood : {'neumann', 'moore'} 122 Type of neighborhood connectivity. 123 params : Dict[str, Any] 124 Initial global parameter values. 125 cell_params : Dict[str, Any] 126 Initial local per-cell parameters. 127 seed : int, optional 128 Seed for the random number generator. 129 """ 130 assert isinstance(rows, int) and rows > 0, "rows must be positive int" 131 assert isinstance(cols, int) and cols > 0, "cols must be positive int" 132 assert ( 133 isinstance(densities, tuple) and len(densities) > 0 134 ), "densities must be a non-empty tuple" 135 for d in densities: 136 assert ( 137 isinstance(d, (float, int)) and d >= 0 138 ), "each density must be non-negative" 139 total_density = float(sum(densities)) 140 assert total_density <= 1.0 + 1e-12, "sum of densities must not exceed 1" 141 assert neighborhood in ( 142 "neumann", 143 "moore", 144 ), "neighborhood must be 'neumann' or 'moore'" 145 146 self._n_species: int = len(densities) 147 # store protected size/density attributes (read-only properties exposed) 148 self._rows: int = rows 149 self._cols: int = cols 150 self._densities: Tuple[float, ...] = tuple(densities) 151 self.params: Dict[str, object] = dict(params) if params is not None else {} 152 self.cell_params: Dict[str, object] = ( 153 dict(cell_params) if cell_params is not None else {} 154 ) 155 156 # per-parameter evolve metadata and evolution state 157 # maps parameter name -> dict with keys 'sd','min','max','species' 158 self._evolve_info: Dict[str, Dict[str, float]] = {} 159 # when True, inheritance uses deterministic copy from parent (no mutation) 160 self._evolution_stopped: bool = False 161 162 # human-readable species names (useful for visualization). Default 163 # generates generic names based on n_species; subclasses may override. 164 self.species_names: Tuple[str, ...] = tuple( 165 f"species{i+1}" for i in range(self._n_species) 166 ) 167 self.neighborhood: str = neighborhood 168 self.generator: np.random.Generator = np.random.default_rng(seed) 169 170 self.grid: np.ndarray = np.zeros((rows, cols), dtype=int) 171 172 total_cells = rows * cols 173 # Fill grid with species states 1..n_species according to densities. 174 for i, dens in enumerate(densities): 175 if dens <= 0: 176 continue 177 n_to_fill = int(round(total_cells * float(dens))) 178 if n_to_fill <= 0: 179 continue 180 empty_flat = np.flatnonzero(self.grid.ravel() == 0) 181 if len(empty_flat) == 0: 182 break 183 n_choice = min(n_to_fill, len(empty_flat)) 184 chosen = self.generator.choice(empty_flat, size=n_choice, replace=False) 185 # assign chosen flattened indices to state i+1 186 r = chosen // cols 187 c = chosen % cols 188 self.grid[r, c] = i + 1 189 190 def validate(self) -> None: 191 """ 192 Validate core CA invariants and grid dimensions. 193 194 Checks that the neighborhood is valid, the grid matches initialized dimensions, 195 and that local parameter arrays match the grid shape. 196 197 Raises 198 ------ 199 ValueError 200 If any structural invariant is violated. 201 """ 202 if self.neighborhood not in ("neumann", "moore"): 203 raise ValueError("neighborhood must be 'neumann' or 'moore'") 204 205 expected_shape = (int(getattr(self, "_rows")), int(getattr(self, "_cols"))) 206 if self.grid.shape != expected_shape: 207 raise ValueError( 208 f"grid shape {self.grid.shape} does not match expected {expected_shape}" 209 ) 210 211 # Ensure any array in cell_params matches grid shape 212 for k, v in (self.cell_params or {}).items(): 213 if isinstance(v, np.ndarray) and v.shape != expected_shape: 214 raise ValueError(f"cell_params['{k}'] must have shape equal to grid") 215 216 def _infer_species_from_param_name(self, param_name: str) -> Optional[int]: 217 """ 218 Infer the 1-based species index from a parameter name using `species_names`. 219 220 This method checks if the given parameter name starts with any of the 221 defined species names followed by an underscore (e.g., 'prey_birth'). 222 It is used to automatically route global parameters to the correct 223 species' local parameter arrays. 224 225 Parameters 226 ---------- 227 param_name : str 228 The name of the parameter to check. 229 230 Returns 231 ------- 232 Optional[int] 233 The 1-based index of the species if a matching prefix is found; 234 otherwise, None. 235 236 Notes 237 ----- 238 The method expects `self.species_names` to be a collection of strings. 239 If `param_name` is not a string or no match is found, it returns None. 240 """ 241 if not isinstance(param_name, str): 242 return None 243 for idx, name in enumerate(self.species_names or ()): # type: ignore 244 if isinstance(name, str) and param_name.startswith(f"{name}_"): 245 return idx + 1 246 return None 247 248 def evolve( 249 self, 250 param: str, 251 species: Optional[int] = None, 252 sd: float = 0.05, 253 min_val: Optional[float] = None, 254 max_val: Optional[float] = None, 255 ) -> None: 256 """ 257 Enable per-cell evolution for a specific parameter on a given species. 258 259 This method initializes a spatial parameter array (local parameter map) 260 for a global parameter. It allows individual cells to carry their own 261 values for that parameter, which can then mutate and evolve during 262 the simulation. 263 264 Parameters 265 ---------- 266 param : str 267 The name of the global parameter to enable for evolution. 268 Must exist in `self.params`. 269 species : int, optional 270 The 1-based index of the species to which this parameter applies. 271 If None, the method attempts to infer the species from the 272 parameter name prefix. 273 sd : float, default 0.05 274 The standard deviation of the Gaussian mutation applied during 275 inheritance/reproduction. 276 min_val : float, optional 277 The minimum allowable value for the parameter (clamping). 278 Defaults to 0.01 if not provided. 279 max_val : float, optional 280 The maximum allowable value for the parameter (clamping). 281 Defaults to 0.99 if not provided. 282 283 Raises 284 ------ 285 ValueError 286 If the parameter is not in `self.params`, the species cannot be 287 inferred, or the species index is out of bounds. 288 289 Notes 290 ----- 291 The local parameter is stored in `self.cell_params` as a 2D numpy 292 array initialized with the current global value for all cells of 293 the target species, and `NaN` elsewhere. 294 """ 295 if min_val is None: 296 min_val = 0.01 297 if max_val is None: 298 max_val = 0.99 299 if param not in self.params: 300 raise ValueError(f"Unknown parameter '{param}'") 301 if species is None: 302 species = self._infer_species_from_param_name(param) 303 if species is None: 304 raise ValueError( 305 "species must be provided or inferable from param name and species_names" 306 ) 307 if not isinstance(species, int) or species <= 0 or species > self._n_species: 308 raise ValueError("species must be an integer between 1 and n_species") 309 310 arr = np.full(self.grid.shape, np.nan, dtype=float) 311 mask = self.grid == int(species) 312 arr[mask] = float(self.params[param]) 313 self.cell_params[param] = arr 314 self._evolve_info[param] = { 315 "sd": float(sd), 316 "min": float(min_val), 317 "max": float(max_val), 318 "species": int(species), 319 } 320 321 def update(self) -> None: 322 """ 323 Perform one update step of the cellular automaton. 324 325 This is an abstract method that defines the transition rules of the 326 system. It must be implemented by concrete subclasses to specify 327 how cell states and parameters change over time based on their 328 current state and neighborhood. 329 330 Raises 331 ------ 332 NotImplementedError 333 If called directly on the base class instead of an implementation. 334 335 Returns 336 ------- 337 None 338 339 Notes 340 ----- 341 In a typical implementation, this method handles the logic for 342 stochastic transitions, movement, or predator-prey interactions. 343 """ 344 raise NotImplementedError( 345 "Override update() in a subclass to define CA dynamics" 346 ) 347 348 def run( 349 self, 350 steps: int, 351 stop_evolution_at: Optional[int] = None, 352 snapshot_iters: Optional[list] = None, 353 ) -> None: 354 """ 355 Execute the cellular automaton simulation for a specified number of steps. 356 357 This method drives the simulation loop, calling `update()` at each 358 iteration. It manages visualization updates, directory creation for 359 data persistence, and handles the freezing of evolving parameters 360 at a specific time step. 361 362 Parameters 363 ---------- 364 steps : int 365 The total number of iterations to run (must be non-negative). 366 stop_evolution_at : int, optional 367 The 1-based iteration index after which parameter mutation is 368 disabled. Useful for observing system stability after a period 369 of adaptation. 370 snapshot_iters : List[int], optional 371 A list of specific 1-based iteration indices at which to save 372 the current grid state to the results directory. 373 374 Returns 375 ------- 376 None 377 378 Notes 379 ----- 380 If snapshots are requested, a results directory is automatically created 381 using a timestamped subfolder (e.g., 'results/run-1700000000/'). 382 Visualization errors are logged but do not terminate the simulation. 383 """ 384 assert ( 385 isinstance(steps, int) and steps >= 0 386 ), "steps must be a non-negative integer" 387 388 # normalize snapshot iteration list 389 snapshot_set = set(snapshot_iters) if snapshot_iters is not None else set() 390 391 for i in range(steps): 392 self.update() 393 # Update visualization if enabled every `interval` iterations 394 if getattr(self, "_viz_on", False): 395 # iteration number is 1-based for display 396 try: 397 self._viz_update(i + 1) 398 except Exception: 399 # Log visualization errors but don't stop the simulation 400 logger.exception( 401 "Visualization update failed at iteration %d", i + 1 402 ) 403 404 # create snapshots if requested at this iteration 405 if (i + 1) in snapshot_set: 406 try: 407 # create snapshot folder if not present 408 if ( 409 not hasattr(self, "_viz_snapshot_dir") 410 or self._viz_snapshot_dir is None 411 ): 412 import os, time 413 414 base = "results" 415 ts = int(time.time()) 416 run_folder = f"run-{ts}" 417 full = os.path.join(base, run_folder) 418 os.makedirs(full, exist_ok=True) 419 self._viz_snapshot_dir = full 420 self._viz_save_snapshot(i + 1) 421 except (OSError, PermissionError): 422 logger.exception( 423 "Failed to create or write snapshot at iteration %d", i + 1 424 ) 425 426 # stop evolution at specified time-step (disable further evolution) 427 if stop_evolution_at is not None and (i + 1) == int(stop_evolution_at): 428 # mark evolution as stopped; do not erase evolve metadata so 429 # deterministic inheritance can still use parent values 430 self._evolution_stopped = True 431 432 433class PP(CA): 434 """ 435 Predator-Prey Cellular Automaton model with Numba-accelerated kernels. 436 437 This model simulates a stochastic predator-prey system where species 438 interact on a 2D grid. It supports evolving per-cell death rates, 439 periodic boundary conditions, and both random and directed hunting 440 behaviors. 441 442 Parameters 443 ---------- 444 rows : int, default 10 445 Number of rows in the simulation grid. 446 cols : int, default 10 447 Number of columns in the simulation grid. 448 densities : Tuple[float, ...], default (0.2, 0.1) 449 Initial population densities for (prey, predator). 450 neighborhood : {'moore', 'neumann'}, default 'moore' 451 The neighborhood type for cell interactions. 452 params : Dict[str, object], optional 453 Global parameters: "prey_death", "predator_death", "prey_birth", 454 "predator_birth". 455 cell_params : Dict[str, object], optional 456 Initial local parameter maps (2D arrays). 457 seed : int, optional 458 Random seed for reproducibility. 459 synchronous : bool, default True 460 If True, updates the entire grid at once. If False, updates 461 cells asynchronously. 462 directed_hunting : bool, default False 463 If True, predators selectively hunt prey rather than choosing 464 neighbors at random. 465 466 Attributes 467 ---------- 468 species_names : Tuple[str, ...] 469 Labels for the species ('prey', 'predator'). 470 synchronous : bool 471 Current update mode. 472 directed_hunting : bool 473 Current hunting strategy logic. 474 """ 475 476 # Default colors: 0=empty black, 1=prey green, 2=predator red 477 _default_cmap = ("black", "green", "red") 478 479 def __init__( 480 self, 481 rows: int = 10, 482 cols: int = 10, 483 densities: Tuple[float, ...] = (0.2, 0.1), 484 neighborhood: str = "moore", 485 params: Dict[str, object] = None, 486 cell_params: Dict[str, object] = None, 487 seed: Optional[int] = None, 488 synchronous: bool = True, 489 directed_hunting: bool = False, # New directed hunting option 490 ) -> None: 491 """ 492 Initialize the Predator-Prey CA with validated parameters and kernels. 493 """ 494 # Allowed params and defaults 495 _defaults = { 496 "prey_death": 0.05, 497 "predator_death": 0.1, 498 "prey_birth": 0.25, 499 "predator_birth": 0.2, 500 } 501 502 # Validate user-supplied params: only allowed keys 503 if params is None: 504 merged_params = dict(_defaults) 505 else: 506 if not isinstance(params, dict): 507 raise TypeError("params must be a dict or None") 508 extra = set(params.keys()) - set(_defaults.keys()) 509 if extra: 510 raise ValueError(f"Unexpected parameter keys: {sorted(list(extra))}") 511 # Do not override user-set values: start from defaults then update with user values 512 merged_params = dict(_defaults) 513 merged_params.update(params) 514 515 # Validate numerical ranges 516 for k, v in merged_params.items(): 517 if not isinstance(v, (int, float)): 518 raise TypeError(f"Parameter '{k}' must be a number between 0 and 1") 519 if not (0.0 <= float(v) <= 1.0): 520 raise ValueError(f"Parameter '{k}' must be between 0 and 1") 521 522 # Call base initializer with merged params 523 super().__init__( 524 rows, cols, densities, neighborhood, merged_params, cell_params, seed 525 ) 526 527 self.synchronous: bool = bool(synchronous) 528 self.directed_hunting: bool = bool(directed_hunting) 529 530 # set human-friendly species names for PP 531 self.species_names = ("prey", "predator") 532 533 if seed is not None: 534 # This sets the seed for all @njit functions globally 535 set_numba_seed(seed) 536 537 self._kernel = PPKernel( 538 rows, cols, neighborhood, directed_hunting=directed_hunting 539 ) 540 541 # Remove PP-specific evolve wrapper; use CA.evolve with optional species 542 543 def validate(self) -> None: 544 """ 545 Validate Predator-Prey specific invariants and spatial parameter arrays. 546 547 Extends the base CA validation to ensure that numerical parameters are 548 within the [0, 1] probability range and that evolved parameter maps 549 (e.g., prey_death) correctly align with the species locations. 550 551 Raises 552 ------ 553 ValueError 554 If grid shapes, parameter ranges, or species masks are inconsistent. 555 TypeError 556 If parameters are non-numeric. 557 """ 558 super().validate() 559 560 # Validate global params 561 for k, v in (self.params or {}).items(): 562 if not isinstance(v, (int, float)): 563 raise TypeError(f"Parameter '{k}' must be numeric") 564 if not (0.0 <= float(v) <= 1.0): 565 raise ValueError(f"Parameter '{k}' must be between 0 and 1") 566 567 # Validate per-cell evolve arrays 568 for pname, meta in (self._evolve_info or {}).items(): 569 arr = self.cell_params.get(pname) 570 if not isinstance(arr, np.ndarray): 571 # absent or non-array per-cell params are allowed; skip 572 continue 573 # shape already checked in super().validate(), but be explicit 574 if arr.shape != self.grid.shape: 575 raise ValueError(f"cell_params['{pname}'] must match grid shape") 576 # expected non-NaN positions correspond to species stored in metadata 577 species = None 578 if isinstance(meta, dict) and "species" in meta: 579 species = int(meta.get("species")) 580 else: 581 # try to infer species from parameter name using species_names 582 species = self._infer_species_from_param_name(pname) 583 if species is None: 584 raise ValueError( 585 f"cell_params['{pname}'] missing species metadata and could not infer from name" 586 ) 587 nonnan = ~np.isnan(arr) 588 expected = self.grid == species 589 if not np.array_equal(nonnan, expected): 590 raise ValueError( 591 f"cell_params['{pname}'] non-NaN entries must match positions of species {species}" 592 ) 593 # values must be within configured range where not NaN 594 mn = float(meta.get("min", 0.0)) 595 mx = float(meta.get("max", 1.0)) 596 vals = arr[~np.isnan(arr)] 597 if vals.size > 0: 598 if np.any(vals < mn) or np.any(vals > mx): 599 raise ValueError( 600 f"cell_params['{pname}'] contains values outside [{mn}, {mx}]" 601 ) 602 603 def update_async(self) -> None: 604 """ 605 Execute an asynchronous update using the optimized Numba kernel. 606 607 This method retrieves the evolved parameter maps and delegates the 608 stochastic transitions to the `PPKernel`. Asynchronous updates 609 typically handle cell-by-cell logic where changes can be 610 immediately visible to neighbors. 611 """ 612 # Get the evolved prey death map 613 # Fallback to a full array of the global param if it doesn't exist yet 614 p_death_arr = self.cell_params.get("prey_death") 615 if p_death_arr is None: 616 p_death_arr = np.full( 617 self.grid.shape, self.params["prey_death"], dtype=np.float64 618 ) 619 620 meta = self._evolve_info.get( 621 "prey_death", {"sd": 0.05, "min": 0.001, "max": 0.1} 622 ) 623 624 # Call the optimized kernel (uses pre-allocated buffers) 625 self._kernel.update( 626 self.grid, 627 p_death_arr, 628 float(self.params["prey_birth"]), 629 float(self.params["prey_death"]), 630 float(self.params["predator_birth"]), 631 float(self.params["predator_death"]), 632 float(meta["sd"]), 633 float(meta["min"]), 634 float(meta["max"]), 635 self._evolution_stopped, 636 ) 637 638 def update(self) -> None: 639 """ 640 Dispatch the simulation step based on the configured update mode. 641 """ 642 self.update_async()
51class CA: 52 """ 53 Base cellular automaton class for spatial simulations. 54 55 This class provides a framework for multi-species cellular automata with 56 support for global parameters, per-cell evolving parameters, and 57 grid initialization based on density. 58 59 Attributes 60 ---------- 61 grid : np.ndarray 62 2D numpy array containing integers in range [0, n_species]. 63 params : Dict[str, Any] 64 Global parameters shared by all cells. 65 cell_params : Dict[str, Any] 66 Local per-cell parameters, typically stored as numpy arrays matching the grid shape. 67 neighborhood : str 68 The adjacency rule used ('neumann' or 'moore'). 69 generator : np.random.Generator 70 The random number generator instance for reproducibility. 71 species_names : Tuple[str, ...] 72 Human-readable names for each species state. 73 """ 74 75 # Default colormap spec (string or sequence); resolved in `visualize` at runtime 76 _default_cmap = "viridis" 77 78 # Read-only accessors for size/densities (protected attributes set in __init__) 79 @property 80 def rows(self) -> int: 81 """int: Number of rows in the grid.""" 82 return getattr(self, "_rows") 83 84 @property 85 def cols(self) -> int: 86 """int: Number of columns in the grid.""" 87 return getattr(self, "_cols") 88 89 @property 90 def densities(self) -> Tuple[float, ...]: 91 """Tuple[float, ...]: Initial density fraction for each species.""" 92 return tuple(getattr(self, "_densities")) 93 94 # make n_species protected with read-only property 95 @property 96 def n_species(self) -> int: 97 """int: Number of distinct species states (excluding empty state 0).""" 98 return int(getattr(self, "_n_species")) 99 100 def __init__( 101 self, 102 rows: int, 103 cols: int, 104 densities: Tuple[float, ...], 105 neighborhood: str, 106 params: Dict[str, object], 107 cell_params: Dict[str, object], 108 seed: Optional[int] = None, 109 ) -> None: 110 """ 111 Initialize the cellular automaton grid and configurations. 112 113 Parameters 114 ---------- 115 rows : int 116 Number of rows in the grid (must be > 0). 117 cols : int 118 Number of columns in the grid (must be > 0). 119 densities : Tuple[float, ...] 120 Initial density for each species. Length defines `n_species`. 121 Values must sum to <= 1.0. 122 neighborhood : {'neumann', 'moore'} 123 Type of neighborhood connectivity. 124 params : Dict[str, Any] 125 Initial global parameter values. 126 cell_params : Dict[str, Any] 127 Initial local per-cell parameters. 128 seed : int, optional 129 Seed for the random number generator. 130 """ 131 assert isinstance(rows, int) and rows > 0, "rows must be positive int" 132 assert isinstance(cols, int) and cols > 0, "cols must be positive int" 133 assert ( 134 isinstance(densities, tuple) and len(densities) > 0 135 ), "densities must be a non-empty tuple" 136 for d in densities: 137 assert ( 138 isinstance(d, (float, int)) and d >= 0 139 ), "each density must be non-negative" 140 total_density = float(sum(densities)) 141 assert total_density <= 1.0 + 1e-12, "sum of densities must not exceed 1" 142 assert neighborhood in ( 143 "neumann", 144 "moore", 145 ), "neighborhood must be 'neumann' or 'moore'" 146 147 self._n_species: int = len(densities) 148 # store protected size/density attributes (read-only properties exposed) 149 self._rows: int = rows 150 self._cols: int = cols 151 self._densities: Tuple[float, ...] = tuple(densities) 152 self.params: Dict[str, object] = dict(params) if params is not None else {} 153 self.cell_params: Dict[str, object] = ( 154 dict(cell_params) if cell_params is not None else {} 155 ) 156 157 # per-parameter evolve metadata and evolution state 158 # maps parameter name -> dict with keys 'sd','min','max','species' 159 self._evolve_info: Dict[str, Dict[str, float]] = {} 160 # when True, inheritance uses deterministic copy from parent (no mutation) 161 self._evolution_stopped: bool = False 162 163 # human-readable species names (useful for visualization). Default 164 # generates generic names based on n_species; subclasses may override. 165 self.species_names: Tuple[str, ...] = tuple( 166 f"species{i+1}" for i in range(self._n_species) 167 ) 168 self.neighborhood: str = neighborhood 169 self.generator: np.random.Generator = np.random.default_rng(seed) 170 171 self.grid: np.ndarray = np.zeros((rows, cols), dtype=int) 172 173 total_cells = rows * cols 174 # Fill grid with species states 1..n_species according to densities. 175 for i, dens in enumerate(densities): 176 if dens <= 0: 177 continue 178 n_to_fill = int(round(total_cells * float(dens))) 179 if n_to_fill <= 0: 180 continue 181 empty_flat = np.flatnonzero(self.grid.ravel() == 0) 182 if len(empty_flat) == 0: 183 break 184 n_choice = min(n_to_fill, len(empty_flat)) 185 chosen = self.generator.choice(empty_flat, size=n_choice, replace=False) 186 # assign chosen flattened indices to state i+1 187 r = chosen // cols 188 c = chosen % cols 189 self.grid[r, c] = i + 1 190 191 def validate(self) -> None: 192 """ 193 Validate core CA invariants and grid dimensions. 194 195 Checks that the neighborhood is valid, the grid matches initialized dimensions, 196 and that local parameter arrays match the grid shape. 197 198 Raises 199 ------ 200 ValueError 201 If any structural invariant is violated. 202 """ 203 if self.neighborhood not in ("neumann", "moore"): 204 raise ValueError("neighborhood must be 'neumann' or 'moore'") 205 206 expected_shape = (int(getattr(self, "_rows")), int(getattr(self, "_cols"))) 207 if self.grid.shape != expected_shape: 208 raise ValueError( 209 f"grid shape {self.grid.shape} does not match expected {expected_shape}" 210 ) 211 212 # Ensure any array in cell_params matches grid shape 213 for k, v in (self.cell_params or {}).items(): 214 if isinstance(v, np.ndarray) and v.shape != expected_shape: 215 raise ValueError(f"cell_params['{k}'] must have shape equal to grid") 216 217 def _infer_species_from_param_name(self, param_name: str) -> Optional[int]: 218 """ 219 Infer the 1-based species index from a parameter name using `species_names`. 220 221 This method checks if the given parameter name starts with any of the 222 defined species names followed by an underscore (e.g., 'prey_birth'). 223 It is used to automatically route global parameters to the correct 224 species' local parameter arrays. 225 226 Parameters 227 ---------- 228 param_name : str 229 The name of the parameter to check. 230 231 Returns 232 ------- 233 Optional[int] 234 The 1-based index of the species if a matching prefix is found; 235 otherwise, None. 236 237 Notes 238 ----- 239 The method expects `self.species_names` to be a collection of strings. 240 If `param_name` is not a string or no match is found, it returns None. 241 """ 242 if not isinstance(param_name, str): 243 return None 244 for idx, name in enumerate(self.species_names or ()): # type: ignore 245 if isinstance(name, str) and param_name.startswith(f"{name}_"): 246 return idx + 1 247 return None 248 249 def evolve( 250 self, 251 param: str, 252 species: Optional[int] = None, 253 sd: float = 0.05, 254 min_val: Optional[float] = None, 255 max_val: Optional[float] = None, 256 ) -> None: 257 """ 258 Enable per-cell evolution for a specific parameter on a given species. 259 260 This method initializes a spatial parameter array (local parameter map) 261 for a global parameter. It allows individual cells to carry their own 262 values for that parameter, which can then mutate and evolve during 263 the simulation. 264 265 Parameters 266 ---------- 267 param : str 268 The name of the global parameter to enable for evolution. 269 Must exist in `self.params`. 270 species : int, optional 271 The 1-based index of the species to which this parameter applies. 272 If None, the method attempts to infer the species from the 273 parameter name prefix. 274 sd : float, default 0.05 275 The standard deviation of the Gaussian mutation applied during 276 inheritance/reproduction. 277 min_val : float, optional 278 The minimum allowable value for the parameter (clamping). 279 Defaults to 0.01 if not provided. 280 max_val : float, optional 281 The maximum allowable value for the parameter (clamping). 282 Defaults to 0.99 if not provided. 283 284 Raises 285 ------ 286 ValueError 287 If the parameter is not in `self.params`, the species cannot be 288 inferred, or the species index is out of bounds. 289 290 Notes 291 ----- 292 The local parameter is stored in `self.cell_params` as a 2D numpy 293 array initialized with the current global value for all cells of 294 the target species, and `NaN` elsewhere. 295 """ 296 if min_val is None: 297 min_val = 0.01 298 if max_val is None: 299 max_val = 0.99 300 if param not in self.params: 301 raise ValueError(f"Unknown parameter '{param}'") 302 if species is None: 303 species = self._infer_species_from_param_name(param) 304 if species is None: 305 raise ValueError( 306 "species must be provided or inferable from param name and species_names" 307 ) 308 if not isinstance(species, int) or species <= 0 or species > self._n_species: 309 raise ValueError("species must be an integer between 1 and n_species") 310 311 arr = np.full(self.grid.shape, np.nan, dtype=float) 312 mask = self.grid == int(species) 313 arr[mask] = float(self.params[param]) 314 self.cell_params[param] = arr 315 self._evolve_info[param] = { 316 "sd": float(sd), 317 "min": float(min_val), 318 "max": float(max_val), 319 "species": int(species), 320 } 321 322 def update(self) -> None: 323 """ 324 Perform one update step of the cellular automaton. 325 326 This is an abstract method that defines the transition rules of the 327 system. It must be implemented by concrete subclasses to specify 328 how cell states and parameters change over time based on their 329 current state and neighborhood. 330 331 Raises 332 ------ 333 NotImplementedError 334 If called directly on the base class instead of an implementation. 335 336 Returns 337 ------- 338 None 339 340 Notes 341 ----- 342 In a typical implementation, this method handles the logic for 343 stochastic transitions, movement, or predator-prey interactions. 344 """ 345 raise NotImplementedError( 346 "Override update() in a subclass to define CA dynamics" 347 ) 348 349 def run( 350 self, 351 steps: int, 352 stop_evolution_at: Optional[int] = None, 353 snapshot_iters: Optional[list] = None, 354 ) -> None: 355 """ 356 Execute the cellular automaton simulation for a specified number of steps. 357 358 This method drives the simulation loop, calling `update()` at each 359 iteration. It manages visualization updates, directory creation for 360 data persistence, and handles the freezing of evolving parameters 361 at a specific time step. 362 363 Parameters 364 ---------- 365 steps : int 366 The total number of iterations to run (must be non-negative). 367 stop_evolution_at : int, optional 368 The 1-based iteration index after which parameter mutation is 369 disabled. Useful for observing system stability after a period 370 of adaptation. 371 snapshot_iters : List[int], optional 372 A list of specific 1-based iteration indices at which to save 373 the current grid state to the results directory. 374 375 Returns 376 ------- 377 None 378 379 Notes 380 ----- 381 If snapshots are requested, a results directory is automatically created 382 using a timestamped subfolder (e.g., 'results/run-1700000000/'). 383 Visualization errors are logged but do not terminate the simulation. 384 """ 385 assert ( 386 isinstance(steps, int) and steps >= 0 387 ), "steps must be a non-negative integer" 388 389 # normalize snapshot iteration list 390 snapshot_set = set(snapshot_iters) if snapshot_iters is not None else set() 391 392 for i in range(steps): 393 self.update() 394 # Update visualization if enabled every `interval` iterations 395 if getattr(self, "_viz_on", False): 396 # iteration number is 1-based for display 397 try: 398 self._viz_update(i + 1) 399 except Exception: 400 # Log visualization errors but don't stop the simulation 401 logger.exception( 402 "Visualization update failed at iteration %d", i + 1 403 ) 404 405 # create snapshots if requested at this iteration 406 if (i + 1) in snapshot_set: 407 try: 408 # create snapshot folder if not present 409 if ( 410 not hasattr(self, "_viz_snapshot_dir") 411 or self._viz_snapshot_dir is None 412 ): 413 import os, time 414 415 base = "results" 416 ts = int(time.time()) 417 run_folder = f"run-{ts}" 418 full = os.path.join(base, run_folder) 419 os.makedirs(full, exist_ok=True) 420 self._viz_snapshot_dir = full 421 self._viz_save_snapshot(i + 1) 422 except (OSError, PermissionError): 423 logger.exception( 424 "Failed to create or write snapshot at iteration %d", i + 1 425 ) 426 427 # stop evolution at specified time-step (disable further evolution) 428 if stop_evolution_at is not None and (i + 1) == int(stop_evolution_at): 429 # mark evolution as stopped; do not erase evolve metadata so 430 # deterministic inheritance can still use parent values 431 self._evolution_stopped = True
Base cellular automaton class for spatial simulations.
This class provides a framework for multi-species cellular automata with support for global parameters, per-cell evolving parameters, and grid initialization based on density.
Attributes
- grid (np.ndarray): 2D numpy array containing integers in range [0, n_species].
- params (Dict[str, Any]): Global parameters shared by all cells.
- cell_params (Dict[str, Any]): Local per-cell parameters, typically stored as numpy arrays matching the grid shape.
- neighborhood (str): The adjacency rule used ('neumann' or 'moore').
- generator (np.random.Generator): The random number generator instance for reproducibility.
- species_names (Tuple[str, ...]): Human-readable names for each species state.
100 def __init__( 101 self, 102 rows: int, 103 cols: int, 104 densities: Tuple[float, ...], 105 neighborhood: str, 106 params: Dict[str, object], 107 cell_params: Dict[str, object], 108 seed: Optional[int] = None, 109 ) -> None: 110 """ 111 Initialize the cellular automaton grid and configurations. 112 113 Parameters 114 ---------- 115 rows : int 116 Number of rows in the grid (must be > 0). 117 cols : int 118 Number of columns in the grid (must be > 0). 119 densities : Tuple[float, ...] 120 Initial density for each species. Length defines `n_species`. 121 Values must sum to <= 1.0. 122 neighborhood : {'neumann', 'moore'} 123 Type of neighborhood connectivity. 124 params : Dict[str, Any] 125 Initial global parameter values. 126 cell_params : Dict[str, Any] 127 Initial local per-cell parameters. 128 seed : int, optional 129 Seed for the random number generator. 130 """ 131 assert isinstance(rows, int) and rows > 0, "rows must be positive int" 132 assert isinstance(cols, int) and cols > 0, "cols must be positive int" 133 assert ( 134 isinstance(densities, tuple) and len(densities) > 0 135 ), "densities must be a non-empty tuple" 136 for d in densities: 137 assert ( 138 isinstance(d, (float, int)) and d >= 0 139 ), "each density must be non-negative" 140 total_density = float(sum(densities)) 141 assert total_density <= 1.0 + 1e-12, "sum of densities must not exceed 1" 142 assert neighborhood in ( 143 "neumann", 144 "moore", 145 ), "neighborhood must be 'neumann' or 'moore'" 146 147 self._n_species: int = len(densities) 148 # store protected size/density attributes (read-only properties exposed) 149 self._rows: int = rows 150 self._cols: int = cols 151 self._densities: Tuple[float, ...] = tuple(densities) 152 self.params: Dict[str, object] = dict(params) if params is not None else {} 153 self.cell_params: Dict[str, object] = ( 154 dict(cell_params) if cell_params is not None else {} 155 ) 156 157 # per-parameter evolve metadata and evolution state 158 # maps parameter name -> dict with keys 'sd','min','max','species' 159 self._evolve_info: Dict[str, Dict[str, float]] = {} 160 # when True, inheritance uses deterministic copy from parent (no mutation) 161 self._evolution_stopped: bool = False 162 163 # human-readable species names (useful for visualization). Default 164 # generates generic names based on n_species; subclasses may override. 165 self.species_names: Tuple[str, ...] = tuple( 166 f"species{i+1}" for i in range(self._n_species) 167 ) 168 self.neighborhood: str = neighborhood 169 self.generator: np.random.Generator = np.random.default_rng(seed) 170 171 self.grid: np.ndarray = np.zeros((rows, cols), dtype=int) 172 173 total_cells = rows * cols 174 # Fill grid with species states 1..n_species according to densities. 175 for i, dens in enumerate(densities): 176 if dens <= 0: 177 continue 178 n_to_fill = int(round(total_cells * float(dens))) 179 if n_to_fill <= 0: 180 continue 181 empty_flat = np.flatnonzero(self.grid.ravel() == 0) 182 if len(empty_flat) == 0: 183 break 184 n_choice = min(n_to_fill, len(empty_flat)) 185 chosen = self.generator.choice(empty_flat, size=n_choice, replace=False) 186 # assign chosen flattened indices to state i+1 187 r = chosen // cols 188 c = chosen % cols 189 self.grid[r, c] = i + 1
Initialize the cellular automaton grid and configurations.
Parameters
- rows (int): Number of rows in the grid (must be > 0).
- cols (int): Number of columns in the grid (must be > 0).
- densities (Tuple[float, ...]):
Initial density for each species. Length defines
n_species. Values must sum to <= 1.0. - neighborhood ({'neumann', 'moore'}): Type of neighborhood connectivity.
- params (Dict[str, Any]): Initial global parameter values.
- cell_params (Dict[str, Any]): Initial local per-cell parameters.
- seed (int, optional): Seed for the random number generator.
79 @property 80 def rows(self) -> int: 81 """int: Number of rows in the grid.""" 82 return getattr(self, "_rows")
int: Number of rows in the grid.
84 @property 85 def cols(self) -> int: 86 """int: Number of columns in the grid.""" 87 return getattr(self, "_cols")
int: Number of columns in the grid.
89 @property 90 def densities(self) -> Tuple[float, ...]: 91 """Tuple[float, ...]: Initial density fraction for each species.""" 92 return tuple(getattr(self, "_densities"))
Tuple[float, ...]: Initial density fraction for each species.
95 @property 96 def n_species(self) -> int: 97 """int: Number of distinct species states (excluding empty state 0).""" 98 return int(getattr(self, "_n_species"))
int: Number of distinct species states (excluding empty state 0).
191 def validate(self) -> None: 192 """ 193 Validate core CA invariants and grid dimensions. 194 195 Checks that the neighborhood is valid, the grid matches initialized dimensions, 196 and that local parameter arrays match the grid shape. 197 198 Raises 199 ------ 200 ValueError 201 If any structural invariant is violated. 202 """ 203 if self.neighborhood not in ("neumann", "moore"): 204 raise ValueError("neighborhood must be 'neumann' or 'moore'") 205 206 expected_shape = (int(getattr(self, "_rows")), int(getattr(self, "_cols"))) 207 if self.grid.shape != expected_shape: 208 raise ValueError( 209 f"grid shape {self.grid.shape} does not match expected {expected_shape}" 210 ) 211 212 # Ensure any array in cell_params matches grid shape 213 for k, v in (self.cell_params or {}).items(): 214 if isinstance(v, np.ndarray) and v.shape != expected_shape: 215 raise ValueError(f"cell_params['{k}'] must have shape equal to grid")
Validate core CA invariants and grid dimensions.
Checks that the neighborhood is valid, the grid matches initialized dimensions, and that local parameter arrays match the grid shape.
Raises
- ValueError: If any structural invariant is violated.
249 def evolve( 250 self, 251 param: str, 252 species: Optional[int] = None, 253 sd: float = 0.05, 254 min_val: Optional[float] = None, 255 max_val: Optional[float] = None, 256 ) -> None: 257 """ 258 Enable per-cell evolution for a specific parameter on a given species. 259 260 This method initializes a spatial parameter array (local parameter map) 261 for a global parameter. It allows individual cells to carry their own 262 values for that parameter, which can then mutate and evolve during 263 the simulation. 264 265 Parameters 266 ---------- 267 param : str 268 The name of the global parameter to enable for evolution. 269 Must exist in `self.params`. 270 species : int, optional 271 The 1-based index of the species to which this parameter applies. 272 If None, the method attempts to infer the species from the 273 parameter name prefix. 274 sd : float, default 0.05 275 The standard deviation of the Gaussian mutation applied during 276 inheritance/reproduction. 277 min_val : float, optional 278 The minimum allowable value for the parameter (clamping). 279 Defaults to 0.01 if not provided. 280 max_val : float, optional 281 The maximum allowable value for the parameter (clamping). 282 Defaults to 0.99 if not provided. 283 284 Raises 285 ------ 286 ValueError 287 If the parameter is not in `self.params`, the species cannot be 288 inferred, or the species index is out of bounds. 289 290 Notes 291 ----- 292 The local parameter is stored in `self.cell_params` as a 2D numpy 293 array initialized with the current global value for all cells of 294 the target species, and `NaN` elsewhere. 295 """ 296 if min_val is None: 297 min_val = 0.01 298 if max_val is None: 299 max_val = 0.99 300 if param not in self.params: 301 raise ValueError(f"Unknown parameter '{param}'") 302 if species is None: 303 species = self._infer_species_from_param_name(param) 304 if species is None: 305 raise ValueError( 306 "species must be provided or inferable from param name and species_names" 307 ) 308 if not isinstance(species, int) or species <= 0 or species > self._n_species: 309 raise ValueError("species must be an integer between 1 and n_species") 310 311 arr = np.full(self.grid.shape, np.nan, dtype=float) 312 mask = self.grid == int(species) 313 arr[mask] = float(self.params[param]) 314 self.cell_params[param] = arr 315 self._evolve_info[param] = { 316 "sd": float(sd), 317 "min": float(min_val), 318 "max": float(max_val), 319 "species": int(species), 320 }
Enable per-cell evolution for a specific parameter on a given species.
This method initializes a spatial parameter array (local parameter map) for a global parameter. It allows individual cells to carry their own values for that parameter, which can then mutate and evolve during the simulation.
Parameters
- param (str):
The name of the global parameter to enable for evolution.
Must exist in
self.params. - species (int, optional): The 1-based index of the species to which this parameter applies. If None, the method attempts to infer the species from the parameter name prefix.
- sd (float, default 0.05): The standard deviation of the Gaussian mutation applied during inheritance/reproduction.
- min_val (float, optional): The minimum allowable value for the parameter (clamping). Defaults to 0.01 if not provided.
- max_val (float, optional): The maximum allowable value for the parameter (clamping). Defaults to 0.99 if not provided.
Raises
- ValueError: If the parameter is not in
self.params, the species cannot be inferred, or the species index is out of bounds.
Notes
The local parameter is stored in self.cell_params as a 2D numpy
array initialized with the current global value for all cells of
the target species, and NaN elsewhere.
322 def update(self) -> None: 323 """ 324 Perform one update step of the cellular automaton. 325 326 This is an abstract method that defines the transition rules of the 327 system. It must be implemented by concrete subclasses to specify 328 how cell states and parameters change over time based on their 329 current state and neighborhood. 330 331 Raises 332 ------ 333 NotImplementedError 334 If called directly on the base class instead of an implementation. 335 336 Returns 337 ------- 338 None 339 340 Notes 341 ----- 342 In a typical implementation, this method handles the logic for 343 stochastic transitions, movement, or predator-prey interactions. 344 """ 345 raise NotImplementedError( 346 "Override update() in a subclass to define CA dynamics" 347 )
Perform one update step of the cellular automaton.
This is an abstract method that defines the transition rules of the system. It must be implemented by concrete subclasses to specify how cell states and parameters change over time based on their current state and neighborhood.
Raises
- NotImplementedError: If called directly on the base class instead of an implementation.
Returns
- None
Notes
In a typical implementation, this method handles the logic for stochastic transitions, movement, or predator-prey interactions.
349 def run( 350 self, 351 steps: int, 352 stop_evolution_at: Optional[int] = None, 353 snapshot_iters: Optional[list] = None, 354 ) -> None: 355 """ 356 Execute the cellular automaton simulation for a specified number of steps. 357 358 This method drives the simulation loop, calling `update()` at each 359 iteration. It manages visualization updates, directory creation for 360 data persistence, and handles the freezing of evolving parameters 361 at a specific time step. 362 363 Parameters 364 ---------- 365 steps : int 366 The total number of iterations to run (must be non-negative). 367 stop_evolution_at : int, optional 368 The 1-based iteration index after which parameter mutation is 369 disabled. Useful for observing system stability after a period 370 of adaptation. 371 snapshot_iters : List[int], optional 372 A list of specific 1-based iteration indices at which to save 373 the current grid state to the results directory. 374 375 Returns 376 ------- 377 None 378 379 Notes 380 ----- 381 If snapshots are requested, a results directory is automatically created 382 using a timestamped subfolder (e.g., 'results/run-1700000000/'). 383 Visualization errors are logged but do not terminate the simulation. 384 """ 385 assert ( 386 isinstance(steps, int) and steps >= 0 387 ), "steps must be a non-negative integer" 388 389 # normalize snapshot iteration list 390 snapshot_set = set(snapshot_iters) if snapshot_iters is not None else set() 391 392 for i in range(steps): 393 self.update() 394 # Update visualization if enabled every `interval` iterations 395 if getattr(self, "_viz_on", False): 396 # iteration number is 1-based for display 397 try: 398 self._viz_update(i + 1) 399 except Exception: 400 # Log visualization errors but don't stop the simulation 401 logger.exception( 402 "Visualization update failed at iteration %d", i + 1 403 ) 404 405 # create snapshots if requested at this iteration 406 if (i + 1) in snapshot_set: 407 try: 408 # create snapshot folder if not present 409 if ( 410 not hasattr(self, "_viz_snapshot_dir") 411 or self._viz_snapshot_dir is None 412 ): 413 import os, time 414 415 base = "results" 416 ts = int(time.time()) 417 run_folder = f"run-{ts}" 418 full = os.path.join(base, run_folder) 419 os.makedirs(full, exist_ok=True) 420 self._viz_snapshot_dir = full 421 self._viz_save_snapshot(i + 1) 422 except (OSError, PermissionError): 423 logger.exception( 424 "Failed to create or write snapshot at iteration %d", i + 1 425 ) 426 427 # stop evolution at specified time-step (disable further evolution) 428 if stop_evolution_at is not None and (i + 1) == int(stop_evolution_at): 429 # mark evolution as stopped; do not erase evolve metadata so 430 # deterministic inheritance can still use parent values 431 self._evolution_stopped = True
Execute the cellular automaton simulation for a specified number of steps.
This method drives the simulation loop, calling update() at each
iteration. It manages visualization updates, directory creation for
data persistence, and handles the freezing of evolving parameters
at a specific time step.
Parameters
- steps (int): The total number of iterations to run (must be non-negative).
- stop_evolution_at (int, optional): The 1-based iteration index after which parameter mutation is disabled. Useful for observing system stability after a period of adaptation.
- snapshot_iters (List[int], optional): A list of specific 1-based iteration indices at which to save the current grid state to the results directory.
Returns
- None
Notes
If snapshots are requested, a results directory is automatically created using a timestamped subfolder (e.g., 'results/run-1700000000/'). Visualization errors are logged but do not terminate the simulation.
434class PP(CA): 435 """ 436 Predator-Prey Cellular Automaton model with Numba-accelerated kernels. 437 438 This model simulates a stochastic predator-prey system where species 439 interact on a 2D grid. It supports evolving per-cell death rates, 440 periodic boundary conditions, and both random and directed hunting 441 behaviors. 442 443 Parameters 444 ---------- 445 rows : int, default 10 446 Number of rows in the simulation grid. 447 cols : int, default 10 448 Number of columns in the simulation grid. 449 densities : Tuple[float, ...], default (0.2, 0.1) 450 Initial population densities for (prey, predator). 451 neighborhood : {'moore', 'neumann'}, default 'moore' 452 The neighborhood type for cell interactions. 453 params : Dict[str, object], optional 454 Global parameters: "prey_death", "predator_death", "prey_birth", 455 "predator_birth". 456 cell_params : Dict[str, object], optional 457 Initial local parameter maps (2D arrays). 458 seed : int, optional 459 Random seed for reproducibility. 460 synchronous : bool, default True 461 If True, updates the entire grid at once. If False, updates 462 cells asynchronously. 463 directed_hunting : bool, default False 464 If True, predators selectively hunt prey rather than choosing 465 neighbors at random. 466 467 Attributes 468 ---------- 469 species_names : Tuple[str, ...] 470 Labels for the species ('prey', 'predator'). 471 synchronous : bool 472 Current update mode. 473 directed_hunting : bool 474 Current hunting strategy logic. 475 """ 476 477 # Default colors: 0=empty black, 1=prey green, 2=predator red 478 _default_cmap = ("black", "green", "red") 479 480 def __init__( 481 self, 482 rows: int = 10, 483 cols: int = 10, 484 densities: Tuple[float, ...] = (0.2, 0.1), 485 neighborhood: str = "moore", 486 params: Dict[str, object] = None, 487 cell_params: Dict[str, object] = None, 488 seed: Optional[int] = None, 489 synchronous: bool = True, 490 directed_hunting: bool = False, # New directed hunting option 491 ) -> None: 492 """ 493 Initialize the Predator-Prey CA with validated parameters and kernels. 494 """ 495 # Allowed params and defaults 496 _defaults = { 497 "prey_death": 0.05, 498 "predator_death": 0.1, 499 "prey_birth": 0.25, 500 "predator_birth": 0.2, 501 } 502 503 # Validate user-supplied params: only allowed keys 504 if params is None: 505 merged_params = dict(_defaults) 506 else: 507 if not isinstance(params, dict): 508 raise TypeError("params must be a dict or None") 509 extra = set(params.keys()) - set(_defaults.keys()) 510 if extra: 511 raise ValueError(f"Unexpected parameter keys: {sorted(list(extra))}") 512 # Do not override user-set values: start from defaults then update with user values 513 merged_params = dict(_defaults) 514 merged_params.update(params) 515 516 # Validate numerical ranges 517 for k, v in merged_params.items(): 518 if not isinstance(v, (int, float)): 519 raise TypeError(f"Parameter '{k}' must be a number between 0 and 1") 520 if not (0.0 <= float(v) <= 1.0): 521 raise ValueError(f"Parameter '{k}' must be between 0 and 1") 522 523 # Call base initializer with merged params 524 super().__init__( 525 rows, cols, densities, neighborhood, merged_params, cell_params, seed 526 ) 527 528 self.synchronous: bool = bool(synchronous) 529 self.directed_hunting: bool = bool(directed_hunting) 530 531 # set human-friendly species names for PP 532 self.species_names = ("prey", "predator") 533 534 if seed is not None: 535 # This sets the seed for all @njit functions globally 536 set_numba_seed(seed) 537 538 self._kernel = PPKernel( 539 rows, cols, neighborhood, directed_hunting=directed_hunting 540 ) 541 542 # Remove PP-specific evolve wrapper; use CA.evolve with optional species 543 544 def validate(self) -> None: 545 """ 546 Validate Predator-Prey specific invariants and spatial parameter arrays. 547 548 Extends the base CA validation to ensure that numerical parameters are 549 within the [0, 1] probability range and that evolved parameter maps 550 (e.g., prey_death) correctly align with the species locations. 551 552 Raises 553 ------ 554 ValueError 555 If grid shapes, parameter ranges, or species masks are inconsistent. 556 TypeError 557 If parameters are non-numeric. 558 """ 559 super().validate() 560 561 # Validate global params 562 for k, v in (self.params or {}).items(): 563 if not isinstance(v, (int, float)): 564 raise TypeError(f"Parameter '{k}' must be numeric") 565 if not (0.0 <= float(v) <= 1.0): 566 raise ValueError(f"Parameter '{k}' must be between 0 and 1") 567 568 # Validate per-cell evolve arrays 569 for pname, meta in (self._evolve_info or {}).items(): 570 arr = self.cell_params.get(pname) 571 if not isinstance(arr, np.ndarray): 572 # absent or non-array per-cell params are allowed; skip 573 continue 574 # shape already checked in super().validate(), but be explicit 575 if arr.shape != self.grid.shape: 576 raise ValueError(f"cell_params['{pname}'] must match grid shape") 577 # expected non-NaN positions correspond to species stored in metadata 578 species = None 579 if isinstance(meta, dict) and "species" in meta: 580 species = int(meta.get("species")) 581 else: 582 # try to infer species from parameter name using species_names 583 species = self._infer_species_from_param_name(pname) 584 if species is None: 585 raise ValueError( 586 f"cell_params['{pname}'] missing species metadata and could not infer from name" 587 ) 588 nonnan = ~np.isnan(arr) 589 expected = self.grid == species 590 if not np.array_equal(nonnan, expected): 591 raise ValueError( 592 f"cell_params['{pname}'] non-NaN entries must match positions of species {species}" 593 ) 594 # values must be within configured range where not NaN 595 mn = float(meta.get("min", 0.0)) 596 mx = float(meta.get("max", 1.0)) 597 vals = arr[~np.isnan(arr)] 598 if vals.size > 0: 599 if np.any(vals < mn) or np.any(vals > mx): 600 raise ValueError( 601 f"cell_params['{pname}'] contains values outside [{mn}, {mx}]" 602 ) 603 604 def update_async(self) -> None: 605 """ 606 Execute an asynchronous update using the optimized Numba kernel. 607 608 This method retrieves the evolved parameter maps and delegates the 609 stochastic transitions to the `PPKernel`. Asynchronous updates 610 typically handle cell-by-cell logic where changes can be 611 immediately visible to neighbors. 612 """ 613 # Get the evolved prey death map 614 # Fallback to a full array of the global param if it doesn't exist yet 615 p_death_arr = self.cell_params.get("prey_death") 616 if p_death_arr is None: 617 p_death_arr = np.full( 618 self.grid.shape, self.params["prey_death"], dtype=np.float64 619 ) 620 621 meta = self._evolve_info.get( 622 "prey_death", {"sd": 0.05, "min": 0.001, "max": 0.1} 623 ) 624 625 # Call the optimized kernel (uses pre-allocated buffers) 626 self._kernel.update( 627 self.grid, 628 p_death_arr, 629 float(self.params["prey_birth"]), 630 float(self.params["prey_death"]), 631 float(self.params["predator_birth"]), 632 float(self.params["predator_death"]), 633 float(meta["sd"]), 634 float(meta["min"]), 635 float(meta["max"]), 636 self._evolution_stopped, 637 ) 638 639 def update(self) -> None: 640 """ 641 Dispatch the simulation step based on the configured update mode. 642 """ 643 self.update_async()
Predator-Prey Cellular Automaton model with Numba-accelerated kernels.
This model simulates a stochastic predator-prey system where species interact on a 2D grid. It supports evolving per-cell death rates, periodic boundary conditions, and both random and directed hunting behaviors.
Parameters
- rows (int, default 10): Number of rows in the simulation grid.
- cols (int, default 10): Number of columns in the simulation grid.
- densities (Tuple[float, ...], default (0.2, 0.1)): Initial population densities for (prey, predator).
- neighborhood ({'moore', 'neumann'}, default 'moore'): The neighborhood type for cell interactions.
- params (Dict[str, object], optional): Global parameters: "prey_death", "predator_death", "prey_birth", "predator_birth".
- cell_params (Dict[str, object], optional): Initial local parameter maps (2D arrays).
- seed (int, optional): Random seed for reproducibility.
- synchronous (bool, default True): If True, updates the entire grid at once. If False, updates cells asynchronously.
- directed_hunting (bool, default False): If True, predators selectively hunt prey rather than choosing neighbors at random.
Attributes
- species_names (Tuple[str, ...]): Labels for the species ('prey', 'predator').
- synchronous (bool): Current update mode.
- directed_hunting (bool): Current hunting strategy logic.
480 def __init__( 481 self, 482 rows: int = 10, 483 cols: int = 10, 484 densities: Tuple[float, ...] = (0.2, 0.1), 485 neighborhood: str = "moore", 486 params: Dict[str, object] = None, 487 cell_params: Dict[str, object] = None, 488 seed: Optional[int] = None, 489 synchronous: bool = True, 490 directed_hunting: bool = False, # New directed hunting option 491 ) -> None: 492 """ 493 Initialize the Predator-Prey CA with validated parameters and kernels. 494 """ 495 # Allowed params and defaults 496 _defaults = { 497 "prey_death": 0.05, 498 "predator_death": 0.1, 499 "prey_birth": 0.25, 500 "predator_birth": 0.2, 501 } 502 503 # Validate user-supplied params: only allowed keys 504 if params is None: 505 merged_params = dict(_defaults) 506 else: 507 if not isinstance(params, dict): 508 raise TypeError("params must be a dict or None") 509 extra = set(params.keys()) - set(_defaults.keys()) 510 if extra: 511 raise ValueError(f"Unexpected parameter keys: {sorted(list(extra))}") 512 # Do not override user-set values: start from defaults then update with user values 513 merged_params = dict(_defaults) 514 merged_params.update(params) 515 516 # Validate numerical ranges 517 for k, v in merged_params.items(): 518 if not isinstance(v, (int, float)): 519 raise TypeError(f"Parameter '{k}' must be a number between 0 and 1") 520 if not (0.0 <= float(v) <= 1.0): 521 raise ValueError(f"Parameter '{k}' must be between 0 and 1") 522 523 # Call base initializer with merged params 524 super().__init__( 525 rows, cols, densities, neighborhood, merged_params, cell_params, seed 526 ) 527 528 self.synchronous: bool = bool(synchronous) 529 self.directed_hunting: bool = bool(directed_hunting) 530 531 # set human-friendly species names for PP 532 self.species_names = ("prey", "predator") 533 534 if seed is not None: 535 # This sets the seed for all @njit functions globally 536 set_numba_seed(seed) 537 538 self._kernel = PPKernel( 539 rows, cols, neighborhood, directed_hunting=directed_hunting 540 )
Initialize the Predator-Prey CA with validated parameters and kernels.
544 def validate(self) -> None: 545 """ 546 Validate Predator-Prey specific invariants and spatial parameter arrays. 547 548 Extends the base CA validation to ensure that numerical parameters are 549 within the [0, 1] probability range and that evolved parameter maps 550 (e.g., prey_death) correctly align with the species locations. 551 552 Raises 553 ------ 554 ValueError 555 If grid shapes, parameter ranges, or species masks are inconsistent. 556 TypeError 557 If parameters are non-numeric. 558 """ 559 super().validate() 560 561 # Validate global params 562 for k, v in (self.params or {}).items(): 563 if not isinstance(v, (int, float)): 564 raise TypeError(f"Parameter '{k}' must be numeric") 565 if not (0.0 <= float(v) <= 1.0): 566 raise ValueError(f"Parameter '{k}' must be between 0 and 1") 567 568 # Validate per-cell evolve arrays 569 for pname, meta in (self._evolve_info or {}).items(): 570 arr = self.cell_params.get(pname) 571 if not isinstance(arr, np.ndarray): 572 # absent or non-array per-cell params are allowed; skip 573 continue 574 # shape already checked in super().validate(), but be explicit 575 if arr.shape != self.grid.shape: 576 raise ValueError(f"cell_params['{pname}'] must match grid shape") 577 # expected non-NaN positions correspond to species stored in metadata 578 species = None 579 if isinstance(meta, dict) and "species" in meta: 580 species = int(meta.get("species")) 581 else: 582 # try to infer species from parameter name using species_names 583 species = self._infer_species_from_param_name(pname) 584 if species is None: 585 raise ValueError( 586 f"cell_params['{pname}'] missing species metadata and could not infer from name" 587 ) 588 nonnan = ~np.isnan(arr) 589 expected = self.grid == species 590 if not np.array_equal(nonnan, expected): 591 raise ValueError( 592 f"cell_params['{pname}'] non-NaN entries must match positions of species {species}" 593 ) 594 # values must be within configured range where not NaN 595 mn = float(meta.get("min", 0.0)) 596 mx = float(meta.get("max", 1.0)) 597 vals = arr[~np.isnan(arr)] 598 if vals.size > 0: 599 if np.any(vals < mn) or np.any(vals > mx): 600 raise ValueError( 601 f"cell_params['{pname}'] contains values outside [{mn}, {mx}]" 602 )
Validate Predator-Prey specific invariants and spatial parameter arrays.
Extends the base CA validation to ensure that numerical parameters are within the [0, 1] probability range and that evolved parameter maps (e.g., prey_death) correctly align with the species locations.
Raises
- ValueError: If grid shapes, parameter ranges, or species masks are inconsistent.
- TypeError: If parameters are non-numeric.
604 def update_async(self) -> None: 605 """ 606 Execute an asynchronous update using the optimized Numba kernel. 607 608 This method retrieves the evolved parameter maps and delegates the 609 stochastic transitions to the `PPKernel`. Asynchronous updates 610 typically handle cell-by-cell logic where changes can be 611 immediately visible to neighbors. 612 """ 613 # Get the evolved prey death map 614 # Fallback to a full array of the global param if it doesn't exist yet 615 p_death_arr = self.cell_params.get("prey_death") 616 if p_death_arr is None: 617 p_death_arr = np.full( 618 self.grid.shape, self.params["prey_death"], dtype=np.float64 619 ) 620 621 meta = self._evolve_info.get( 622 "prey_death", {"sd": 0.05, "min": 0.001, "max": 0.1} 623 ) 624 625 # Call the optimized kernel (uses pre-allocated buffers) 626 self._kernel.update( 627 self.grid, 628 p_death_arr, 629 float(self.params["prey_birth"]), 630 float(self.params["prey_death"]), 631 float(self.params["predator_birth"]), 632 float(self.params["predator_death"]), 633 float(meta["sd"]), 634 float(meta["min"]), 635 float(meta["max"]), 636 self._evolution_stopped, 637 )
Execute an asynchronous update using the optimized Numba kernel.
This method retrieves the evolved parameter maps and delegates the
stochastic transitions to the PPKernel. Asynchronous updates
typically handle cell-by-cell logic where changes can be
immediately visible to neighbors.