Source code for modelly.zoo.forest_fire.model

"""Forest fire and succession on a raster.

A raster of cells, each empty, a tree, burning or burnt. At the start every cell holds a tree
with probability ``density`` (or with its own probability, from a ``cover`` raster such as the
values of a ``modelly.space.geo.RasterLayer``), and the trees of the west edge are set alight.
Every step a tree next to a burning cell (von Neumann neighbourhood) catches fire and a
burning cell burns out. With ``growth > 0`` empty and burnt cells grow a tree with that
probability per step and with ``lightning > 0`` a tree is struck with that probability per
step: the Drossel-Schwabl forest-fire model of succession and disturbance.

The state is one integer per cell, a property layer of the grid (``model.layer``, shaped
like the grid); the spread is one sparse product per step, ``CellIndex.neighbor_sum``, over
the grid's adjacency.

Validation: without growth and lightning the fire burns exactly the clusters of trees that
touch the west edge, so the model is site percolation on the square lattice. The share of
trees burnt jumps, and the fire starts to reach the east edge, at the percolation threshold
``p_c = 0.592746`` (Newman and Ziff 2000): it stays small below and covers most trees above.

References:
    Drossel, B. and Schwabl, F. (1992). Self-organized critical forest-fire model.
    Physical Review Letters, 69(11), 1629-1632.

    Newman, M. E. J. and Ziff, R. M. (2000). Efficient Monte Carlo algorithm and
    high-precision results for percolation. Physical Review Letters, 85(19), 4104-4107.

    Stauffer, D. and Aharony, A. (1994). Introduction to Percolation Theory, 2nd ed.
    Taylor and Francis.
"""

from __future__ import annotations

from enum import IntEnum
from functools import partial

import numpy as np

from modelly.core.model import Model
from modelly.data.collector import DataCollector
from modelly.scenarios.scenario import Scenario
from modelly.space.cells.index import CellIndex
from modelly.space.grid.grids import OrthogonalVonNeumannGrid

# Site-percolation threshold of the square lattice (Newman and Ziff 2000).
PERCOLATION_THRESHOLD = 0.592746
WEST = 0


[docs] class Cover(IntEnum): """Cell states.""" EMPTY = 0 TREE = 1 BURNING = 2 BURNT = 3
[docs] class ForestScenario(Scenario): """Parameters of the forest-fire model. Attributes: width: cells from west to east. height: cells from south to north. density: probability that a cell starts with a tree. cover: optional per-cell tree probability, shape ``(width, height)`` (a raster's values); replaces ``density`` when given. growth: probability per step that an empty or burnt cell grows a tree. lightning: probability per step that a tree is struck and catches fire. steps: default run length used by ``run``. """ width: int = 100 height: int = 100 density: float = 0.6 cover: object = None growth: float = 0.0 lightning: float = 0.0 steps: int = 2_000
[docs] class ForestFire(Model): """Fire spreading through a tree raster, with optional regrowth and lightning. Example: >>> model = ForestFire(ForestScenario(rng=1, width=30, height=30)) >>> model.run() >>> 0.0 <= model.burnt_share() <= 1.0 True """ def __init__(self, scenario: ForestScenario = ForestScenario) -> None: """Plant the trees and ignite the west edge.""" super().__init__(scenario=scenario) s = self.scenario self.grid = OrthogonalVonNeumannGrid((s.width, s.height), random=self.random) self.index = CellIndex(self.grid) chance = np.full((s.width, s.height), s.density) if s.cover is None else s.cover chance = np.asarray(chance, dtype=float).ravel() trees = self.rng.random(self.index.n) < chance self.state = np.where(trees, Cover.TREE, Cover.EMPTY).astype(np.int8) self.layer = self.index.to_layer(self.state) self.layer[WEST][self.layer[WEST] == Cover.TREE] = Cover.BURNING self.initial_trees = int(trees.sum()) self.datacollector = DataCollector( model_reporters={c.name: partial(count_of, cover=c) for c in Cover} ) self.datacollector.collect(self)
[docs] def step(self) -> None: """Spread, burn out, then (in succession mode) strike and regrow.""" s = self.scenario state = self.state burning = state == Cover.BURNING exposed = self.index.neighbor_sum(burning.astype(np.int32)) > 0 catch = (state == Cover.TREE) & exposed if s.lightning > 0: catch |= (state == Cover.TREE) & ( self.rng.random(state.shape) < s.lightning ) state[burning] = Cover.BURNT state[catch] = Cover.BURNING if s.growth > 0: bare = (state == Cover.EMPTY) | (state == Cover.BURNT) state[bare & (self.rng.random(state.shape) < s.growth)] = Cover.TREE self.running = bool(burning.any() or s.growth > 0 or s.lightning > 0) self.datacollector.collect(self)
[docs] def run(self, steps: int | None = None) -> None: """Run ``steps`` steps (the scenario's by default), or until the fire is out.""" for _ in range(self.scenario.steps if steps is None else steps): if not self.running: break self.step()
[docs] def burnt_share(self) -> float: """Share of the initial trees that have burnt or are burning.""" fire = np.isin(self.state, (Cover.BURNING, Cover.BURNT)).sum() return float(fire / max(self.initial_trees, 1))
[docs] def spans(self) -> bool: """Whether the fire has reached the east edge.""" east = self.layer[-1] return bool(np.isin(east, (Cover.BURNING, Cover.BURNT)).any())
[docs] def count_of(model: ForestFire, cover: Cover) -> int: """The number of cells in state ``cover`` (a model reporter).""" return int((model.state == cover).sum())