Skip to content

Commit 74969a5

Browse files
committed
Added basic visualization
Also changed order of updates to handle deaths before births, removing the need to check whether the post-birth grid matches the reference
1 parent 5f41b62 commit 74969a5

2 files changed

Lines changed: 238 additions & 27 deletions

File tree

CA.py

Lines changed: 125 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class CA:
2121
- cell_params: local (per-cell) parameters dict
2222
"""
2323

24+
# Default colormap spec (string or sequence); resolved in `visualize` at runtime
25+
_default_cmap = "viridis"
26+
2427
def __init__(
2528
self,
2629
rows: int,
@@ -131,8 +134,95 @@ def run(self, steps: int) -> None:
131134
Returns: None
132135
"""
133136
assert isinstance(steps, int) and steps >= 0, "steps must be a non-negative integer"
134-
for _ in range(steps):
137+
for i in range(steps):
135138
self.update()
139+
# Update visualization if enabled every `interval` iterations
140+
if getattr(self, "_viz_on", False):
141+
# iteration number is 1-based for display
142+
try:
143+
self._viz_update(i + 1)
144+
except Exception:
145+
# Don't let visualization errors stop the simulation
146+
pass
147+
148+
def visualize(
149+
self,
150+
interval: int = 1,
151+
figsize: Tuple[float, float] = (5, 5),
152+
pause: float = 0.001,
153+
cmap=None,
154+
) -> None:
155+
"""Enable interactive visualization of the grid.
156+
157+
Args:
158+
- interval: update plot every `interval` iterations (>=1)
159+
- figsize: figure size passed to matplotlib
160+
- pause: seconds to pause after draw (controls responsiveness)
161+
- cmap: colormap spec (string, sequence of colors, or matplotlib Colormap).
162+
163+
This function imports matplotlib lazily so simulations without
164+
visualization do not require matplotlib to be installed.
165+
"""
166+
if not isinstance(interval, int) or interval < 1:
167+
raise ValueError("interval must be a positive integer")
168+
169+
# Lazy import so matplotlib is optional
170+
import matplotlib.pyplot as plt
171+
from matplotlib.colors import ListedColormap
172+
173+
# Resolve default cmap: prefer instance attribute override
174+
c_spec = self._default_cmap if cmap is None else cmap
175+
176+
# Build a discrete colormap with entries for states 0..n_species
177+
n_colors_needed = self.n_species + 1
178+
if isinstance(c_spec, str):
179+
# request discrete version of named colormap
180+
cmap_obj = plt.get_cmap(c_spec, n_colors_needed)
181+
elif isinstance(c_spec, (list, tuple)):
182+
colors = list(c_spec)
183+
if len(colors) < n_colors_needed:
184+
colors = colors + [colors[-1]] * (n_colors_needed - len(colors))
185+
cmap_obj = ListedColormap(colors[:n_colors_needed])
186+
else:
187+
# Assume user provided a Colormap-like object
188+
cmap_obj = c_spec
189+
190+
plt.ion()
191+
fig, ax = plt.subplots(figsize=figsize)
192+
im = ax.imshow(self.grid, cmap=cmap_obj, interpolation="nearest", vmin=0, vmax=self.n_species)
193+
ax.set_title("Iteration 0")
194+
plt.show(block=False)
195+
fig.canvas.draw()
196+
plt.pause(pause)
197+
198+
# Store visualization state on the instance (only when visualization enabled)
199+
self._viz_on = True
200+
self._viz_interval = interval
201+
self._viz_fig = fig
202+
self._viz_ax = ax
203+
self._viz_im = im
204+
self._viz_cmap = cmap_obj
205+
self._viz_pause = float(pause)
206+
207+
def _viz_update(self, iteration: int) -> None:
208+
"""Update the interactive plot if the configured interval has passed.
209+
210+
This function also performs the minimal redraw using `plt.pause` so the
211+
plot remains responsive.
212+
"""
213+
if not getattr(self, "_viz_on", False):
214+
return
215+
if (iteration % int(self._viz_interval)) != 0:
216+
return
217+
218+
# Lazy import for pause; matplotlib already imported in visualize
219+
import matplotlib.pyplot as plt
220+
221+
self._viz_im.set_data(self.grid)
222+
self._viz_ax.set_title(f"Iteration {iteration}")
223+
# draw/update
224+
self._viz_fig.canvas.draw_idle()
225+
plt.pause(self._viz_pause)
136226

