Chapter 24: Cellular Automata with DisSModel¶
Part IV — DisSModel: Core and Paradigms
Implemented by the dissmodel-ca package.
Live demo: try the models in this chapter directly in the browser — dissmodel-ca-demo on Hugging Face Spaces.
Learning Objectives¶
By the end of this chapter you will be able to:
- Go beyond Chapter 21's Game of Life teaser into the full
dissmodel-camodel library - Compare TerraME Lua cellular automata to their DisSModel equivalents, rule for rule
- Explain why a model with no "empty" state can't exhibit a percolation threshold
- Measure, not just claim, the performance gap between vector and raster substrates
# Standard imports — add chapter-specific imports below
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
Chapter 23 showed that DisSModel's lifecycle isn't inherently spatial. This chapter is the opposite case: cellular automata are the paradigm where space is the model — every cell's next state depends only on itself and its neighbors, with no central controller anywhere in the equations. Chapter 21's Game of Life was a first taste; dissmodel-ca is a full library built on that same idea.
The dissmodel-ca Model Library¶
dissmodel-ca ships eight models, all built on the same CellularAutomaton base class Chapter 21 introduced, spanning both classic textbook automata and research models from this book's own group:
| Model | Substrate | Description |
|---|---|---|
GameOfLife |
Vector / Raster | Classic Conway's simulation |
FireModel |
Vector / Raster | Forest fire spread |
Snow |
Vector | Snowfall accumulation and gravity dynamics |
Growth |
Vector | Stochastic radial growth |
Anneal |
Vector | Binary system relaxation via majority-vote rule |
Excitable |
Vector | Excitable medium waves (spiral/ring patterns) |
Parasit |
Vector | Host-parasite spatial dynamics |
Interspecific |
Vector | Grass species competition |
Every model in this table implements the same contract: a rule(idx) method deciding one cell's next state from its neighbors, applied automatically to every cell each tick by CellularAutomaton.execute(). That shared shape is also what makes the package's Streamlit explorer (Chapter 22) possible without listing models by hand — auto-discovery works precisely because every concrete CellularAutomaton subclass looks the same from the outside.
Theory: What Is a Cellular Automaton¶
Chapter 23's system dynamics models treated a whole stock as one undifferentiated block — no spatial structure at all. Cellular automata exist to add exactly that: a grid of autonomous cells, each changing state based only on its own state and its neighbors', with no cell able to see the whole grid at once.
Cellular automata were proposed by John von Neumann — the same von Neumann of computer architecture — while searching for a model of logical systems capable of self-replication. Every cellular automaton, however different the phenomenon it models, is defined by six elements: a grid of cells, a neighborhood, a finite set of discrete states, a finite set of transition rules, an initial state, and discrete time.
Two neighborhood shapes recur throughout this chapter, and both should already be familiar: Von Neumann (4 orthogonal neighbors) and Moore (all 8 surrounding cells, diagonals included) — in dissmodel-ca these map directly onto Rook and Queen, the exact contiguity definitions Chapter 10 built by hand.
Synchronization, Made Implicit¶
TerraME kept two copies of every CellularSpace — one holding past values, one holding present values — because every rule had to read the past copy while writing the present one, or cells updated in different order within the same tick would see an inconsistent mix of old and new state. CellularAutomaton.rule(idx) achieves the same consistency without ever naming a past attribute: execute() reads the state consolidated at the end of the previous tick, for every cell, before writing any new one. The synchronization TerraME made an explicit, named concept, DisSModel gets for free from the order execute() already runs in.
GameOfLife: TerraME vs DisSModel¶
Conway's rule — die below 2 or above 3 live neighbors, survive at exactly 2 or 3, come alive at exactly 3 — is identical in both languages. Life.lua builds its neighborhood with wrap = true (a toroidal grid, where edges connect to the opposite side); GameOfLife does not enable wrap by default, so behavior differs at the grid boundary specifically, not the interior — worth checking before porting a TerraME model that depends on a toroidal edge.
from dissmodel.core import Environment
from dissmodel.geo import vector_grid
from dissmodel_ca.models import GameOfLife
gdf = vector_grid(dimension=(15, 15), resolution=1, attrs={"state": 0})
env = Environment(start_time=0, end_time=1)
gol = GameOfLife(gdf=gdf)
gol.initialize()
print("Alive before:", int(gdf["state"].sum()))
env.run()
print("Alive after one tick:", int(gdf["state"].sum()))
Alive before: 139 Running from 0 to 1 (duration: 1) Alive after one tick: 50
Case Study: Fire in the Forest¶
FireModel is the classic first full application of the six CA elements above: a FOREST / BURNING / BURNED state space, a Rook (Von Neumann) neighborhood, and one rule — a FOREST cell catches fire if any neighbor is BURNING; a BURNING cell becomes BURNED after exactly one tick:
from dissmodel_ca.models import FireModel, FireState
gdf = vector_grid(dimension=(40, 40), resolution=1, attrs={"state": 0})
env = Environment(start_time=0, end_time=30)
fire = FireModel(gdf=gdf, initial_fire_density=0.05, seed=42)
fire.initialize()
print("Initial:", gdf["state"].value_counts().to_dict())
env.run()
print("After 30 ticks:", gdf["state"].value_counts().to_dict())
Initial: {0: 1520, 1: 80}
Running from 0 to 30 (duration: 30)
After 30 ticks: {2: 1600}
Watching only the printed state counts hides where the fire actually is on the grid. Attaching a Map component — exactly the way Chapter 21's GameOfLife example did — colors each cell by its state and redraws it every tick, live:
from dissmodel.visualization import Map
from matplotlib.colors import ListedColormap
gdf = vector_grid(dimension=(40, 40), resolution=1, attrs={"state": 0})
env = Environment(start_time=0, end_time=2)
fire = FireModel(gdf=gdf, initial_fire_density=0.05, seed=42)
fire.initialize()
cmap = ListedColormap(["forestgreen", "orangered", "black"])
Map(gdf=gdf, plot_params={"column": "state", "cmap": cmap, "ec": "gray", "vmin": 0, "vmax": 2})
env.run()
Green cells are still forest, orange cells are actively burning, black cells are already burned — a single glance at the last frame shows the fire's actual shape and reach, something the earlier cell's raw counts never could. This is the same Map class, called the same way, on a completely different model — the substrate-agnostic visualization Chapter 21 first introduced doesn't care which CellularAutomaton subclass it's attached to.
The propagation rule matches TerraME's Fire.lua step by step — only the neighborhood library and an IntEnum (FireState.FOREST, .BURNING, .BURNED) in place of Lua strings differ.
Percolation and Density Thresholds¶
The textbook version of this model usually adds a fourth state, EMPTY — cells with nothing flammable at all — and the resulting phenomenon is percolation: below some critical forest density, empty gaps break the grid into disconnected patches, and a fire started in one patch self-extinguishes without ever reaching the others; above that density, the forest forms one giant connected component, and a single spark burns nearly all of it.
FireModel as shipped, though, has only three states — FOREST, BURNING, BURNED — no EMPTY. initial_fire_density controls what fraction of cells start already burning, not how much of the grid is flammable in the first place. Every non-burning cell starts as forest. Worth checking directly rather than assuming the textbook description applies unmodified:
for density in [0.3, 0.05, 0.01]:
gdf = vector_grid(dimension=(40, 40), resolution=1, attrs={"state": 0})
env = Environment(start_time=0, end_time=100)
fire = FireModel(gdf=gdf, initial_fire_density=density, seed=1)
fire.initialize()
initial_burning = int((gdf["state"] == FireState.BURNING).sum())
env.run()
burned = int((gdf["state"] == FireState.BURNED).sum())
print(f"density={density:<5} initial_burning={initial_burning:<4} final_burned={burned} / {len(gdf)}")
Running from 0 to 100 (duration: 100) density=0.3 initial_burning=495 final_burned=1600 / 1600 Running from 0 to 100 (duration: 100) density=0.05 initial_burning=88 final_burned=1600 / 1600 Running from 0 to 100 (duration: 100) density=0.01 initial_burning=13 final_burned=1600 / 1600
Even at initial_fire_density=0.01 — a bare handful of sparks on a 1,600-cell grid — the entire grid ends up burned. There is no density threshold here, and there can't be: with no EMPTY state to act as a firebreak, every forest cell on a connected grid is reachable from any spark eventually. This isn't a bug — it's an honest, checkable consequence of what the model's state space actually contains, and exactly the kind of claim worth verifying against real code rather than repeating from a textbook description of cellular automata in general. Building a genuine percolation demonstration would mean extending FireModel's state space with an EMPTY value and updating rule() to treat it as non-flammable — a natural extension exercise, not a change to the package itself.
Vector vs Raster: Comparing Implementations¶
FireModel ships in both forms, exactly like Chapter 21's GameOfLife — same rule, same propagation logic, different substrate underneath. Timing both on the same grid and tick count makes Chapter 20's performance argument concrete instead of theoretical:
import time
from dissmodel.geo.raster import raster_grid
from dissmodel_ca.models import FireModelRaster
# Vector
gdf = vector_grid(dimension=(40, 40), resolution=1, attrs={"state": 0})
env = Environment(start_time=0, end_time=30)
fire_vec = FireModel(gdf=gdf, initial_fire_density=0.01, seed=1)
fire_vec.initialize()
start = time.perf_counter()
env.run()
vector_ms = (time.perf_counter() - start) * 1000
# Raster
backend = raster_grid(rows=40, cols=40, attrs={"state": 0})
env = Environment(start_time=0, end_time=30)
fire_ras = FireModelRaster(backend=backend, initial_fire_density=0.01, seed=1)
fire_ras.initialize()
start = time.perf_counter()
env.run()
raster_ms = (time.perf_counter() - start) * 1000
print(f"Vector: {vector_ms:8.1f} ms")
print(f"Raster: {raster_ms:8.1f} ms")
print(f"Raster is {vector_ms / raster_ms:.0f}x faster on this 40x40 grid, 30 ticks")
Running from 0 to 30 (duration: 30) Running from 0 to 30 (duration: 30) Vector: 32081.8 ms Raster: 4.0 ms Raster is 8091x faster on this 40x40 grid, 30 ticks
The gap is not subtle — on this grid it comes out well over a thousand times. FireModel.rule(idx) runs once per cell, in pure Python, via self.gdf.index.map(self.rule): arbitrary Python can't be vectorized, so every one of 1,600 cells pays interpreter overhead every single tick. FireModelRaster.execute() instead applies the propagation logic to the whole grid as one array operation — the same kind of focal, whole-array computation Chapter 8 introduced and Chapter 20 argued for directly. Neither implementation is "wrong": the vector version stays easier to read cell-by-cell and easier to combine with exact, irregular geometry; the raster version is what you reach for once the grid — or the number of experiments run over it — grows past what per-cell Python can keep up with.
Exercises¶
- Rook, not Queen.
FireModel.setup()usesRookinstead of the package's more common default,Queen. Looking back at Chapter 10's Queen-vs-Rook distinction, explain in one sentence why a fire model specifically calls for the narrower neighborhood. - Wrap the edges.
GameOfLife's default neighborhood doesn't wrap at the grid boundary, unlike TerraME'sLife.lua. Sketch — in words, no code required — what would need to change increate_neighborhood()'s call for wrap-around edges to be enabled. - Add an
EMPTYstate. Without touching the installed package, sketch (in a markdown cell, as pseudocode) how you would extendFireStatewith anEMPTYvalue and adjustrule()so thatEMPTYcells never catch fire. What would you expect the density=0.3 percolation experiment to look like with that change in place? - Pick an unfamiliar model. Choose one of
Anneal,Excitable,Snow,Growth,Parasit, orInterspecificfrom the model table, install nothing new, and read its docstring alone (help(dissmodel_ca.models.Anneal), for instance). Write a one-paragraph description of the phenomenon it models and which neighborhood strategy — Rook or Queen — you'd guess it relies on, before checking.
# Your code here
Summary¶
Key concepts introduced¶
- The six elements of any cellular automaton: grid, neighborhood, states, rules, initial state, discrete time
CellularAutomaton.rule(idx)as the contract every model indissmodel-caimplements, and why it makes DisSModel's synchronization implicit where TerraME needed an explicitpastcopyGameOfLifeandFireModel, TerraME Lua compared line-by-line against DisSModel Python, including a documented known gap (toroidal wrap not enabled by default)- Why
FireModel's three-state space can't exhibit a genuine percolation threshold — checked directly against the running code, not assumed from the general theory - A measured, not claimed, vector-vs-raster performance gap on identical fire-spread logic — Chapter 20's argument, made concrete with real numbers
Chapter 25 moves from cells that only know their neighbors to agents that can move, decide, and interact — dissmodel-abm, built on the same Model foundation from Chapter 21 once again.
Further Reading¶
- von Neumann, J., & Burks, A. W. (1966). Theory of Self-Reproducing Automata. University of Illinois Press — the origin of cellular automata as a formal idea
- Batty, M. (2005). Cities and Complexity. MIT Press — the source of this chapter's epigraph on emergent phenomena in cellular automata
- dissmodel-ca on GitHub: https://github.com/DisSModel/dissmodel-ca
- Live demo (no installation required): https://huggingface.co/spaces/profsergiocosta/dissmodel-ca-demo