Skip to content

Commit 2269850

Browse files
committed
changed updating to correct asynchronous rules
Previous rules had issue where prey and predators could reproduce into multiple cells at each iteration.
1 parent 117520d commit 2269850

2 files changed

Lines changed: 219 additions & 165 deletions

File tree

CA.py

Lines changed: 104 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,19 @@ def run(self, steps: int) -> None:
136136

137137

138138
class PP(CA):
139-
"""Predator-Prey cellular automaton.
139+
"""Predator-prey CA.
140140
141141
States: 0 = empty, 1 = prey, 2 = predator
142142
143-
Expected params keys (all values in [0,1]):
144-
- "prey_death": probability a prey dies each step
145-
- "predator_death": probability a predator dies each step
146-
- "reproduction": per-neighbor prey reproduction probability
147-
- "consumption": per-neighbor predation probability
143+
Parameters (in `params` dict). Allowed keys and defaults:
144+
- "prey_death": 0.05
145+
- "predator_death": 0.1
146+
- "prey_birth": 0.25
147+
- "predator_birth": 0.2
148148
149-
Defaults are provided for any missing keys and user-provided values
150-
are preserved. Any unknown keys in `params` will raise a ValueError.
149+
The constructor validates parameters are in [0,1] and raises if
150+
other user-supplied params are present. The `synchronous` flag
151+
chooses the update mode (default False -> asynchronous updates).
151152
"""
152153

153154
def __init__(
@@ -159,82 +160,105 @@ def __init__(
159160
params: Dict[str, object],
160161
cell_params: Dict[str, object],
161162
seed: Optional[int] = None,
163+
synchronous: bool = False,
162164
) -> None:
163-
# initialize base CA
164-
super().__init__(rows, cols, densities, neighborhood, params, cell_params, seed)
165-
166-
# Enforce predator-prey has exactly two species
167-
assert self.n_species == 2, "PP model requires exactly two species (prey=1, predator=2)"
168-
169-
# Allowed parameter keys and defaults
170-
_allowed = {
171-
"prey_death": 0.02,
172-
"predator_death": 0.05,
173-
"reproduction": 0.2,
174-
"consumption": 0.5,
165+
# Allowed params and defaults
166+
_defaults = {
167+
"prey_death": 0.05,
168+
"predator_death": 0.1,
169+
"prey_birth": 0.25,
170+
"predator_birth": 0.2,
175171
}
176172

177-
# Check for unknown user-specified keys (in the params dict provided by user)
178-
user_keys = set(self.params.keys())
179-
unknown = user_keys.difference(_allowed.keys())
180-
if len(unknown) > 0:
181-
raise ValueError(f"Unknown parameter keys for PP: {sorted(list(unknown))}")
182-
183-
# Fill defaults for missing keys without overriding user-specified values
184-
for k, v in _allowed.items():
185-
if k not in self.params:
186-
self.params[k] = v
187-
188-
# Validate parameter ranges
189-
for k in _allowed.keys():
190-
val = self.params[k]
191-
if not isinstance(val, (int, float)):
192-
raise TypeError(f"Parameter '{k}' must be numeric")
193-
if not (0.0 <= float(val) <= 1.0):
194-
raise ValueError(f"Parameter '{k}' must be between 0 and 1 (got {val})")
195-
196-
def update(self) -> None:
197-
"""One update step for predator-prey dynamics.
198-
199-
Uses a copy of the current grid to evaluate rules so newly changed
200-
cells do not immediately influence other rules in the same step.
173+
# Validate user-supplied params: only allowed keys
174+
if params is None:
175+
merged_params = dict(_defaults)
176+
else:
177+
if not isinstance(params, dict):
178+
raise TypeError("params must be a dict or None")
179+
extra = set(params.keys()) - set(_defaults.keys())
180+
if extra:
181+
raise ValueError(f"Unexpected parameter keys: {sorted(list(extra))}")
182+
# Do not override user-set values: start from defaults then update with user values
183+
merged_params = dict(_defaults)
184+
merged_params.update(params)
185+
186+
# Validate numerical ranges
187+
for k, v in merged_params.items():
188+
if not isinstance(v, (int, float)):
189+
raise TypeError(f"Parameter '{k}' must be a number between 0 and 1")
190+
if not (0.0 <= float(v) <= 1.0):
191+
raise ValueError(f"Parameter '{k}' must be between 0 and 1")
192+
193+
# Call base initializer with merged params
194+
super().__init__(rows, cols, densities, neighborhood, merged_params, cell_params, seed)
195+
196+
self.synchronous: bool = bool(synchronous)
197+
198+
def update_sync(self) -> None:
199+
"""Synchronous update (not implemented)."""
200+
raise NotImplementedError("Synchronous PP update not implemented")
201+
202+
def update_async(self) -> None:
203+
"""Asynchronous (random-sequential) update.
204+
205+
Rules (applied using a copy of the current grid for reference):
206+
- Iterate occupied cells in random order.
207+
- Prey (1): pick random neighbor; if neighbor was empty in copy,
208+
reproduce into it with probability `prey_birth`.
209+
- Predator (2): pick random neighbor; if neighbor was prey in copy,
210+
reproduce into it (convert to predator) with probability `predator_birth`.
211+
- After the reproduction loop, apply deaths synchronously using the
212+
copy as the reference so newly created individuals are not instantly
213+
killed. Deaths only remove individuals if the current cell still
214+
matches the species from the reference copy.
201215
"""
202-
# copy of the grid to base all decisions on
203-
old = self.grid.copy()
216+
rows, cols = self.grid.shape
217+
grid_ref = self.grid.copy()
204218

