@@ -136,18 +136,19 @@ def run(self, steps: int) -> None:
136136
137137
138138class 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