137227

138228
class PP(CA):
@@ -151,6 +241,9 @@ class PP(CA):
151241
chooses the update mode (default False -> asynchronous updates).
152242
"""
153243

244+
# Default colors: 0=empty black, 1=prey green, 2=predator red
245+
_default_cmap = ("black", "green", "red")
246+
154247
def __init__(
155248
self,
156249
rows: int,
@@ -211,7 +304,22 @@ def update_sync(self) -> None:
211304
rows, cols = self.grid.shape
212305
grid_ref = self.grid.copy()
213306

214-
# Precompute neighbor shifts and arrays for indexing
307+
# Sample deaths based on the reference grid and apply them first.
308+
# Death probabilities are sampled from the state at the start of
309+
# the iteration (grid_ref) so sampling remains consistent.
310+
rand_prey = self.generator.random(self.grid.shape)
311+
rand_pred = self.generator.random(self.grid.shape)
312+
313+
prey_death_mask = (grid_ref == 1) & (rand_prey < float(self.params["prey_death"]))
314+
pred_death_mask = (grid_ref == 2) & (rand_pred < float(self.params["predator_death"]))
315+
316+
# Apply deaths to the current grid. We deliberately do this before
317+
# reproduction but keep using `grid_ref` for all reproduction checks
318+
# below to preserve the original (birth-before-death) semantics.
319+
self.grid[prey_death_mask] = 0
320+
self.grid[pred_death_mask] = 0
321+
322+
# Precompute neighbor shifts and arrays for indexing (used by reproduction)
215323
if self.neighborhood == "neumann":
216324
shifts = [(-1, 0), (1, 0), (0, -1), (0, 1)]
217325
else:
@@ -243,7 +351,6 @@ def _process_reproduction(sources, birth_prob, target_state_required, new_state_
243351
# Each attempting source picks one neighbor uniformly
244352
nbr_idx = self.generator.integers(0, n_shifts, size=K)
245353
nr = (src[:, 0] + dr_arr[nbr_idx]) % rows
246-
247354
nc = (src[:, 1] + dc_arr[nbr_idx]) % cols
248355

249356
# Only keep attempts where the reference grid at the target has the required state
@@ -291,16 +398,6 @@ def _process_reproduction(sources, birth_prob, target_state_required, new_state_
291398
pred_sources = np.argwhere(grid_ref == 2)
292399
_process_reproduction(pred_sources, self.params["predator_birth"], 1, 2)
293400

294-
# Vectorized synchronous deaths (same as async implementation)
295-
rand_prey = self.generator.random(self.grid.shape)
296-
rand_pred = self.generator.random(self.grid.shape)
297-
298-
prey_death_mask = (grid_ref == 1) & (rand_prey < float(self.params["prey_death"])) & (self.grid == 1)
299-
pred_death_mask = (grid_ref == 2) & (rand_pred < float(self.params["predator_death"])) & (self.grid == 2)
300-
301-
self.grid[prey_death_mask] = 0
302-
self.grid[pred_death_mask] = 0
303-
304401
def update_async(self) -> None:
305402
"""Asynchronous (random-sequential) update.
306403
@@ -318,13 +415,26 @@ def update_async(self) -> None:
318415
rows, cols = self.grid.shape
319416
grid_ref = self.grid.copy()
320417

