Skip to content

Commit e1ba9cc

Browse files
committed
Moved evolve to CA
This allows cleaner evolution handling
1 parent 698bbc5 commit e1ba9cc

1 file changed

Lines changed: 120 additions & 114 deletions

File tree

models/CA.py

Lines changed: 120 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -50,26 +50,6 @@ def densities(self) -> Tuple[float, ...]:
5050
def n_species(self) -> int:
5151
return int(getattr(self, "_n_species"))
5252

53-
def validate(self) -> None:
54-
"""Validate core CA invariants.
55-
56-
Checks that `neighborhood` is valid, that `self.grid` has the
57-
texpected shape `(rows, cols)`, and that any numpy arrays in
58-
`self.cell_params` have matching shapes. Raises `ValueError` on
59-
validation failure.
60-
"""
61-
if self.neighborhood not in ("neumann", "moore"):
62-
raise ValueError("neighborhood must be 'neumann' or 'moore'")
63-
64-
expected_shape = (int(getattr(self, "_rows")), int(getattr(self, "_cols")))
65-
if self.grid.shape != expected_shape:
66-
raise ValueError(f"grid shape {self.grid.shape} does not match expected {expected_shape}")
67-
68-
# Ensure any array in cell_params matches grid shape
69-
for k, v in (self.cell_params or {}).items():
70-
if isinstance(v, np.ndarray) and v.shape != expected_shape:
71-
raise ValueError(f"cell_params['{k}'] must have shape equal to grid")
72-
7353
def __init__(
7454
self,
7555
rows: int,
@@ -113,6 +93,16 @@ def __init__(
11393
self._densities: Tuple[float, ...] = tuple(densities)
11494
self.params: Dict[str, object] = dict(params) if params is not None else {}
11595
self.cell_params: Dict[str, object] = dict(cell_params) if cell_params is not None else {}
96+
97+
# per-parameter evolve metadata and evolution state
98+
# maps parameter name -> dict with keys 'sd','min','max','species'
99+
self._evolve_info: Dict[str, Dict[str, float]] = {}
100+
# when True, inheritance uses deterministic copy from parent (no mutation)
101+
self._evolution_stopped: bool = False
102+
103+
# human-readable species names (useful for visualization). Default
104+
# generates generic names based on n_species; subclasses may override.
105+
self.species_names: Tuple[str, ...] = tuple(f"species{i+1}" for i in range(self._n_species))
116106
self.neighborhood: str = neighborhood
117107
self.generator: np.random.Generator = np.random.default_rng(seed)
118108

@@ -136,6 +126,67 @@ def __init__(
136126
c = chosen % cols
137127
self.grid[r, c] = i + 1
138128

129+
def validate(self) -> None:
130+
"""Validate core CA invariants.
131+
132+
Checks that `neighborhood` is valid, that `self.grid` has the
133+
texpected shape `(rows, cols)`, and that any numpy arrays in
134+
`self.cell_params` have matching shapes. Raises `ValueError` on
135+
validation failure.
136+
"""
137+
if self.neighborhood not in ("neumann", "moore"):
138+
raise ValueError("neighborhood must be 'neumann' or 'moore'")
139+
140+
expected_shape = (int(getattr(self, "_rows")), int(getattr(self, "_cols")))
141+
if self.grid.shape != expected_shape:
142+
raise ValueError(f"grid shape {self.grid.shape} does not match expected {expected_shape}")
143+
144+
# Ensure any array in cell_params matches grid shape
145+
for k, v in (self.cell_params or {}).items():
146+
if isinstance(v, np.ndarray) and v.shape != expected_shape:
147+
raise ValueError(f"cell_params['{k}'] must have shape equal to grid")
148+
149+
def _infer_species_from_param_name(self, param_name: str) -> Optional[int]:
150+
"""Infer species index (1-based) from a parameter name using `species_names`.
151+
152+
Returns the 1-based species index if a matching prefix is found,
153+
otherwise `None`.
154+
"""
155+
if not isinstance(param_name, str):
156+
return None
157+
for idx, name in enumerate(self.species_names or ()): # type: ignore
158+
if isinstance(name, str) and param_name.startswith(f"{name}_"):
159+
return idx + 1
160+
return None
161+
162+
def evolve(self, param: str, species: Optional[int] = None, sd: float = 0.05, min_val: Optional[float] = None, max_val: Optional[float] = None) -> None:
163+
"""Enable per-cell evolution for `param` on `species`.
164+
165+
If `species` is None, attempt to infer the species using
166+
`_infer_species_from_param_name(param)` which matches against
167+
`self.species_names`. This keeps `CA` free of domain-specific
168+
(predator/prey) logic while preserving backward compatibility when
169+
subclasses set `species_names` (e.g. `('prey','predator')`).
170+
"""
171+
if min_val is None:
172+
min_val = 0.01
173+
if max_val is None:
174+
max_val = 0.99
175+
if param not in self.params:
176+
raise ValueError(f"Unknown parameter '{param}'")
177+
if species is None:
178+
species = self._infer_species_from_param_name(param)
179+
if species is None:
180+
raise ValueError("species must be provided or inferable from param name and species_names")
181+
if not isinstance(species, int) or species <= 0 or species > self._n_species:
182+
raise ValueError("species must be an integer between 1 and n_species")
183+
184+
arr = np.full(self.grid.shape, np.nan, dtype=float)
185+
mask = (self.grid == int(species))
186+
arr[mask] = float(self.params[param])
187+
self.cell_params[param] = arr
188+
self._evolve_info[param] = {"sd": float(sd), "min": float(min_val), "max": float(max_val), "species": int(species)}
189+
139190
def count_neighbors(self) -> Tuple[np.ndarray, ...]:
140191
"""Count neighbors for each non-zero state.
141192
@@ -200,14 +251,6 @@ def run(self, steps: int, stop_evolution_at: Optional[int] = None, snapshot_iter
200251
"""
201252
assert isinstance(steps, int) and steps >= 0, "steps must be a non-negative integer"
202253

203-
# NOTE: validation of `cell_params` and evolved parameters has been
204-
# moved to the `validate()` method on the class. The run loop no
205-
# longer performs per-cell validation for performance; call
206-
# `validate()` explicitly when needed.
207-
208-
# normalize snapshot iteration list
209-
snapshot_set = set(snapshot_iters) if snapshot_iters is not None else set()
210-
211254
# normalize snapshot iteration list
212255
snapshot_set = set(snapshot_iters) if snapshot_iters is not None else set()
213256

@@ -241,31 +284,12 @@ def run(self, steps: int, stop_evolution_at: Optional[int] = None, snapshot_iter
241284

242285
# stop evolution at specified time-step (disable further evolution)
243286
if stop_evolution_at is not None and (i + 1) == int(stop_evolution_at):
244-
# disable further evolution
245-
self._evolve_info = {}
246-
247-
# create snapshots if requested at this iteration
248-
if (i + 1) in snapshot_set:
287+
# mark evolution as stopped; do not erase evolve metadata so
288+
# deterministic inheritance can still use parent values
249289
try:
250-
# create snapshot folder if not present
251-
if not hasattr(self, "_viz_snapshot_dir") or self._viz_snapshot_dir is None:
252-
import os, time
253-
254-
base = "results"
255-
ts = int(time.time())
256-
run_folder = f"run-{ts}"
257-
full = os.path.join(base, run_folder)
258-
os.makedirs(full, exist_ok=True)
259-
self._viz_snapshot_dir = full
260-
self._viz_save_snapshot(i + 1)
261-
except Exception:
262-
pass
263-
264-
# stop evolution at specified time-step (disable further evolution)
265-
if stop_evolution_at is not None and (i + 1) == int(stop_evolution_at):
266-
try:
267-
self._evolve_info = {}
290+
self._evolution_stopped = True
268291
except Exception:
292+
# best-effort: ignore if attribute cannot be set
269293
pass
270294

271295
def visualize(
@@ -859,51 +883,11 @@ def __init__(
859883
super().__init__(rows, cols, densities, neighborhood, merged_params, cell_params, seed)
860884

861885
self.synchronous: bool = bool(synchronous)
862-
863-
# Information about which parameters are being evolved and their mutation specs
864-
# Maps parameter name -> dict with keys: 'sd', 'min', 'max'
865-
self._evolve_info: Dict[str, Dict[str, float]] = {}
886+
# set human-friendly species names for PP
887+
self.species_names = ("prey", "predator")
866888

867889

868-
def evolve(self, param: str, sd: float = 0.05, min_val: Optional[float] = None, max_val: Optional[float] = None) -> None:
869-
"""Enable per-cell evolution for a given parameter.
870-
871-
Creates a per-cell array in `self.cell_params[param]` with the same
872-
shape as the grid. Cells currently occupied by the relevant species are
873-
initialized to the global value in `self.params[param]`; other cells are
874-
set to NaN. Mutation metadata (sd, min, max) are stored in
875-
`self._evolve_info[param]`.
876-
877-
Args:
878-
- param: one of the keys in `self.params` (e.g. 'prey_death')
879-
- sd: standard deviation for Gaussian mutations
880-
- min: minimum clipped value after mutation
881-
- max: maximum clipped value after mutation
882-
"""
883-
# Note: deprecated keyword names `min` and `max` have been removed.
884-
# Callers must use `min_val` and `max_val` explicitly.
885-
# Provide sensible defaults when not specified
886-
if min_val is None:
887-
min_val = 0.01
888-
if max_val is None:
889-
max_val = 0.99
890-
if param not in self.params:
891-
raise ValueError(f"Unknown parameter '{param}'")
892-
893-
# determine target species for this parameter
894-
if param.startswith("prey_"):
895-
species = 1
896-
elif param.startswith("predator_"):
897-
species = 2
898-
else:
899-
raise ValueError("Parameter must start with 'prey_' or 'predator_' to evolve")
900-
901-
# create per-cell float array with NaNs for non-relevant cells
902-
arr = np.full(self.grid.shape, np.nan, dtype=float)
903-
mask = (self.grid == species)
904-
arr[mask] = float(self.params[param])
905-
self.cell_params[param] = arr
906-
self._evolve_info[param] = {"sd": float(sd), "min": float(min_val), "max": float(max_val)}
890+
# Remove PP-specific evolve wrapper; use CA.evolve with optional species
907891

908892
def validate(self) -> None:
909893
"""Validate PP-specific invariants in addition to base CA checks.
@@ -932,8 +916,15 @@ def validate(self) -> None:
932916
# shape already checked in super().validate(), but be explicit
933917
if arr.shape != self.grid.shape:
934918
raise ValueError(f"cell_params['{pname}'] must match grid shape")
935-
# expected non-NaN positions correspond to species in grid
936-
species = 1 if pname.startswith("prey_") else 2
919+
# expected non-NaN positions correspond to species stored in metadata
920+
species = None
921+
if isinstance(meta, dict) and "species" in meta:
922+
species = int(meta.get("species"))
923+
else:
924+
# try to infer species from parameter name using species_names
925+
species = self._infer_species_from_param_name(pname)
926+
if species is None:
927+
raise ValueError(f"cell_params['{pname}'] missing species metadata and could not infer from name")
937928
nonnan = ~np.isnan(arr)
938929
expected = (self.grid == species)
939930
if not np.array_equal(nonnan, expected):
@@ -970,15 +961,23 @@ def _apply_deaths_and_clear_params(self, grid_ref: np.ndarray, rand_prey: np.nda
970961
self.grid[pred_death_mask] = 0
971962

972963
# Clear per-cell parameters for dead individuals
973-
for pname in self._evolve_info:
974-
if pname.startswith("prey_"):
975-
arr = self.cell_params.get(pname)
976-
if isinstance(arr, np.ndarray) and arr.shape == self.grid.shape:
977-
arr[prey_death_mask] = np.nan
978-
elif pname.startswith("predator_"):
979-
arr = self.cell_params.get(pname)
980-
if isinstance(arr, np.ndarray) and arr.shape == self.grid.shape:
981-
arr[pred_death_mask] = np.nan
964+
for pname, meta in self._evolve_info.items():
965+
# determine species from metadata or infer from name
966+
species = None
967+
if isinstance(meta, dict) and "species" in meta:
968+
species = int(meta.get("species"))
969+
else:
970+
species = self._infer_species_from_param_name(pname)
971+
if species is None:
972+
# cannot determine species; skip clearing for safety
973+
continue
974+
arr = self.cell_params.get(pname)
975+
if not (isinstance(arr, np.ndarray) and arr.shape == self.grid.shape):
976+
continue
977+
if species == 1:
978+
arr[prey_death_mask] = np.nan
979+
elif species == 2:
980+
arr[pred_death_mask] = np.nan
982981

983982
def _neighbor_shifts(self) -> Tuple[np.ndarray, np.ndarray, int]:
984983
"""Return neighbor shift arrays (dr_arr, dc_arr, n_shifts) for the configured neighborhood."""
@@ -1007,16 +1006,17 @@ def _inherit_params_on_birth(self, chosen_rs: np.ndarray, chosen_cs: np.ndarray,
10071006
an array of parent coordinates with same length.
10081007
"""
10091008
for pname, meta in self._evolve_info.items():
1010-
# determine species this parameter belongs to
1011-
if pname.startswith("prey_"):
1012-
target_species = 1
1013-
elif pname.startswith("predator_"):
1014-
target_species = 2
1009+
# determine species this parameter belongs to via metadata or inference
1010+
species = None
1011+
if isinstance(meta, dict) and "species" in meta:
1012+
species = int(meta.get("species"))
10151013
else:
1016-
continue
1014+
species = self._infer_species_from_param_name(pname)
1015+
if species is None:
1016+
raise ValueError(f"_evolve_info contains unexpected key '{pname}' without species metadata and could not infer")
10171017

10181018
# if new_state is not the species for this param, clear at targets
1019-
if new_state_val != target_species:
1019+
if new_state_val != species:
10201020
arr = self.cell_params.get(pname)
10211021
if isinstance(arr, np.ndarray) and arr.shape == self.grid.shape:
10221022
arr[chosen_rs, chosen_cs] = np.nan
@@ -1037,8 +1037,14 @@ def _inherit_params_on_birth(self, chosen_rs: np.ndarray, chosen_cs: np.ndarray,
10371037
sd = float(meta["sd"])
10381038
mn = float(meta["min"])
10391039
mx = float(meta["max"])
1040-
mut = parent_vals + self.generator.normal(0.0, sd, size=parent_vals.shape)
1041-
mut = np.clip(mut, mn, mx)
1040+
# If evolution has been stopped, inheritance is deterministic: copy
1041+
# parent values directly without Gaussian mutation so we can observe
1042+
# which parameter values survive.
1043+
if getattr(self, "_evolution_stopped", False):
1044+
mut = parent_vals.copy()
1045+
else:
1046+
mut = parent_vals + self.generator.normal(0.0, sd, size=parent_vals.shape)
1047+
mut = np.clip(mut, mn, mx)
10421048
# If an array exists but has wrong shape, raise an informative error
10431049
existing = self.cell_params.get(pname)
10441050
if isinstance(existing, np.ndarray) and existing.shape != self.grid.shape:

0 commit comments

Comments
 (0)