A fast, deliberately narrow alternative to spread() for the common case: a
per-cell probability of being spread to, one or more starting cells, an
optional per-fire maximum size, and cell indices back.
spreadCpp(
landscape,
loci,
spreadProb,
maxSize = Inf,
directions = 8L,
iterations = Inf,
minSize = 0,
jumpTries = 0L,
jumpMeanDist = 3,
minSizeTries = 100L,
stopCells = NULL,
stopEvent = NULL,
stopAt = NULL
)A SpatRaster; only its geometry (number of columns and
cells) is used.
Integer vector of starting cell indices, one per fire.
Numeric of length 1 or terra::ncell(landscape), or a
SpatRaster or RasterLayer congruent with landscape: the probability
that a cell is spread to.
Numeric of length 1 or length(loci): the largest number of
cells each fire may reach. Inf (the default) means no limit.
4 or 8. Default 8.
Maximum number of generations. Default Inf.
Numeric of length 1 or length(loci): a size each fire reaches
before the normal generation rule applies. While a fire has fewer than
minSize cells, all of its burning cells stay active from one generation to
the next (persistence) and draw again, with the same spreadProb, against
their still-unburned neighbours, so the fire grows into a patch shaped by
spreadProb rather than dying out early. The generation that reaches
minSize stops there, exactly. The next generation spreads from every cell
of the patch that still has an unburned, burnable neighbour (its perimeter),
not only from the cells that caught last; after that only the cells that
caught in the last generation spread, as usual. A fire under minSize with no unburned
neighbour whose spreadProb is above 0 (and not NA) stops where it is;
otherwise only iterations limits how long a very low spreadProb takes to
get there. Must not exceed maxSize. The default, 0, is the behaviour
without it, with the same random draws. Used to start every escaped fire at
the escape size, e.g. minSize = 9 cells for 50 ha at 5.76-ha cells.
Integer, default 0 (off). Only for a fire that is still under
minSize and stuck – none of its burning cells has an unburned neighbour
with spreadProb above 0 – up to jumpTries attempts are made to jump: a
random cell of the fire, a distance drawn from an exponential with mean
jumpMeanDist, truncated to 1.5-20 cells so it clears the adjacent ring,
and a uniform direction. A target off the landscape, already burned or
unburnable is rejected; otherwise it catches with its spreadProb. The first
success joins the fire and the fire keeps growing from all its cells. If
every attempt fails the fire stops where it is. With 0 no extra random draws
are made. This is a fixed rule for fires trapped in small patches, not a
fitted spotting process; spotting tied to fire weather is future work.
Mean jump distance in cells (see jumpTries). Default 3.
Integer, default 100. With minSize above 1, minSize is
reached by rejection rather than persistence: each fire spreads normally
from its one ignition cell, and a fire that dies below minSize is undone
(its cells freed, its rows dropped) and started again from its ignition cell,
up to minSizeTries times. A fire that reaches minSize is kept and goes on
uncut, so its size, shape and burning front are those of a fire that got
there under its own spreadProb – a sample from the fires that reach
minSize. After minSizeTries rejections the fire falls back to the
persistence rule of minSize (and jumpTries) from its ignition cell. The
expected number of tries per fire is 1 / P(reaching minSize), so a low
spreadProb or a small burnable patch costs more; the result carries an
attribute "minSizeTries", a data.table with each fire's id, its
rejected tries, and whether it used the fallback. minSizeTries = 0
gives persistence alone (the behaviour before this argument existed, with the
same random draws). Without minSize it has no effect.
Optional per-fire stop rule; all NULL
(the default) leaves the results and the random numbers used exactly as
without them. stopCells is a vector of cell indices (indexed like loci),
stopEvent the same length: the fire (an index into loci, 1-based) each
stop cell belongs to, and stopAt an integer of length 1 or length(loci):
the number of a fire's own stop cells at which it stops. A cell may be a
stop cell of several fires, and repeated (cell, fire) pairs count once. Only
a fire's own stop cells count towards its stopAt, and a fire with no stop
cells never stops by this rule. The fire stops the moment it burns its
stopAt-th stop cell, in the middle of a generation, in the same way as at
maxSize: the cells already burned stay burned and the other fires carry
on. It interacts with minSize and jumpTries as maxSize does: a fire
that has stopped is never pushed on to minSize or made to jump, and a try
rejected under minSizeTries is undone with its count. The ignition cell
counts if it is a stop cell. Giving stopCells needs stopEvent and
stopAt.
A data.table keyed on id, with integer columns id,
initialLocus and indices, and a logical active (always FALSE, since
the spread has finished) – the same shape spread() returns for
returnIndices = TRUE. When minSize is above 1 and minSizeTries above 0,
it also carries an attribute "minSizeTries": a data.table with one row per
fire (in loci order) and columns id, tries (its rejected attempts) and
fallback (TRUE if it ran out of tries and used persistence). The
attribute is transient: most data.table operations drop it, e.g. subsetting
or dt[, .N, by = "id"], so read it from the returned object straight away,
e.g. attr(out, "minSizeTries").
Growth happens in generations. The cells that caught in one generation are the only ones that try to spread in the next, so fires grow outwards and a fire's extent is tied to how many generations it has had.
One draw per (burning cell, unburned neighbour) pair, compared against
spreadProb of the neighbour, not of the cell doing the spreading. A
cell with k burning neighbours therefore catches with probability
1 - (1 - p)^k in that generation, exactly as in spread().
A cell belongs to exactly one fire, and is never spread to twice. Once a cell has caught it is never reconsidered. Burning cells are visited in a random order within a generation, so when two fires reach the same cell neither is systematically favoured.
A fire stops at its own maxSize and never exceeds it. spread() adds
a whole generation and then randomly drops the excess; spreadCpp() stops
on the boundary, which lands on maxSize exactly.
NA in spreadProb means unburnable, as in spread().
spreadCpp() is deliberately narrow. It covers one case – a per-cell
probability of being spread to, one or more ignitions, an optional per-fire
size cap – and takes only the arguments listed above. Every other argument
spread() accepts is unsupported, and passing one is an error rather than
being silently ignored:
allowOverlapNot supported. Fires never overlap; a cell belongs to one fire.
returnDistances, circle, circleMaxRadiusNot supported. No distances are computed and spread is not constrained to a disc.
asymmetry, asymmetryAngleNot supported. Spread is isotropic; there is no directional bias.
neighProbs, relativeSpreadProbNot supported. Every eligible neighbour is drawn for independently; the number of neighbours a cell spreads to is not itself drawn from a distribution, and probabilities are absolute rather than rescaled within a cell's neighbourhood.
stopRule, stopRuleBehaviorNot supported. The only stopping
conditions are maxSize, iterations, and having nowhere left to go.
exactSizesNot supported, though maxSize is exact in the sense
that a fire which reaches its cap stops there.
persistenceNot supported. A burned cell stays burned and does not re-burn.
maskNot supported. Use NA (or 0) in spreadProb to make cells
unburnable.
spreadStateNot supported. A spread cannot be continued from an
earlier one; each call starts from loci.
spreadProbLaterNot supported. One spreadProb applies to every
generation.
torusNot supported. The landscape does not wrap; spread stops at the edges.
plot.it, id, returnIndicesNot supported as options. Nothing is plotted, and the return value is always the indices form.
Use spread() when any of these are needed.
spreadCpp() is not a drop-in reimplementation of spread(). It makes
its own random draws, so for a given seed it does not reproduce spread()'s
cells, and swapping one for the other changes results and invalidates cached
output. It is reproducible in the ordinary sense: the same seed and the same
inputs give the same answer.
What the two do agree on is behaviour in aggregate. Over 300 seeds on a
1.44M-cell landscape the two produce the same total burned area (Wilcoxon
p = 0.34) and the same fire-size distribution (Kolmogorov-Smirnov
p = 0.56), with quantiles within 7% of each other from the 10th percentile
to the 99th.
Roughly 2x faster than spread() across a range of fire regimes: 1.6-2.4x
over nine scenarios spanning 596 to 188,762 burned cells and landscapes from
0.36M to 9M cells, median 2.1x.
The ratio understates the benefit on the expensive calls, so judge it in seconds. Across those nine scenarios the speedup RATIO falls slightly as more burns (correlation -0.37 with burned cells) while the seconds saved rise steeply (correlation +0.95). The scenario with the lowest ratio, 161,701 burned cells at 1.58x, saves 0.048 s per call – 24 times more than the 2.00x scenario at the other end, which saves 0.002 s. On real fire-model fits the same holds: two landscapes whose ratios differ by 30% (1.44x and 1.88x) save almost identical wall time per objective evaluation (9.5 s and 9.6 s).
The gain depends on what fraction of the landscape burns, and it can vanish.
The per-cell state is allocated once per call and costs the same in both
implementations, so it is pure overhead that neither avoids; what spreadCpp()
makes cheaper is the spreading itself. When almost nothing burns, that
overhead dominates and there is nothing left to win. Measured on one landscape
of ~8.4M cells: with 0.13% of it burned, spreadCpp() was no faster at all
(0.95x), where scenarios burning 0.7% and 4.2% of their landscapes gave 2.1x
and 2.4x.
The same allocation makes cost grow with landscape size even when the fires
do not: holding the fires fixed, going from 1.44M to 9M cells costs an extra
0.035 s per call. So if the fires occupy a small part of a large landscape,
crop first and map the indices back – that both speeds up the call and
restores the advantage. Cropping 1.44M to 300k cells is worth about 30%.
spread(), which is slower but far more general.