205-
# neighbor counts for each species (index 0 -> prey, 1 -> predator)
206-
counts = self.count_neighbors()
207-
prey_neighbors = counts[0]
208-
pred_neighbors = counts[1] if self.n_species >= 2 else np.zeros_like(self.grid)
219+
# Precompute neighbor shifts
220+
if self.neighborhood == "neumann":
221+
shifts = [(-1, 0), (1, 0), (0, -1), (0, 1)]
222+
else:
223+
shifts = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
209224

210-
rows, cols = self.grid.shape
211-
# Reproduction into empty cells from neighboring prey
212-
empty_mask = old == 0
213-
# probability that at least one neighboring prey reproduces into the cell
214-
birth_param = float(self.params["reproduction"])
215-
birth_prob = 1.0 - np.power(1.0 - birth_param, prey_neighbors)
216-
rand = self.generator.random(size=(rows, cols))
217-
birth_cells = empty_mask & (prey_neighbors > 0) & (rand < birth_prob)
218-
self.grid[birth_cells] = 1
219-
220-
# Predation: prey replaced by predator due to neighboring predators
221-
prey_mask = old == 1
222-
cons_param = float(self.params["consumption"])
223-
cons_prob = 1.0 - np.power(1.0 - cons_param, pred_neighbors)
224-
rand = self.generator.random(size=(rows, cols))
225-
predation_cells = prey_mask & (pred_neighbors > 0) & (rand < cons_prob)
226-
self.grid[predation_cells] = 2
227-
228-
# Deaths: use the copied `old` grid so newly-occupied cells are not killed immediately
229-
# Prey death
230-
prey_death_p = float(self.params["prey_death"])
231-
rand = self.generator.random(size=(rows, cols))
232-
prey_death_cells = (old == 1) & (rand < prey_death_p)
233-
self.grid[prey_death_cells] = 0
234-
235-
# Predator death
236-
pred_death_p = float(self.params["predator_death"])
237-
rand = self.generator.random(size=(rows, cols))
238-
pred_death_cells = (old == 2) & (rand < pred_death_p)
239-
self.grid[pred_death_cells] = 0
225+
# Get occupied cells from the reference grid and shuffle
226+
occupied = np.argwhere(grid_ref != 0)
227+
if occupied.size > 0:
228+
order = self.generator.permutation(len(occupied))
229+
for idx in order:
230+
r, c = int(occupied[idx, 0]), int(occupied[idx, 1])
231+
state = int(grid_ref[r, c])
232+
# pick a random neighbor shift
233+
dr, dc = shifts[self.generator.integers(0, len(shifts))]
234+
nr = (r + dr) % rows
235+
nc = (c + dc) % cols
236+
if state == 1:
237+
# Prey reproduces into empty neighbor (reference must be empty)
238+
if grid_ref[nr, nc] == 0:
239+
if self.generator.random() < float(self.params["prey_birth"]):
240+
self.grid[nr, nc] = 1
241+
elif state == 2:
242+
# Predator reproduces into prey neighbor (reference must be prey)
243+
if grid_ref[nr, nc] == 1:
244+
if self.generator.random() < float(self.params["predator_birth"]):
245+
self.grid[nr, nc] = 2
246+
247+
# Vectorized synchronous deaths, based on grid_ref but only kill if
248+
# the current grid still matches the referenced species (so newly
249+
# occupied cells are not removed mistakenly).
250+
rand_prey = self.generator.random(self.grid.shape)
251+
rand_pred = self.generator.random(self.grid.shape)
252+
253+
prey_death_mask = (grid_ref == 1) & (rand_prey < float(self.params["prey_death"])) & (self.grid == 1)
254+
pred_death_mask = (grid_ref == 2) & (rand_pred < float(self.params["predator_death"])) & (self.grid == 2)
255+
256+
self.grid[prey_death_mask] = 0
257+
self.grid[pred_death_mask] = 0
240258

259+
def update(self) -> None:
260+
"""Dispatch to synchronous or asynchronous update mode."""
261+
if self.synchronous:
262+
self.update_sync()
263+
else:
264+
self.update_async()

0 commit comments

Comments
 (0)