Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 8 additions & 45 deletions examples/hex_snowflake/hex_snowflake/cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,15 @@


class Cell(FixedAgent):
"""Represents a single ALIVE or DEAD cell in the simulation."""
"""Lightweight marker agent used only for visualization.

DEAD = 0
ALIVE = 1
The snowflake's frozen / empty state is stored in model.state_grid rather
than inside this agent. This follows the pattern from Issue #366: patch
agents that store only environment state are replaced by a grid-level
NumPy array on the model.
"""

def __init__(self, cell, model, init_state=DEAD):
"""Create a cell, in the given state, at the given x, y position."""
def __init__(self, cell, model):
"""Place a visualization marker at the given grid cell."""
super().__init__(model)
self.cell = cell
self.state = init_state
self._next_state = None
self.is_considered = False

@property
def is_alive(self):
return self.state == self.ALIVE

@property
def considered(self):
return self.is_considered is True

def determine_state(self):
"""Compute if the cell will be dead or alive at the next tick. A dead
cell will become alive if it has only one neighbor. The state is not
changed here, but is just computed and stored in self._next_state,
because our current state may still be necessary for our neighbors
to calculate their next state.
When a cell is made alive, its neighbors are able to be considered
in the next step. Only cells that are considered check their neighbors
for performance reasons.
"""
# assume no state change
self._next_state = self.state

if not self.is_alive and self.is_considered:
# Get the neighbors and apply the rules on whether to be alive or dead
# at the next tick.
live_neighbors = sum(
neighbor.is_alive for neighbor in self.cell.neighborhood.agents
)

if live_neighbors == 1:
self._next_state = self.ALIVE
for a in self.cell.neighborhood.agents:
a.is_considered = True

def assume_state(self):
"""Set the state to the new computed state"""
self.state = self._next_state
86 changes: 71 additions & 15 deletions examples/hex_snowflake/hex_snowflake/model.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,92 @@
import mesa
import numpy as np
from mesa.discrete_space import HexGrid

from .cell import Cell

# Environment state is stored on the model as a NumPy array instead of inside
# individual Cell agents. Each grid position is represented by a value in
# state_grid (0 = empty, 1 = frozen) and a boolean in is_considered that
# tracks which cells sit on the frontier of the growing snowflake. Only
# frontier cells check their neighbors each step, preserving the original
# performance optimization.


class HexSnowflake(mesa.Model):
"""Represents the hex grid of cells. The grid is represented by a 2-dimensional array
of cells with adjacency rules specific to hexagons.
"""Represents the hex grid of cells. The grid is represented by a 2-dimensional
array of cells with adjacency rules specific to hexagons.

Environment state (frozen / empty) lives in model.state_grid rather than
inside agent objects, following the pattern from Issue #366.
"""

def __init__(self, width=50, height=50, rng=None):
"""Create a new playing area of (width, height) cells."""
super().__init__(rng=rng)
# Use a hexagonal grid, where edges wrap around.
self.grid = HexGrid((width, height), capacity=1, torus=True, random=self.random)

# Place a dead cell at each location.
# Use a hexagonal grid where edges wrap around.
self.grid = HexGrid((width, height), capacity=1, torus=True, random=self.rng)

# Create a lightweight Cell agent at each position for visualization.
for entry in self.grid.all_cells:
Cell(entry, self)

# activate the center(ish) cell.
centerish_cell = self.grid[(width // 2, height // 2)]
centerish_cell.agents[0].state = 1
for a in centerish_cell.neighborhood.agents:
a.is_considered = True
# Environment state stored as a NumPy array: 0 = empty, 1 = frozen.
self.state_grid = np.zeros((width, height), dtype=np.int8)

# Track which cells are on the frontier (adjacent to at least one
# frozen cell). Only frontier cells are evaluated each step.
self.is_considered = np.zeros((width, height), dtype=bool)

# Seed the snowflake at the center cell.
cx, cy = width // 2, height // 2
self.state_grid[cx, cy] = 1

# Mark the neighbors of the seed as frontier cells.
centerish_cell = self.grid[(cx, cy)]
for neighbor in centerish_cell.neighborhood:
nx, ny = neighbor.coordinate
self.is_considered[nx, ny] = True

self.running = True

def step(self):
"""Perform the model step in two stages:
- First, all cells assume their next state (whether they will be dead or alive)
- Then, all cells change state to their next state
"""Advance the snowflake by one tick.

A dead cell on the frontier becomes frozen if exactly one of its
neighbors is already frozen. The frontier expands to include the
neighbors of any newly frozen cell.

State is computed into new_state before being applied so that all
cells use the same snapshot of the grid, matching the original
two-phase determine_state / assume_state design. We iterate over
Cell agents (via self.agents) rather than the grid's CellCollection
because AgentSet iteration is significantly faster.
"""
self.agents.do("determine_state")
self.agents.do("assume_state")
new_state = self.state_grid.copy()
new_considered = self.is_considered.copy()

for agent in self.agents:
x, y = agent.cell.coordinate

if self.state_grid[x, y] == 1:
continue # already frozen, nothing to do

if not self.is_considered[x, y]:
continue # not on the frontier, skip for performance

live_neighbors = sum(
self.state_grid[nx, ny]
for n in agent.cell.neighborhood
for nx, ny in [n.coordinate]
)

if live_neighbors == 1:
new_state[x, y] = 1
# Expand the frontier to include this cell's neighbors.
for n in agent.cell.neighborhood:
nx, ny = n.coordinate
new_considered[nx, ny] = True

self.state_grid = new_state
self.is_considered = new_considered
25 changes: 18 additions & 7 deletions examples/hex_snowflake/hex_snowflake/portrayal.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
def portrayCell(cell):
"""This function is registered with the visualization server to be called
each tick to indicate how to draw the cell in its current state.
:param cell: the cell in the simulation
:return: the portrayal dictionary.
"""Portrayal function called each tick to describe how to draw a cell.

Reads the frozen / empty state from model.state_grid rather than from an
agent attribute, because state now lives on the model as a NumPy array.

:param cell: the grid cell in the simulation
:return: the portrayal dictionary
"""
if cell is None:
raise AssertionError

# Retrieve the Cell agent sitting on this grid position.
agent = cell.agents[0] if cell.agents else None

# Look up the frozen state from the model-level NumPy array.
x, y = cell.coordinate
state = agent.model.state_grid[x, y] if agent else 0

return {
"Shape": "hex",
"r": 1,
"Filled": "true",
"Layer": 0,
"x": cell.x,
"y": cell.y,
"Color": "black" if cell.isAlive else "white",
"x": x,
"y": y,
"Color": "black" if state == 1 else "white",
}
Loading