Demand-driven allocation: CLUE-S and lulcc, part 4 of 6
A complete CLUE-S model of Plum Island
Write lulcc's CLUE-S model of Plum Island with terraflux new clue, read every node and the loop that carries the map and its history, run fourteen years, validate them against 1999, and see what the elasticities and the allocation's settings do.
- Level
- Intermediate
- Time
- 50 min
- Template
- CLUE-S
- 1CLUE-S: the ideas
- 2Logistic suitability
- 3The demand
- 4CLUE-S: the whole model
- 5CLUE-S: the rules
- 6Ordered allocation
The first three parts of this series built CLUE-S's pieces one at a time: the competition between
classes, the suitability of each class
from logistic regressions, and the demand. In this part you assemble the
whole model of the Plum Island Ecosystems with one command, terraflux new clue, and read it node by node: what
each of its seven nodes does, how they connect, and how the loop carries two values from year to year, the map and
its history, while R's random number generator keeps drawing. You will run the fourteen years from 1985 to 1999,
watch the demand being met year by year, compare the simulated 1999 with the observed one, and move the two
settings that change the answer most: the elasticities and the allocation's scale. At the end you will
have a CLUE-S model you can adapt to your own maps.
The idea#
The model in one sentence#
Every year, every cell takes the land use whose suitability (plus a bonus for staying as it is, the elasticity, plus a number per class adjusted until the areas match) is highest, so that the map holds the cells the demand asks for that year; the next year starts from that map (Verburg et al., 2002). Three things are fitted or computed once, before the years:
| Piece | What it is | Node | Part |
|---|---|---|---|
| suitability | a logistic regression of each class on its drivers, and its map | glm.fit, glm.predict |
2 |
| demand | the cells of each class in each year, interpolated from the observed maps | demand.from_maps |
3 |
| randomness | R's random number generator, seeded | rng.r |
this part |
| history | how many years each cell has had its class | lulcc.history |
5 |
and one thing runs every year, inside a loop: lulcc.step, the allocation of that year's demand.
Key idea: the loop of CLUE-S
For \(t = 1 \dots T\) (the years of the demand after the first):
- start from the map \(L_{t-1}\) (the observed map of 1985 at \(t = 1\)) and the history \(H_{t-1}\);
- for every cell \(i\) and class \(u\), \(TPROP_{i,u} = P_{i,u} + ELAS_u [L_{t-1}(i) = u] + ITER_u\), with \(P\) the suitability and the elasticity added only for the cell's current class;
- give each cell its class of highest \(TPROP\), count, move each \(ITER_u\) by
scale× (demand − cells), and repeat until the counts are withinmax_diff(and on averagemean_diff) of the demand of year \(t\); - \(L_t\) is the map so allocated; \(H_t(i) = H_{t-1}(i) + 1\) where the class stayed, 0 where it changed.
Why carry the history, and the generator#
The history (lulcc.history) does nothing in this model: it is there because lulcc's conversion rules can
depend on it ("forest can become built land only after 5 years as other land": part 5), and because a rule needs
the history of the year it is applied in, so the history must travel with the map. The generator gives the
small random jitter added to the suitabilities to break ties. It is R's own generator (Mersenne-Twister after
set.seed(seed)): Terraflux draws exactly the numbers R would, which is why a run here gives lulcc's maps cell for
cell (Moulds et al., 2015). The generator is one object that every step draws from in turn, so the draws of a year
continue where the previous year's stopped, as in R.
When to use it#
Use the whole model when you have (or can assume) the amount of each land use year by year, and want its location: scenarios of regional land use, the consequences of a target ("no net loss of forest"), several classes growing at once. The first part discusses when CLUE-S is the right family; Choosing a land change model compares it with the others.
What you need#
- Observed maps of land use, one band per date, named by year (
pie_landuse.tif: 1985, 1991, 1999; 434 × 497 cells of about 100 m; 1 forest, 2 built, 3 other; 255 no data). - Drivers on the same grid (
pie_drivers.tif:elevation,slope,dist_built_1985). - Formulas: which drivers explain each class (from part 2).
- The files are on the first part's page. The application's template CLUE-S builds the same model on maps drawn by chance.
Build the model#
With one command#
terraflux new clue --observed pie_landuse.tif --times 1985,1991,1999 --drivers pie_drivers.tif \
--formulas '{"1": ["elevation", "slope"], "2": ["elevation", "slope", "dist_built_1985"], "3": ["elevation", "slope"]}' \
--elasticity 0.2,0.2,0.2 \
--settings '{"jitter": 0.0002, "scale": 0.000001, "max_iter": 1000, "max_diff": 50, "mean_diff": 50}' \
-o clue.toml
terraflux run clue.toml --out clue
These are the settings of lulcc's vignette for Plum Island. On Windows' PowerShell, write the JSON with single
quotes as here; in cmd, escape the double quotes. Every option:
CLUE-S (lulcc's CluesModel): logistic regressions, a linear demand, CLUE allocation
Usage: terraflux new clue [OPTIONS] --observed <OBSERVED> --drivers <DRIVERS> --output <OUTPUT>
Options:
--observed <OBSERVED> The observed maps, one band per date (the demand is interpolated from them)
--threads <THREADS> Threads to use (default and most: a quarter of the cores left free; never every core)
--times <TIMES> The date of each band of `observed`, e.g. `0,6,14`
--drivers <DRIVERS> The drivers: a raster with one band per variable
--start <START> The map the simulation starts from and the regressions are fitted on (default: the first observed band)
--formulas <FORMULAS> The drivers of each class's regression as JSON, e.g. `{"1": ["slope"], "2": ["slope", "dist"]}`
--elasticity <ELASTICITY> CLUE: the elasticity of each class (one value, or a list in class order), e.g. `0.2,0.2,0.2`
--order <ORDER> Ordered: the order the classes are served in, e.g. `2,1,3`
--deterministic Ordered: take the most suitable cells instead of drawing them
--settings <SETTINGS> The allocation's settings as JSON (CLUE: jitter, scale, max_iter, max_diff, mean_diff)
--allowed <ALLOWED> The conversion matrix as JSON, in class order, e.g. `[[1,1,0],[0,1,0],[1,1,1]]`
--neighbourhood <NEIGHBOURHOOD> Neighbourhood rules as JSON, e.g. `{"rules": {"1": 0.3, "2": 0.2}}`
--restrictions <RESTRICTIONS> A map of restricted cells (non-zero keep their class)
--history <HISTORY> A map of the steps each cell has had its class
--seed <SEED> [default: 1]
-o, --output <OUTPUT>
-h, --help Print helpThe start band
terraflux new clue names the start map by its band's name when the observed file has band names
(band = "1985" here), and by its index (band = "0") when it has none. A name survives a file whose bands
are reordered; with a file of unnamed bands, check that the index is the date you mean.
The run takes about 15 seconds on Plum Island (113,563 cells, 14 years; the slowest years iterate a few
hundred times) and writes clue/maps.tif (a band per year, 1986 to 1999) and clue/steps.csv.
In the application
Wizard... writes the same model: choose CLUE-S in The model, the observed maps and the drivers in The
data (the dates come from the band names), and the elasticity in The settings. The formulas are written with
every driver for every class; edit them in the regression node's settings.
Step 1: suitability, demand, generator and history#
Model file (TOML)
format = "terraflux/1"
[inputs.observed]
kind = "stack"
path = "pie_landuse.tif"
[inputs.drivers]
kind = "stack"
path = "pie_drivers.tif"
[inputs.start]
kind = "map"
path = "pie_landuse.tif"
band = "1985"
[inputs.seed]
kind = "value"
value = 1
[[nodes]]
id = "regression"
op = "glm.fit"
inputs = { landscape = "$start", drivers = "$drivers" }
params = { formulas = { 1 = ["elevation", "slope"], 2 = ["elevation", "slope", "dist_built_1985"], 3 = ["elevation", "slope"] } }
note = "Logistic regression of the presence of each class on its drivers (lulcc's glmModels)."
[[nodes]]
id = "suitability"
op = "glm.predict"
inputs = { model = "regression.model", drivers = "$drivers", landscape = "$start" }
note = "The suitability of each class at every cell; the drivers are static, so it is computed once."
[[nodes]]
id = "demand"
op = "demand.from_maps"
inputs = { maps = "$observed" }
params = { times = [1985, 1991, 1999] }
note = "The cells of each class at each step interpolated linearly from the observed maps."
[[nodes]]
id = "rng"
op = "rng.r"
inputs = { seed = "$seed" }
note = "R's generator after set.seed(seed), as lulcc draws."
[[nodes]]
id = "history"
op = "lulcc.history"
inputs = { landscape = "$start" }
note = "How long each cell has had its class (1 where not given)."
The model's four inputs are the observed maps (a stack: every band), the drivers (a stack), the start (one band of the observed maps, 1985) and the seed.
regression(glm.fit) fits, on the start map, one logistic regression per class: presence of the class (1) or not (0) against the drivers listed informulas(a table of class to drivers). Itsmodeloutput holds the coefficients (part 2 reads them).suitability(glm.predict) turns the regressions into a stack with one layer per class (named1,2,3), the probability of each class in each cell. It needs the drivers again, and the start map for its grid and its cells. Because Plum Island's drivers do not change during the simulation, it is computed once, outside the loop; a dynamic driver would move it inside (see Dynamic drivers).demand(demand.from_maps) counts the cells of each class in each observed map and interpolates them linearly year by year (times: the date of each band). It has two outputs:demand, the table of cells per class and year, andsteps, its number of years after the first (14), which the loop uses as itstimes. So the loop runs exactly as many years as the demand has.rng(rng.r) makes R's generator from the seed.history(lulcc.history) gives every cell a history of 1 (or reads a map of histories from its optional input).
Step 2: the loop#
Model file (TOML)
format = "terraflux/1"
[inputs.observed]
kind = "stack"
path = "pie_landuse.tif"
[inputs.drivers]
kind = "stack"
path = "pie_drivers.tif"
[inputs.start]
kind = "map"
path = "pie_landuse.tif"
band = "1985"
[inputs.seed]
kind = "value"
value = 1
[[nodes]]
id = "regression"
op = "glm.fit"
inputs = { landscape = "$start", drivers = "$drivers" }
params = { formulas = { 1 = ["elevation", "slope"], 2 = ["elevation", "slope", "dist_built_1985"], 3 = ["elevation", "slope"] } }
[[nodes]]
id = "suitability"
op = "glm.predict"
inputs = { model = "regression.model", drivers = "$drivers", landscape = "$start" }
[[nodes]]
id = "demand"
op = "demand.from_maps"
inputs = { maps = "$observed" }
params = { times = [1985, 1991, 1999] }
[[nodes]]
id = "rng"
op = "rng.r"
inputs = { seed = "$seed" }
[[nodes]]
id = "history"
op = "lulcc.history"
inputs = { landscape = "$start" }
[[nodes]]
id = "simulation"
op = "control.repeat"
inputs = { times = "demand.steps", landscape = "$start", history = "history.history" }
[nodes.body]
carry = { landscape = "allocate.landscape", history = "allocate.history" }
collect = { maps = "allocate.landscape", info = "allocate.info" }
[[nodes.body.nodes]]
id = "allocate"
op = "lulcc.step"
inputs = { landscape = "@landscape", history = "@history", suitability = "suitability.suitability", demand = "demand.demand", step = "@step", rng = "rng.rng" }
params = { allocation = { method = "clue", jitter = 0.0002, scale = 0.000001, max_iter = 1000, max_diff = 50, mean_diff = 50 }, elasticity = [0.2, 0.2, 0.2] }
[outputs.maps]
from = "simulation.maps"
[outputs.steps]
from = "simulation.info"
The loop simulation (control.repeat) has three inputs: times, connected to demand.steps (a number
that comes from a node, not typed), and two carried values, landscape (the start map) and history (node 5).
Its body:
carry = { landscape = "allocate.landscape", history = "allocate.history" }: after each year, both carried values take what the allocation gives, so next year's@landscapeand@historyare this year's results;collect = { maps = "allocate.landscape", info = "allocate.info" }: the map and the table of every year are kept (simulation.maps,simulation.info).
allocate (lulcc.step) is the year. Its inputs, and why each comes from where it does:
| Input | Connected to | Why |
|---|---|---|
landscape |
@landscape |
this year's map: last year's result |
history |
@history |
its history, carried with it |
suitability |
suitability.suitability |
fitted once, the same every year |
demand |
demand.demand |
the whole table; the step picks its row |
step |
@step |
which row of the demand (1 = 1986) |
rng |
rng.rng |
the generator, drawn from in turn |
Its settings: allocation (method = "clue" and the five numbers of part 1),
elasticity (one per class, in the order of the demand's classes, or { 2 = 0.9 } by class), and the rules of
part 5 (allowed, neighbourhood; the input restrictions).
Read the carried values
As in every loop, the body must read @landscape and @history; reading $start would allocate every year
from 1985. The suitability, the demand and the generator, on the other hand, are read from outside on
purpose: they do not belong to a year.
Read the results#
Fourteen years, and the demand met#















Every year the allocation met the demand within its tolerance of 50 cells (steps.csv: converged = 1, from 52
iterations in 1986 to a few hundred later). The map, though, tells a second story: the first year changed 13,023
cells (11% of the landscape) for a net change of a few hundred, and the following years about 450 to 600 each.
In the first year every cell whose suitability favours another class by more than the elasticity of 0.2 switches:
the map is "redrawn" to agree with the regressions. After that the map is consistent with them and only the
demand's yearly increments move cells. This first-year reshuffle is the strongest sign that the elasticities are
too low (below).
Against the observed map of 1999#



The comparison of Validating a simulation (validate.agreement
and validate.three_map, from 1985): 1,609 hits, 5,948 misses, 1,021 wrong hits and 16,892 false alarms, a
figure of merit of 0.063. The simulated map agrees with 79.0% of the observed one, while persistence (the map
of 1985 unchanged) agrees with 92.4%: the false alarms are the reshuffle of the first year. Measured at coarser
blocks, the figure of merit rises:
This is lulcc's published example, run exactly; it is a demonstration of the method, not a calibrated model. The next section shows the first thing to change.
Replicates#
The jitter is tiny (0.0002 against suitabilities that differ by hundredths), so the seed matters little here: runs with seeds 2 to 5 differ from seed 1 in 27 to 33 cells of 1999, and 48 cells are built in some of the five runs but not all. CLUE-S's allocation is nearly deterministic; its uncertainty is in its inputs (the demand, the regressions, the elasticities), which scenarios explore better than seeds.
What to report#
The demand (its source, its table); the regressions (formulas, coefficients, their fit, part 2); the
elasticities and any rules; the allocation's settings; whether every year converged (steps.csv); the validation
on an observed map at several resolutions, against persistence.
Settings that change the answer#
The elasticities#







The observed change from 1985 to 1999 was 8,578 cells. With no elasticity, a third of the landscape (33,430 cells)
ends in another class: the model is free to redraw everything its regressions disagree with. Raising the
elasticity to 0.4 brings the change down to 8,267 cells, the observed amount, and the agreement with the map of 1999
from 79% to 87%. Higher elasticities change little more but make the allocation struggle: with 0.8, the iteration
variables must climb far above the elasticity before a cell will move, and with scale = 0.000001 the 1,000
iterations are not enough in seven years of fourteen (converged = 0; Terraflux warns). Raise max_iter, or the
scale, with high elasticities. Giving built land alone a high elasticity (0.9, last frame) keeps the towns from
turning into forest, a realistic rule, and cuts the reshuffle by a quarter.
The figure of merit barely moves (0.058 to 0.065): the elasticities decide how much of the landscape is reshuffled, not whether the new built land lands on the right cells, which is the regressions' job. Choose them from what is known of each class (in CLUE-S's logic, near 1 for land uses that are costly to convert, such as built land or orchards, lower for land that changes easily, such as shifting farmland; Verburg et al., 2002) and check the gross change of the first year against the observed yearly change. Part 5 adds conversion rules for finer control.
The scale#




scale × (the difference in cells) is how far an iteration variable moves in one pass. With 0.00001, a
difference of 5,000 cells moves it by 0.05, about the gaps between suitabilities: the allocation converges in 5
passes. With 0.0001 the same difference moves it by 0.5, more than the whole range of the suitabilities, and the
variables swing from one extreme to the other: in the last pass every cell of the landscape is forest, and the
map of that year is useless. lulcc's default (0.0005) is far too large here; on maps of 100,000 cells and more,
start from 0.000001 to 0.00001 and check the first year. Always read converged in steps.csv.
Common pitfalls#
- Ignoring the first year. A gross change far larger than the net change in the first year means the elasticities let the regressions redraw the map. Compare it with the observed yearly change.
- A start map the demand does not begin from. The first row of the demand must be the start map's counts:
demand.from_mapsguarantees it when the start is the first observed map. - Regressions fitted on another map than the start. The
regressionandsuitabilitynodes read$start; when projecting from a later map (--startofterraflux new clue), the regressions are fitted on it. converged= 0. The map of that year does not have the demanded areas; fixscale,max_iteror the tolerances.- Treating lulcc's vignette as calibrated. Its settings show the method; calibrate yours (elasticities, formulas) against an observed map (Calibration and many runs).
- Classes of the map missing from the demand, or a stray code: the step stops (part 1).
Going further#
- Part 5: Rules: conversion matrices, neighbourhoods and restrictions.
- Validating a simulation and Calibration and many runs.
- A demand per region: CLUE-S with a demand for each part of the map.
- Dynamic drivers: the suitability recomputed in the loop.
- The operators:
lulcc.step,glm.fit,glm.predict,demand.from_maps,rng.r,lulcc.history.
References#
- Moulds, S., Buytaert, W., & Mijic, A. (2015). An open and extensible framework for spatially explicit land use change modelling: the lulcc R package. Geoscientific Model Development, 8(10), 3215–3229. https://doi.org/10.5194/gmd-8-3215-2015
- Verburg, P. H., Soepboer, W., Veldkamp, A., Limpiada, R., Espaldon, V., & Mastura, S. S. A. (2002). Modeling the spatial dynamics of regional land use: the CLUE-S model. Environmental Management, 30(3), 391–405. https://doi.org/10.1007/s00267-002-2630-x