418+
# Sample and apply deaths first (based on the reference grid). Deaths
419+
# are sampled from `grid_ref` so statistics remain identical.
420+
rand_prey = self.generator.random(self.grid.shape)
421+
rand_pred = self.generator.random(self.grid.shape)
422+
423+
prey_death_mask = (grid_ref == 1) & (rand_prey < float(self.params["prey_death"]))
424+
pred_death_mask = (grid_ref == 2) & (rand_pred < float(self.params["predator_death"]))
425+
426+
self.grid[prey_death_mask] = 0
427+
self.grid[pred_death_mask] = 0
428+
321429
# Precompute neighbor shifts
322430
if self.neighborhood == "neumann":
323431
shifts = [(-1, 0), (1, 0), (0, -1), (0, 1)]
324432
else:
325433
shifts = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
326434

327-
# Get occupied cells from the reference grid and shuffle
435+
# Get occupied cells from the original reference grid and shuffle.
436+
# We iterate over `grid_ref` so that sources can die and reproduce
437+
# in the same iteration, meaning we are order-agnostic.
328438
occupied = np.argwhere(grid_ref != 0)
329439
if occupied.size > 0:
330440
order = self.generator.permutation(len(occupied))
@@ -346,21 +456,9 @@ def update_async(self) -> None:
346456
if self.generator.random() < float(self.params["predator_birth"]):
347457
self.grid[nr, nc] = 2
348458

349-
# Vectorized synchronous deaths, based on grid_ref but only kill if
350-
# the current grid still matches the referenced species (so newly
351-
# occupied cells are not removed mistakenly).
352-
rand_prey = self.generator.random(self.grid.shape)
353-
rand_pred = self.generator.random(self.grid.shape)
354-
355-
prey_death_mask = (grid_ref == 1) & (rand_prey < float(self.params["prey_death"])) & (self.grid == 1)
356-
pred_death_mask = (grid_ref == 2) & (rand_pred < float(self.params["predator_death"])) & (self.grid == 2)
357-
358-
self.grid[prey_death_mask] = 0
359-
self.grid[pred_death_mask] = 0
360-
361459
def update(self) -> None:
362460
"""Dispatch to synchronous or asynchronous update mode."""
363461
if self.synchronous:
364462
self.update_sync()
365463
else:
366-
self.update_async()
464+
self.update_async()

prompts.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,4 +412,117 @@ Implement the synchronous updating for the PP class as a vectorized (optimized)
412412
self.grid[prey_death_mask] = 0
413413
self.grid[pred_death_mask] = 0
414414

