@@ -258,6 +258,182 @@ def test_kernel_deterministic_with_seed(self):
258258 assert np .array_equal (results [0 ], results [1 ]), "Results should be deterministic"
259259
260260
261+ class TestPPKernelDirectedHunting :
262+ """Tests for PPKernel with directed hunting behavior."""
263+
264+ def test_kernel_initialization_directed_false (self ):
265+ """Kernel should default to directed_hunting=False."""
266+ kernel = PPKernel (50 , 50 , "moore" )
267+ assert kernel .directed_hunting == False
268+
269+ def test_kernel_initialization_directed_true (self ):
270+ """Kernel should accept directed_hunting=True."""
271+ kernel = PPKernel (50 , 50 , "moore" , directed_hunting = True )
272+ assert kernel .directed_hunting == True
273+
274+ def test_kernel_directed_runs_without_error (self , medium_grid , prey_death_array ):
275+ """Directed hunting kernel should run without errors."""
276+ set_numba_seed (42 )
277+ kernel = PPKernel (50 , 50 , "moore" , directed_hunting = True )
278+
279+ grid = medium_grid .copy ()
280+ prey_death = prey_death_array .copy ()
281+
282+ # Run multiple steps
283+ for _ in range (20 ):
284+ kernel .update (grid , prey_death , 0.2 , 0.05 , 0.2 , 0.1 )
285+
286+ # Grid should only have valid states
287+ assert grid .min () >= 0
288+ assert grid .max () <= 2
289+
290+ def test_kernel_directed_valid_states (self , medium_grid , prey_death_array ):
291+ """Directed kernel should produce only valid states."""
292+ set_numba_seed (42 )
293+ kernel = PPKernel (50 , 50 , "moore" , directed_hunting = True )
294+
295+ grid = medium_grid .copy ()
296+ prey_death = prey_death_array .copy ()
297+
298+ for _ in range (50 ):
299+ kernel .update (grid , prey_death , 0.2 , 0.05 , 0.2 , 0.1 )
300+
301+ unique = np .unique (grid )
302+ assert all (v in [0 , 1 , 2 ] for v in unique )
303+
304+ def test_kernel_directed_prey_death_consistency (self , medium_grid , prey_death_array ):
305+ """Directed kernel should maintain prey_death array consistency."""
306+ set_numba_seed (42 )
307+ kernel = PPKernel (50 , 50 , "moore" , directed_hunting = True )
308+
309+ grid = medium_grid .copy ()
310+ prey_death = prey_death_array .copy ()
311+
312+ for _ in range (20 ):
313+ kernel .update (grid , prey_death , 0.2 , 0.05 , 0.2 , 0.1 ,
314+ evolution_stopped = False )
315+
316+ # Prey cells should have non-NaN death rates
317+ prey_mask = (grid == 1 )
318+ non_prey_mask = (grid != 1 )
319+
320+ if np .any (prey_mask ):
321+ assert np .all (~ np .isnan (prey_death [prey_mask ]))
322+ assert np .all (np .isnan (prey_death [non_prey_mask ]))
323+
324+ def test_kernel_directed_evolution_respects_bounds (self , medium_grid , prey_death_array ):
325+ """Directed kernel evolution should stay within bounds."""
326+ set_numba_seed (42 )
327+ kernel = PPKernel (50 , 50 , "moore" , directed_hunting = True )
328+ evolve_min , evolve_max = 0.01 , 0.15
329+
330+ grid = medium_grid .copy ()
331+ prey_death = prey_death_array .copy ()
332+
333+ for _ in range (100 ):
334+ kernel .update (grid , prey_death , 0.2 , 0.05 , 0.2 , 0.1 ,
335+ evolve_sd = 0.1 , evolve_min = evolve_min , evolve_max = evolve_max ,
336+ evolution_stopped = False )
337+
338+ valid_values = prey_death [~ np .isnan (prey_death )]
339+ if len (valid_values ) > 0 :
340+ assert valid_values .min () >= evolve_min - 1e-10
341+ assert valid_values .max () <= evolve_max + 1e-10
342+
343+ def test_kernel_directed_neumann_neighborhood (self ):
344+ """Directed hunting should work with von Neumann neighborhood."""
345+ np .random .seed (42 )
346+ set_numba_seed (42 )
347+
348+ grid = np .random .choice ([0 , 1 , 2 ], (30 , 30 ), p = [0.5 , 0.3 , 0.2 ]).astype (np .int32 )
349+ prey_death = np .full ((30 , 30 ), 0.05 , dtype = np .float64 )
350+ prey_death [grid != 1 ] = np .nan
351+
352+ kernel = PPKernel (30 , 30 , "neumann" , directed_hunting = True )
353+
354+ for _ in range (20 ):
355+ kernel .update (grid , prey_death , 0.2 , 0.05 , 0.2 , 0.1 )
356+
357+ assert grid .min () >= 0
358+ assert grid .max () <= 2
359+
360+ def test_random_vs_directed_different_behavior (self ):
361+ """Random and directed kernels should produce different results."""
362+ np .random .seed (123 )
363+
364+ # Create identical starting grids
365+ grid_template = np .random .choice ([0 , 1 , 2 ], (40 , 40 ),
366+ p = [0.50 , 0.35 , 0.15 ]).astype (np .int32 )
367+
368+ grid_random = grid_template .copy ()
369+ grid_directed = grid_template .copy ()
370+
371+ prey_death_random = np .full ((40 , 40 ), 0.05 , dtype = np .float64 )
372+ prey_death_random [grid_random != 1 ] = np .nan
373+ prey_death_directed = prey_death_random .copy ()
374+
375+ kernel_random = PPKernel (40 , 40 , "moore" , directed_hunting = False )
376+ kernel_directed = PPKernel (40 , 40 , "moore" , directed_hunting = True )
377+
378+ # Run with same seed
379+ set_numba_seed (999 )
380+ for _ in range (50 ):
381+ kernel_random .update (grid_random , prey_death_random ,
382+ 0.2 , 0.05 , 0.6 , 0.1 )
383+
384+ set_numba_seed (999 )
385+ for _ in range (50 ):
386+ kernel_directed .update (grid_directed , prey_death_directed ,
387+ 0.2 , 0.05 , 0.6 , 0.1 )
388+
389+ # Grids should differ (directed hunting changes dynamics)
390+ # Note: not guaranteed for every seed, but highly likely
391+ prey_random = np .sum (grid_random == 1 )
392+ prey_directed = np .sum (grid_directed == 1 )
393+ pred_random = np .sum (grid_random == 2 )
394+ pred_directed = np .sum (grid_directed == 2 )
395+
396+ # At minimum, both should have valid grids
397+ assert grid_random .min () >= 0 and grid_random .max () <= 2
398+ assert grid_directed .min () >= 0 and grid_directed .max () <= 2
399+
400+ # The populations should likely differ
401+ # (we don't assert this strictly as it depends on random dynamics)
402+ print (f"Random: prey={ prey_random } , pred={ pred_random } " )
403+ print (f"Directed: prey={ prey_directed } , pred={ pred_directed } " )
404+
405+ def test_directed_predator_hunts_adjacent_prey (self ):
406+ """Directed predator should successfully hunt adjacent prey."""
407+ # Create controlled scenario: predator surrounded by prey
408+ grid = np .zeros ((10 , 10 ), dtype = np .int32 )
409+ grid [5 , 5 ] = 2 # Predator in center
410+ grid [4 , 5 ] = 1 # Prey above
411+ grid [6 , 5 ] = 1 # Prey below
412+ grid [5 , 4 ] = 1 # Prey left
413+ grid [5 , 6 ] = 1 # Prey right
414+
415+ prey_death = np .full ((10 , 10 ), 0.05 , dtype = np .float64 )
416+ prey_death [grid != 1 ] = np .nan
417+
418+ kernel = PPKernel (10 , 10 , "neumann" , directed_hunting = True )
419+
420+ initial_prey = np .sum (grid == 1 )
421+ initial_pred = np .sum (grid == 2 )
422+
423+ # Run with high predator birth, zero predator death
424+ set_numba_seed (42 )
425+ for _ in range (5 ):
426+ kernel .update (grid , prey_death , 0.0 , 0.05 , 1.0 , 0.0 )
427+
428+ final_prey = np .sum (grid == 1 )
429+ final_pred = np .sum (grid == 2 )
430+
431+ # Predators should have converted some prey
432+ # (with 100% birth rate and 0% death rate)
433+ assert final_pred >= initial_pred , "Predator population should not decrease"
434+ print (f"Prey: { initial_prey } -> { final_prey } " )
435+ print (f"Pred: { initial_pred } -> { final_pred } " )
436+
261437# ============================================================================
262438# TEST: PCF COMPUTATION
263439# ============================================================================
@@ -483,6 +659,14 @@ def test_warmup_compiles_kernel(self):
483659
484660 # Should complete quickly (less than 1 second for 10 iterations)
485661 assert elapsed < 1.0 , f"Kernel too slow after warmup: { elapsed :.2f} s"
662+
663+
664+ def test_warmup_directed_hunting (self ):
665+ """Warmup should work with directed_hunting=True."""
666+ try :
667+ warmup_numba_kernels (30 , directed_hunting = True )
668+ except Exception as e :
669+ pytest .fail (f"Warmup with directed_hunting failed: { e } " )
486670
487671
488672# ============================================================================
@@ -571,6 +755,44 @@ def test_extreme_parameters(self):
571755 assert True
572756
573757
758+ def test_directed_single_predator_surrounded_by_prey (self ):
759+ """Directed hunting: single predator surrounded by prey."""
760+ grid = np .ones ((5 , 5 ), dtype = np .int32 ) # All prey
761+ grid [2 , 2 ] = 2 # One predator in center
762+
763+ prey_death = np .full ((5 , 5 ), 0.05 , dtype = np .float64 )
764+ prey_death [grid != 1 ] = np .nan
765+
766+ kernel = PPKernel (5 , 5 , "moore" , directed_hunting = True )
767+ set_numba_seed (42 )
768+
769+ # Run a few steps
770+ for _ in range (3 ):
771+ kernel .update (grid , prey_death , 0.0 , 0.05 , 0.9 , 0.0 )
772+
773+ # Should not crash, grid should be valid
774+ assert grid .min () >= 0
775+ assert grid .max () <= 2
776+
777+ def test_directed_no_prey_nearby (self ):
778+ """Directed hunting: predator with no prey neighbors should explore."""
779+ grid = np .zeros ((10 , 10 ), dtype = np .int32 )
780+ grid [0 , 0 ] = 2 # Predator in corner
781+ grid [9 , 9 ] = 1 # Prey far away
782+
783+ prey_death = np .full ((10 , 10 ), 0.05 , dtype = np .float64 )
784+ prey_death [grid != 1 ] = np .nan
785+
786+ kernel = PPKernel (10 , 10 , "moore" , directed_hunting = True )
787+ set_numba_seed (42 )
788+
789+ # Run - predator should explore randomly (no prey adjacent)
790+ for _ in range (5 ):
791+ kernel .update (grid , prey_death , 0.0 , 0.05 , 0.5 , 0.0 )
792+
793+ assert grid .min () >= 0
794+ assert grid .max () <= 2
795+
574796# ============================================================================
575797# MAIN
576798# ============================================================================
0 commit comments