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()
class CA:
 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.
CA( rows: int, cols: int, densities: Tuple[float, ...], neighborhood: str, params: Dict[str, object], cell_params: Dict[str, object], seed: Optional[int] = None)
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.
rows: int
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.

cols: int
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.

densities: Tuple[float, ...]
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.

n_species: int
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).

def validate(self) -> None:
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.
def evolve( self, param: str, species: Optional[int] = None, sd: float = 0.05, min_val: Optional[float] = None, max_val: Optional[float] = None) -> None:
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.

def update(self) -> None:
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.

def run( self, steps: int, stop_evolution_at: Optional[int] = None, snapshot_iters: Optional[list] = None) -> None:
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.

class PP(CA):
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.
PP( rows: int = 10, cols: int = 10, densities: Tuple[float, ...] = (0.2, 0.1), neighborhood: str = 'moore', params: Dict[str, object] = None, cell_params: Dict[str, object] = None, seed: Optional[int] = None, synchronous: bool = True, directed_hunting: bool = False)
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.

def validate(self) -> None:
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.
def update_async(self) -> None:
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.

def update(self) -> None:
639    def update(self) -> None:
640        """
641        Dispatch the simulation step based on the configured update mode.
642        """
643        self.update_async()

Dispatch the simulation step based on the configured update mode.

Inherited Members
CA
rows
cols
densities
n_species
evolve
run