415+
### changes
416+
Swapped the order of birth and death checks. This results in the same behavior, but removes the need to check whether the post-birth grid matches the reference grid.
417+
418+
## Add basic visualisations
419+
Basic tests pass and the grid updates seem plausible, but to see it in better detail we need to add visualisation. A first step in this is an imshow plot which updates every N iterations.
420+
421+
### prompt
422+
Add optional grid visualisation support to the CA class. Use matplotlib.pyplot's interactive mode to dynamically update the the plot every N intervals. Do this by adding a function visualize which takes the interval, the figsize, the pause, and a colormap. this function should turn on the visualisation, creating the necessary variables. Ensure that there is reasonable default behavior for all arguments, including the colormap. Make sure the plot gets updated each interval iterations inside of the run function, showing the current iteration. if this would bloat the run function, add a helper function to update the plot.
423+
424+
Next, update the PP class to change the default to a black empty cells, with green prey and red predators. Again, ensure that this can be overridden by the user when calling visualize, just like the other arguments.
425+
426+
For both classes, ensure that the matplotlib library is not needed to run the simulation without visualisation. Also, make the necessary overrides (for the colormap default for instance) as short as possible. I do not want classes inheriting CA to have to override the entire visualize function just to change the default color map. However, if visualization is off, there is no need to store any of the variables in the CA / PP class.
427+
428+
### output (also added the defaults)
429+
def run(self, steps: int) -> None:
430+
"""Run the CA for a number of steps.
431+
432+
Args:
433+
- steps (int): number of iterations to run (must be non-negative).
434+
435+
Returns: None
436+
"""
437+
assert isinstance(steps, int) and steps >= 0, "steps must be a non-negative integer"
438+
for i in range(steps):
439+
self.update()
440+
# Update visualization if enabled every `interval` iterations
441+
if getattr(self, "_viz_on", False):
442+
# iteration number is 1-based for display
443+
try:
444+
self._viz_update(i + 1)
445+
except Exception:
446+
# Don't let visualization errors stop the simulation
447+
pass
448+
449+
def visualize(
450+
self,
451+
interval: int = 1,
452+
figsize: Tuple[float, float] = (5, 5),
453+
pause: float = 0.001,
454+
cmap=None,
455+
) -> None:
456+
"""Enable interactive visualization of the grid.
457+
458+
Args:
459+
- interval: update plot every `interval` iterations (>=1)
460+
- figsize: figure size passed to matplotlib
461+
- pause: seconds to pause after draw (controls responsiveness)
462+
- cmap: colormap spec (string, sequence of colors, or matplotlib Colormap).
463+
464+
This function imports matplotlib lazily so simulations without
465+
visualization do not require matplotlib to be installed.
466+
"""
467+
if not isinstance(interval, int) or interval < 1:
468+
raise ValueError("interval must be a positive integer")
469+
470+
# Lazy import so matplotlib is optional
471+
import matplotlib.pyplot as plt
472+
from matplotlib.colors import ListedColormap
473+
474+
# Resolve default cmap: prefer instance attribute override
475+
c_spec = self._default_cmap if cmap is None else cmap
476+
477+
# Build a discrete colormap with entries for states 0..n_species
478+
n_colors_needed = self.n_species + 1
479+
if isinstance(c_spec, str):
480+
# request discrete version of named colormap
481+
cmap_obj = plt.get_cmap(c_spec, n_colors_needed)
482+
elif isinstance(c_spec, (list, tuple)):
483+
colors = list(c_spec)
484+
if len(colors) < n_colors_needed:
485+
colors = colors + [colors[-1]] * (n_colors_needed - len(colors))
486+
cmap_obj = ListedColormap(colors[:n_colors_needed])
487+
else:
488+
# Assume user provided a Colormap-like object
489+
cmap_obj = c_spec
490+
491+
plt.ion()
492+
fig, ax = plt.subplots(figsize=figsize)
493+
im = ax.imshow(self.grid, cmap=cmap_obj, interpolation="nearest", vmin=0, vmax=self.n_species)
494+
ax.set_title("Iteration 0")
495+
plt.show(block=False)
496+
fig.canvas.draw()
497+
plt.pause(pause)
498+
499+
# Store visualization state on the instance (only when visualization enabled)
500+
self._viz_on = True
501+
self._viz_interval = interval
502+
self._viz_fig = fig
503+
self._viz_ax = ax
504+
self._viz_im = im
505+
self._viz_cmap = cmap_obj
506+
self._viz_pause = float(pause)
507+
508+
def _viz_update(self, iteration: int) -> None:
509+
"""Update the interactive plot if the configured interval has passed.
510+
511+
This function also performs the minimal redraw using `plt.pause` so the
512+
plot remains responsive.
513+
"""
514+
if not getattr(self, "_viz_on", False):
515+
return
516+
if (iteration % int(self._viz_interval)) != 0:
517+
return
518+
519+
# Lazy import for pause; matplotlib already imported in visualize
520+
import matplotlib.pyplot as plt
521+
522+
self._viz_im.set_data(self.grid)
523+
self._viz_ax.set_title(f"Iteration {iteration}")
524+
# draw/update
525+
self._viz_fig.canvas.draw_idle()
526+
plt.pause(self._viz_pause)
527+
415528
### changes

0 commit comments

Comments
 (0)