Build a Forest Fire Simulation

Page content

Cellular Automata From First Principles 09: Build a Forest Fire Simulation

A forest fire is almost an ideal cellular-automaton exercise.

The visible states are obvious:

0 = empty
1 = tree
2 = burning

The interactions are local.

And small changes in fuel density or ignition assumptions can produce very different global outcomes.

This is not intended as a high-fidelity wildfire model.

It is a deliberately simplified spread model designed to isolate one mechanism:

How does local fuel connectivity determine whether fire propagates through a landscape?


Initialize the forest

import numpy as np

EMPTY = 0
TREE = 1
FIRE = 2


def make_forest(
    rows=150,
    cols=200,
    tree_density=0.65,
    seed=42,
):
    rng = np.random.default_rng(seed)

    grid = np.zeros(
        (rows, cols),
        dtype=np.uint8,
    )

    grid[
        rng.random((rows, cols)) < tree_density
    ] = TREE

    return grid

Ignite one cell:

forest = make_forest()

forest[
    forest.shape[0] // 2,
    forest.shape[1] // 2,
] = FIRE

Now tree_density becomes an experimental parameter.

At low density, burning regions may encounter gaps.

At high density, the tree network may become connected enough for fire to travel much farther.


Detect burning neighbors without wraparound

For a literal forest map, the left edge should not normally touch the right edge.

That means periodic np.roll boundaries would create a modeling artifact.

A useful helper is a fixed-edge shift:

def shift_fixed(a, dy, dx):
    out = np.zeros_like(a)

    src_y0 = max(0, -dy)
    src_y1 = a.shape[0] - max(0, dy)
    src_x0 = max(0, -dx)
    src_x1 = a.shape[1] - max(0, dx)

    dst_y0 = max(0, dy)
    dst_y1 = a.shape[0] - max(0, -dy)
    dst_x0 = max(0, dx)
    dst_x1 = a.shape[1] - max(0, -dx)

    out[
        dst_y0:dst_y1,
        dst_x0:dst_x1,
    ] = a[
        src_y0:src_y1,
        src_x0:src_x1,
    ]

    return out

Then:

def burning_neighbor_mask(grid):
    burning = grid == FIRE
    near_fire = np.zeros_like(
        burning,
        dtype=bool,
    )

    for dy in (-1, 0, 1):
        for dx in (-1, 0, 1):
            if dy == 0 and dx == 0:
                continue

            near_fire |= shift_fixed(
                burning,
                dy,
                dx,
            )

    return near_fire

Boundary behavior is part of the model, so it should be visible in the implementation.


Write one synchronous update

def forest_step(
    grid,
    rng,
    lightning_probability=0.0,
):
    next_grid = grid.copy()

    burning = grid == FIRE
    trees = grid == TREE
    near_fire = burning_neighbor_mask(grid)

    next_grid[burning] = EMPTY

    ignite_from_neighbor = trees & near_fire

    lightning = (
        trees
        & (
            rng.random(grid.shape)
            < lightning_probability
        )
    )

    next_grid[
        ignite_from_neighbor | lightning
    ] = FIRE

    return next_grid

Each transition remains readable.

burning -> empty

tree + burning neighbor -> burning

tree + lightning event -> burning

That readability matters more than compressing the entire rule into one expression.


Run until the fire dies out

def run_fire(
    initial,
    steps=250,
    seed=123,
    lightning_probability=0.0,
):
    rng = np.random.default_rng(seed)
    grid = initial.copy()
    history = []

    for _ in range(steps):
        history.append(grid.copy())

        if (
            not np.any(grid == FIRE)
            and lightning_probability == 0
        ):
            break

        grid = forest_step(
            grid,
            rng,
            lightning_probability,
        )

    return history

With spontaneous ignition disabled:

no burning cells
      ->
no future burning cells

That is a useful termination invariant.

Forest-fire spread from one ignition


Measure the state populations

The animation tells us where the fire moved.

Population curves tell us what the process did overall.

def state_counts(grid):
    return {
        "empty": int(
            np.count_nonzero(grid == EMPTY)
        ),
        "trees": int(
            np.count_nonzero(grid == TREE)
        ),
        "burning": int(
            np.count_nonzero(grid == FIRE)
        ),
    }

Collect that every generation.

Forest state populations through time

The burning population rises while connected fuel is available, then collapses as the local front runs out of reachable trees.


Measure burn fraction

def fire_summary(initial, final):
    initial_trees = np.count_nonzero(
        initial == TREE
    )

    surviving_trees = np.count_nonzero(
        final == TREE
    )

    burned = initial_trees - surviving_trees

    return {
        "initial_trees": int(initial_trees),
        "surviving_trees": int(
            surviving_trees
        ),
        "burned": int(burned),
        "burn_fraction": (
            burned / initial_trees
            if initial_trees
            else 0.0
        ),
    }

Now sweep density across many seeds.

That experiment is more informative than selecting one dramatic animation.

We can ask:

At density d,
what is the distribution of burn fractions?

That question separates a stochastic realization from a model-level claim.


Add direction only when the mechanism demands it

The current model treats all neighboring directions equally.

A wind-biased model would not.

Conceptually:

burning neighbor west of tree
    -> higher ignition probability

burning neighbor east of tree
    -> lower ignition probability

That changes the neighborhood from an unordered collection into a directional one.

The general lesson is:

An anisotropic world requires an anisotropic local rule.

Do not add wind as a cosmetic animation effect. Put it into the transition probabilities.


Add regrowth to create a persistent system

If empty cells can regrow trees and trees can ignite spontaneously, the world no longer terminates.

A slow regrowth process can compete with a fast burning process:

slow:
trees accumulate
fuel network forms

fast:
ignition occurs
fire consumes connected fuel

The interaction between timescales can generate long-running global dynamics.


One idea to keep

This chapter is not valuable because it looks like a fire.

It is valuable because we can state exactly what the model contains:

space
state
neighborhood
boundary condition
spread rule
random process
initial fuel density

and then ask how changing one ingredient alters the outcome distribution.

In the next chapter we will use the same local viewpoint for a conserved moving quantity: cars on a one-dimensional road.