
multiscape is an exact optimisation framework for multi-action,
multi-objective spatial planning in R. It addresses planning problems
in which management actions are explicit decision alternatives whose
spatial allocation must satisfy planning requirements while accounting
for multiple competing objectives. Building on the transition from
place-based prioritisation towards spatially explicit action planning
(Tallis et al., 2021;
Salgado-Rojas et al., 2023),
multiscape jointly represents where management actions can be
implemented, which actions are selected, and the consequences expected
from those decisions.
Planning units, features, feasible actions, action-specific costs and outcomes, and spatial or management constraints are represented within a mixed-integer linear programming (MILP) formulation. Multiple objectives are defined independently, allowing ecological, economic, and spatial criteria to remain explicit rather than being collapsed a priori into a single criterion. Alternative spatial plans can then be generated and compared to characterise trade-offs among objectives and the corresponding spatial allocation of management actions. This formulation supports conservation and restoration applications as well as broader multiple-use spatial planning problems involving ecological, economic, and social objectives (Neubert et al., 2025).
Install the released version of multiscape from Comprehensive R
Archive Network (CRAN):
install.packages("multiscape")
Alternatively, install the latest development version from GitHub:
if (!requireNamespace("remotes", quietly = TRUE)) {
install.packages("remotes")
}
remotes::install_github("josesalgr/multiscape")
We illustrate the multiscape workflow using a spatial planning problem
from Spain’s Meseta Ibérica, adapted from Cánibe Iglesias et
al. (2025). The example
comprises 11,109 planning units, 151 species, four
ecosystem-service features, and four alternative management actions:
Afforestation, BAU, FarmReturn, and Firesmart.
Before constructing the optimisation problem, we examine its spatial
units, features, management alternatives, implementation costs, and
expected outcomes. The example data are included in multiscape and can
be loaded directly.
library(multiscape)
meseta <- load_meseta()
The study area is divided into 11,109 planning units of approximately 1
km². Each unit represents a location where a
management action may be implemented. The supplied spatial polygons and
identifiers are stored in planning_units.
head(meseta$planning_units)
#> Simple feature collection with 6 features and 2 fields
#> Geometry type: POLYGON
#> Dimension: XY
#> Bounding box: xmin: 2936400 ymin: 2279200 xmax: 2945400 ymax: 2281200
#> Projected CRS: GRS_1980_IUGG_1980_Lambert_Azimuthal_Equal_Area
#> id geometry cost
#> 1 1 POLYGON ((2944400 2280200, ... 0.1
#> 2 2 POLYGON ((2945400 2280200, ... 0.1
#> 3 3 POLYGON ((2937400 2279200, ... 0.1
#> 4 4 POLYGON ((2938400 2279200, ... 0.1
#> 5 5 POLYGON ((2939400 2279200, ... 0.1
#> 6 6 POLYGON ((2944400 2279200, ... 0.1
The planning problem considers 155 features: 151 species and four
ecosystem services. A feature is a measurable quantity represented
in the planning model. The features table identifies these quantities,
while dist_features provides their reference amounts by planning
unit and feature. These amounts serve as the comparison values for
action-specific outcomes; they do not define management decisions or
representation targets.
head(meseta$features)
#> id name
#> 1 1 ACCGENT
#> 2 2 ACCNISU
#> 3 3 ACRARUN
#> 4 4 ACRSCIR
#> 5 5 AEGCAUD
#> 6 6 AER
In this example, dist_features assigns zero credited representation to
the reference. Representation is instead credited through the outcomes
associated with selected actions.
Four alternative actions can be implemented throughout the study area: Afforestation, BAU, FarmReturn, and Firesmart. Their implementation costs vary by planning unit, so a plan’s cost depends on which actions are chosen and where they are implemented.
meseta$actions
#> id name
#> 1 Afforestation Afforestation
#> 2 BAU BAU
#> 3 FarmReturn FarmReturn
#> 4 Firesmart Firesmart
plot(
meseta$maps$costs,
main = "Action-specific implementation costs",
border = NA,
pal = function(n) hcl.colors(n, "YlGnBu", rev = TRUE)
)

The same feature may respond differently to different management actions
and locations. The outcomes table supplies its expected representation
for each recorded planning-unit, feature, and action combination. In
this dataset, outcomes are binary: a value of 1 credits one unit of
representation, while unrecorded combinations contribute zero. This
coding also applies to the included ecosystem-service features.
head(meseta$outcomes)
#> pu feature outcome action
#> 1 1 7 1 Afforestation
#> 2 1 8 1 Afforestation
#> 3 1 12 1 Afforestation
#> 4 1 13 1 Afforestation
#> 5 1 14 1 Afforestation
#> 6 1 15 1 Afforestation
The maps below illustrate the action-specific representation of
DENMINO. They show outcomes, rather than differences from the
reference.
plot(
meseta$maps$DENMINO,
main = "Action-specific outcomes for DENMINO",
border = NA,
breaks = c(-0.1, 0.5, 1.1),
pal = function(n) c("grey95", "#238b45")
)

We now use these inputs to build a Problem object. The stages below
first establish its spatial and feature structure, then add management
decisions and their consequences, feasibility requirements, and
objectives. No optimisation is performed until the method and solver
have been configured.
create_problem() defines the planning units, the feature catalogue,
and the reference amounts stored in dist_features. We pass the
supplied tables directly. In Stage 4, include_pu_cost = FALSE excludes
the separate planning-unit cost from the objective, so this example
counts only the costs of implementing specific actions, which we
introduce in Stage 2.
problem <- create_problem(
pu = meseta$planning_units,
features = meseta$features,
dist_features = meseta$dist_features,
cost = "cost"
)
The initial Problem contains the spatial structure and 155 features,
but no registered actions, effects, constraints, or objectives. This
incomplete state is expected: those components are added in subsequent
stages. We can inspect the model as it is being constructed.
print(problem)
#> A multiscape object (<Problem>)
#> ├─data
#> │├─planning units: <tbl_df> (11109 total)
#> │├─costs: min: 0.1, max: 0.1
#> │└─features: 155 total ("ACCGENT", "ACCNISU", "ACRARUN", ...)
#> └─actions and effects
#> │├─actions: none
#> │├─feasible action pairs: none
#> │├─effect data: none
#> │└─profit data: none
#> └─spatial
#> │├─geometry: sf (11109 rows)
#> │├─coordinates: 11109 rows (x: 2868900..3007900, y: 2110700..2280700)
#> │└─relations: none
#> └─targets and constraints
#> │├─targets: none
#> │├─area constraints: none
#> │├─budget constraints: none
#> │├─planning-unit locks: none
#> │└─action locks: none
#> └─model
#> │├─status: not built yet (will build in solve())
#> │├─objectives: none
#> │├─method: single-objective
#> │├─solver: not set (auto)
#> │└─checks: incomplete (no objective registered)
#> # ℹ Use `x$data` to inspect stored tables and model snapshots.
add_actions() registers the available management decisions and their
implementation costs; it does not choose where to implement them.
The loader already uses readable action names as identifiers
consistently across actions, action_costs, and outcomes, so no
preparation is needed.
problem <- problem |>
add_actions(
actions = meseta$actions,
cost = meseta$action_costs
)
An outcome describes the amount of a feature under a particular
action; an effect describes its change relative to the reference. If
$r$ denotes the reference amount and $y$ the action-specific outcome,
the signed effect is $\Delta = y-r$. add_effects() accepts three
equivalent ways of specifying a given action’s consequences, using
one of the following columns:
| Column | Supplied value | Outcome $y$ | Effect $\Delta$ |
|-------------------|-------------------------|-------------------|-----------------|
| outcome | Amount under the action | outcome | $y-r$ |
| effect | Signed absolute change | $r+\text{effect}$ | effect |
| relative_change | Proportional change $q$ | $r(1+q)$ | $rq$ |
For example, with a reference of 100, an outcome of 130, an effect of
30, and a relative change of 0.30 all describe the same resulting
amount. They are alternative inputs, not values to supply together.
When the reference is zero, proportional changes cannot produce a
positive outcome; this example therefore uses the supplied outcome
values directly.
problem <- problem |>
add_effects(
effects = meseta$outcomes
)
At this point, the problem describes both the available spatial decisions and the consequences expected if they are implemented. The next stage determines which combinations of decisions are admissible.
Constraints define the feasible set of spatial plans. We require at
most one action per planning unit and impose an absolute representation
target for each feature. sense = "max" makes the action-cardinality
constraint an upper bound, so units may remain without a selected
action. The feature targets require the sum of credited representation
to reach the supplied minimum for each feature.
head(meseta$targets)
#> feature target
#> 1 1 92.8
#> 2 2 109.6
#> 3 3 26.4
#> 4 4 180.0
#> 5 5 102.4
#> 6 6 269.6
problem <- problem |>
add_constraint_action_cardinality(count = 1, sense = "max") |>
add_constraint_targets_absolute(meseta$targets)
These targets are expressed in the outcome units rather than as percentages of the reference. Given the binary coding of this example, a fractional target must still be met or exceeded by the resulting total representation.
Constraints determine whether a plan is feasible; objectives determine how feasible plans are evaluated. Here we register two quantities to minimise: the cost of implementing the chosen actions and their spatial fragmentation. They remain separate objectives until an optimisation method specifies how to combine them.
problem <- problem |>
add_spatial_relations(meseta$boundary, name = "boundary") |>
add_objective_min_cost(include_pu_cost = FALSE, alias = "cost") |>
add_objective_min_fragmentation_action(
relation_name = "boundary", alias = "spatial"
)
The fragmentation objective sums weighted boundaries where the selection
of a given action differs between neighbouring units. Matching action
assignments contribute no boundary for that action; different actions
contribute to their respective boundaries. Minimising this criterion
favours more cohesive action patches without imposing strict
connectivity. The supplied boundary table provides the spatial
relations and their weights.
The optimisation method specifies how registered objectives are handled. For a first illustrative run, we use a weighted sum with coefficients 1 for cost and 0.5 for fragmentation, minimising $\text{cost}+0.5\,\text{spatial}$. Because weight normalisation and objective scaling are disabled, this choice is sensitive to the input units and should be understood as an illustrative preference, not a universally meaningful trade-off rate.
We save the formulation before configuring the method so that it can also be used for the multi-run exploration below.
problem <- problem |>
set_method_weighted_sum(
aliases = c("cost", "spatial"),
runs = set_runs_manual(data.frame(
weight_cost = 1,
weight_spatial = 0.5
)),
normalize_weights = FALSE,
objective_scaling = FALSE
)
The solver then searches for a feasible assignment that minimises the weighted criterion. We use Gurobi, which requires a valid installation and licence, with a 300-second solver limit and a 5% relative optimality-gap limit. Building the optimisation model can require additional time and memory. The optimisation is executed when knitting this README and its result is cached, so subsequent renders need not repeat the solve unless the code changes. Rendering this chunk requires an installed and licensed Gurobi solver.
problem <- set_solver_gurobi(
problem,
gap_limit = 0.05,
time_limit = 300,
verbose = TRUE
)
solutions <- solve(problem)
#> Set parameter Username
#> Set parameter LicenseID to value 2844238
#> Set parameter TimeLimit to value 300
#> Set parameter FeasibilityTol to value 1e-09
#> Set parameter IntFeasTol to value 1e-09
#> Set parameter MIPGap to value 0.05
#> Set parameter MIPGapAbs to value 0
#> Set parameter OptimalityTol to value 1e-09
#> Set parameter NodefileStart to value 0.5
#> Academic license - for non-commercial use only - expires 2027-07-14
#> Gurobi Optimizer version 12.0.2 build v12.0.2rc0 (win64 - Windows 10.0 (19045.2))
#>
#> CPU model: 12th Gen Intel(R) Core(TM) i7-12700H, instruction set [SSE2|AVX|AVX2]
#> Thread count: 14 physical cores, 20 logical processors, using up to 20 threads
#>
#> Non-default parameters:
#> TimeLimit 300
#> FeasibilityTol 1e-09
#> IntFeasTol 1e-09
#> MIPGap 0.05
#> MIPGapAbs 0
#> OptimalityTol 1e-09
#> NodefileStart 0.5
#> LogToConsole 0
#>
#> Optimize a model with 327122 rows, 142317 columns and 2120102 nonzeros
#> Model fingerprint: 0x18f5272c
#> Variable types: 86772 continuous, 55545 integer (55545 binary)
#> Coefficient statistics:
#> Matrix range [1e+00, 1e+00]
#> Objective range [1e+00, 7e+00]
#> Bounds range [1e+00, 1e+00]
#> RHS range [8e-01, 9e+03]
#> Presolve removed 142334 rows and 11125 columns
#> Presolve time: 4.78s
#> Presolved: 184788 rows, 131192 columns, 1681755 nonzeros
#> Variable types: 0 continuous, 131192 integer (131192 binary)
#> Deterministic concurrent LP optimizer: primal simplex, dual simplex, and barrier
#> Showing barrier log only...
#>
#> Root barrier log...
#>
#> Ordering time: 1.96s
#>
#> Barrier statistics:
#> AA' NZ : 5.943e+06
#> Factor NZ : 2.145e+07 (roughly 300 MB of memory)
#> Factor Ops : 4.108e+09 (less than 1 second per iteration)
#> Threads : 11
#>
#> Objective Residual
#> Iter Primal Dual Primal Dual Compl Time
#> 0 9.08549505e+07 -4.09748130e+05 2.40e+03 0.00e+00 2.36e+03 9s
#> 1 3.47762789e+07 -3.33400318e+05 8.66e+02 1.58e+00 7.33e+02 10s
#> 2 2.81132534e+06 -3.21967467e+05 8.05e+01 8.14e-01 6.08e+01 10s
#> 3 9.02184971e+05 -3.10629553e+05 2.45e+01 4.03e-01 1.91e+01 10s
#> 4 3.87322807e+05 -3.01982316e+05 9.29e+00 2.60e-01 7.71e+00 11s
#> 5 2.24942854e+05 -2.87930717e+05 4.49e+00 1.95e-01 4.08e+00 11s
#> 6 1.21722132e+05 -1.57332447e+05 1.92e+00 7.83e-05 1.43e+00 11s
#> 7 6.70445867e+04 -6.98603867e+04 5.85e-01 5.77e-15 4.66e-01 11s
#> 8 5.56541059e+04 -4.10644675e+04 3.67e-01 6.00e-15 2.90e-01 12s
#> 9 4.25341831e+04 -1.91292600e+04 1.67e-01 4.00e-15 1.60e-01 12s
#> 10 4.16902051e+04 -4.30829840e+03 1.59e-01 6.88e-15 1.17e-01 12s
#> 11 3.81011615e+04 9.81693227e+03 1.10e-01 3.66e-15 6.78e-02 13s
#> 12 3.33141639e+04 1.82504425e+04 5.66e-02 5.66e-15 3.44e-02 13s
#> 13 2.98016261e+04 2.19762987e+04 2.47e-02 7.69e-15 1.75e-02 13s
#> 14 2.88989373e+04 2.25255123e+04 1.84e-02 5.19e-15 1.42e-02 14s
#> 15 2.87336352e+04 2.36594964e+04 1.73e-02 4.00e-15 1.12e-02 14s
#> 16 2.76331951e+04 2.42516480e+04 1.04e-02 4.88e-15 7.48e-03 14s
#> 17 2.70453799e+04 2.45937589e+04 7.05e-03 5.50e-15 5.42e-03 14s
#> 18 2.65020957e+04 2.48596222e+04 4.22e-03 6.95e-15 3.63e-03 15s
#> 19 2.60274969e+04 2.52286068e+04 2.04e-03 4.52e-15 1.76e-03 15s
#> 20 2.58833211e+04 2.53155146e+04 1.48e-03 2.53e-15 1.25e-03 15s
#> 21 2.58179479e+04 2.53643921e+04 1.23e-03 4.88e-15 9.99e-04 16s
#> 22 2.57076401e+04 2.53843108e+04 8.25e-04 5.25e-15 7.13e-04 16s
#> 23 2.56171574e+04 2.54040800e+04 5.03e-04 8.76e-15 4.70e-04 16s
#> 24 2.55623126e+04 2.54257984e+04 3.11e-04 3.61e-15 3.01e-04 16s
#> 25 2.55186149e+04 2.54374382e+04 1.62e-04 3.58e-15 1.80e-04 17s
#> 26 2.54990910e+04 2.54513899e+04 9.99e-05 5.54e-15 1.05e-04 17s
#> 27 2.54875587e+04 2.54560979e+04 6.55e-05 4.63e-15 6.95e-05 17s
#> 28 2.54839890e+04 2.54580283e+04 5.50e-05 2.09e-15 5.74e-05 17s
#> 29 2.54810141e+04 2.54597586e+04 4.59e-05 5.38e-15 4.70e-05 18s
#> 30 2.54729208e+04 2.54612117e+04 2.15e-05 2.33e-15 2.59e-05 18s
#> 31 2.54703240e+04 2.54632141e+04 1.42e-05 3.87e-15 1.57e-05 18s
#> 32 2.54691539e+04 2.54640654e+04 1.09e-05 3.95e-15 1.12e-05 18s
#> 33 2.54659252e+04 2.54641982e+04 1.95e-06 6.77e-15 3.84e-06 19s
#> 34 2.54651950e+04 2.54648110e+04 1.66e-07 5.91e-15 8.56e-07 19s
#> 35 2.54651237e+04 2.54650654e+04 5.57e-08 4.41e-15 1.30e-07 19s
#> 36 2.54650851e+04 2.54650770e+04 3.69e-09 5.87e-15 1.79e-08 20s
#> 37 2.54650823e+04 2.54650821e+04 1.51e-10 1.23e-12 4.88e-10 20s
#>
#> Barrier solved model in 37 iterations and 19.82 seconds (18.67 work units)
#> Optimal objective 2.54650823e+04
#>
#>
#> Root crossover log...
#>
#> 165733 DPushes remaining with DInf 0.0000000e+00 20s
#> 0 DPushes remaining with DInf 0.0000000e+00 23s
#>
#> 22178 PPushes remaining with PInf 8.2450785e-06 23s
#> 0 PPushes remaining with PInf 0.0000000e+00 24s
#>
#> Push phase complete: Pinf 0.0000000e+00, Dinf 2.7618265e+03 24s
#>
#>
#> Root simplex log...
#>
#> Iteration Objective Primal Inf. Dual Inf. Time
#> 174690 2.5465082e+04 0.000000e+00 2.761826e+03 24s
#> 175374 2.5465082e+04 0.000000e+00 0.000000e+00 25s
#> Concurrent spin time: 0.00s
#>
#> Solved with barrier
#>
#> Root relaxation: objective 2.546508e+04, 175374 iterations, 19.20 seconds (13.70 work units)
#> Total elapsed time = 25.66s (DegenMoves)
#>
#> Nodes | Current Node | Objective Bounds | Work
#> Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time
#>
#> 0 0 25465.0822 0 2133 - 25465.0822 - - 26s
#> H 0 0 25633.000000 25465.0822 0.66% - 27s
#>
#> Explored 1 nodes (182306 simplex iterations) in 27.04 seconds (26.90 work units)
#> Thread count was 20 (of 20 available processors)
#>
#> Solution count 1: 25633
#>
#> Optimal solution found (tolerance 5.00e-02)
#> Best objective 2.563300000000e+04, best bound 2.546550000000e+04, gap 0.6535%
We inspect the returned SolutionSet before analysing its objective
values. The print method summarises the method, attempted runs, stored
solutions, and solver diagnostics.
print(solutions)
#> A multiscape solution set (<SolutionSet>)
#> ├─method
#> │├─name: `weighted`
#> │├─objectives: 2 (cost, spatial)
#> │└─run design: manual
#> │ └─design detail: 1 requested runs
#> └─content
#> │├─design rows: 1
#> │├─attempted runs: 1
#> │├─stored solutions: 1
#> │└─without solution: 0
#> └─run summary
#> │├─statuses: optimal: 1
#> │├─runtime: 27.217
#> │├─gap: 0.0065
#> │├─design columns: none
#> │└─objective columns: value_cost, value_spatial
#> └─objective ranges
#> │├─cost: 23980
#> │└─spatial: 3306
#> # ℹ Use get_runs(), get_objectives(), get_pu(), and get_actions() to inspect
#> results.
A 5% gap is a stopping tolerance, not a guarantee that the exact optimum or a unique spatial plan has been found. Check the reported solver status and gap before interpreting any returned solutions.
The following analyses use the solutions object from Stage 5. The
first results concern a single weighted run; we then vary the weight on
spatial fragmentation to examine alternative plans.
get_runs() links the requested run to the retained solution and
reports its solver status, runtime, and gap. get_objectives() provides
the component objective values in their original units. The weighted
value of this run can be reconstructed using the same coefficients as
above.
get_runs(solutions)
#> run_id solution_id status runtime gap
#> 1 1 1 optimal 27.217 0.006534545
performance <- get_objectives(solutions, format = "wide")
performance
#> solution_id cost spatial
#> 1 1 23980 3306
Solver versions and stopping conditions can lead to different feasible plans. Interpret the values returned by your run rather than expecting a particular spatial solution.
Targets express the required feature representation and should be
checked independently of cost and fragmentation. get_targets() reports
the required and achieved amounts for each feature, allowing all targets
to be checked.
target_achievement <- get_targets(solutions)
head(target_achievement)
#> solution_id feature feature_name target_level total_available target achieved
#> 1 1 1 ACCGENT 92.8 NA 92.8 2384
#> 2 1 2 ACCNISU 109.6 NA 109.6 2636
#> 3 1 3 ACRARUN 26.4 NA 26.4 572
#> 4 1 4 ACRSCIR 180.0 NA 180.0 1068
#> 5 1 5 AEGCAUD 102.4 NA 102.4 103
#> 6 1 6 AER 269.6 NA 269.6 270
#> gap met
#> 1 2291.2 TRUE
#> 2 2526.4 TRUE
#> 3 545.6 TRUE
#> 4 888.0 TRUE
#> 5 0.6 TRUE
#> 6 0.4 TRUE
all(target_achievement$met)
#> [1] TRUE
A value of TRUE in the final check indicates that all reported targets
were met. Confirm that the solver returned a feasible solution before
interpreting target achievement.
Objective values describe a plan’s performance; its decision space
representation records which action was assigned to each planning unit.
get_actions() returns the action-level results, including a selected
indicator. Rather than plotting all 11,109 units, we display the first
ten selected assignments and the number of units assigned to each
action.
selected_actions <- get_actions(solutions, solution = 1)
selected_actions <- selected_actions[
selected_actions$selected == 1,
c("pu", "action", "cost"),
drop = FALSE
]
head(selected_actions, 10)
#> pu action cost
#> 2 1 BAU 1
#> 6 2 BAU 1
#> 10 3 BAU 1
#> 14 4 BAU 1
#> 18 5 BAU 1
#> 22 6 BAU 1
#> 26 7 BAU 1
#> 30 8 BAU 1
#> 34 9 BAU 1
#> 38 10 BAU 1
table(selected_actions$action)
#>
#> Afforestation BAU FarmReturn Firesmart
#> 2363 2719 423 3185
The selected actions can also be inspected spatially.
plot_spatial_actions() shows the complete spatial prescription for the
first solution. Each colour identifies the selected management action,
while grey planning units have no selected action. At most one action
can be selected per planning unit.
plot_spatial_actions(
solutions,
solutions = 1,
layout = "single",
fill_values = c(
Afforestation = "#E6C34A",
BAU = "#41AB5D",
FarmReturn = "#2B8CBE",
Firesmart = "#9B7653"
)
)

To examine the compromise between cost and spatial cohesion, we keep the
planning data and constraints fixed and vary the weight on
fragmentation. We rebuild the same formulation in problemMO to keep
this exploration independent of the preceding single-run problem. Each
row in set_runs_manual() represents one run.
problemMO <- create_problem(
pu = meseta$planning_units,
features = meseta$features,
dist_features = meseta$dist_features,
cost = "cost"
) |>
add_actions(
actions = meseta$actions,
cost = meseta$action_costs
) |>
add_effects(
effects = meseta$outcomes
) |>
add_constraint_action_cardinality(count = 1, sense = "max") |>
add_constraint_targets_absolute(meseta$targets) |>
add_spatial_relations(meseta$boundary, name = "boundary") |>
add_objective_min_cost(include_pu_cost = FALSE, alias = "cost") |>
add_objective_min_fragmentation_action(
relation_name = "boundary", alias = "spatial"
) |>
set_solver_gurobi(
gap_limit = 0.05,
time_limit = 300,
verbose = FALSE
)
spatial_weights <- c(0, 0.1, 0.25, 0.5, 1, 2)
spatial_exploration <- problemMO |>
set_method_weighted_sum(
aliases = c("cost", "spatial"),
runs = set_runs_manual(data.frame(
weight_cost = 1,
weight_spatial = spatial_weights
)),
normalize_weights = FALSE,
objective_scaling = FALSE
)
alternatives <- solve(spatial_exploration)
The objective values of the six solutions can be inspected directly with
get_objectives() and visualised using plot_tradeoff().
get_objectives(alternatives, format = "wide")
#> solution_id cost spatial
#> 1 1 22637 22485
#> 2 2 22765 7865
#> 3 3 23173 5231
#> 4 4 23980 3306
#> 5 5 24944 1822
#> 6 6 25996 1090
plot_tradeoff(
alternatives,
label_runs = TRUE,
objectives = c("cost", "spatial"),
connect = FALSE
)

Both objectives are minimised, so smaller values are preferred on either axis. A larger spatial coefficient gives the solver a stronger incentive to accept higher costs if this reduces fragmentation. These six weighted runs sample the trade-off: they do not establish its complete Pareto frontier.
The returned objective values show the observed compromises directly: a lower fragmentation score may require a higher implementation cost, while a cost-focused run may produce a more fragmented assignment. Because the solver stops at a finite optimality gap, the sample can include dominated solutions. Subsequent frontier diagnostics describe the observed set, not a complete Pareto frontier.
The trade-off plot summarises how the objectives change, but not which management actions are selected in each location. We therefore map the spatial assignments in the six retained alternatives, using consistent colours across panels. Grey planning units have no selected action.
plot_spatial_actions(
alternatives,
solutions = get_objectives(alternatives, format = "wide")$solution_id,
layout = "single",
ncol = 2,
fill_values = c(
Afforestation = "#E6C34A",
BAU = "#41AB5D",
FarmReturn = "#2B8CBE",
Firesmart = "#9B7653"
)
)

These panels illustrate how increasing the fragmentation weight changes the location and composition of selected actions. They show solutions for the bundled inputs, rather than the ten-replicate scenario analysis in the original study.
Objective values reveal performance, but do not show how similar two spatial prescriptions are. In decision space, Jaccard similarity measures overlap in selected planning unit-action pairs. A value of 1 means identical assignments; 0 means no assignments in common.
selection_similarity(alternatives, metric = "jaccard", format = "matrix")
#> 1 2 3 4 5 6
#> 1 1.0000000 0.3256152 0.2932796 0.2495680 0.2002491 0.1745742
#> 2 0.3256152 1.0000000 0.6390935 0.4364974 0.2988033 0.2468110
#> 3 0.2932796 0.6390935 1.0000000 0.5766297 0.4054251 0.3430646
#> 4 0.2495680 0.4364974 0.5766297 1.0000000 0.6582665 0.5065351
#> 5 0.2002491 0.2988033 0.4054251 0.6582665 1.0000000 0.7065921
#> 6 0.1745742 0.2468110 0.3430646 0.5065351 0.7065921 1.0000000
#> attr(,"metric")
#> [1] "jaccard"
action_frequency <- selection_frequency(alternatives)
head(action_frequency[order(-action_frequency$frequency), ], 10)
#> pu action n_selected n_solutions frequency
#> 80 10014 Firesmart 6 6 1
#> 82 10015 BAU 6 6 1
#> 145 1003 Afforestation 6 6 1
#> 185 10039 Afforestation 6 6 1
#> 193 10040 Afforestation 6 6 1
#> 197 10041 Afforestation 6 6 1
#> 204 10042 Firesmart 6 6 1
#> 214 10045 BAU 6 6 1
#> 317 10069 Afforestation 6 6 1
#> 325 10070 Afforestation 6 6 1
Assignment frequency measures recurrence within the six retained plans, not an ecological selection probability or irreplaceability measure.
An observed solution is dominated when another observed solution is
no worse on either objective and better on at least one.
frontier_knee() identifies a potential compromise among the observed
non-dominated solutions. Its result depends on the sampled objective
values and their normalisation, so it should be treated as a decision
aid rather than a uniquely best plan. frontier_extremes() and
frontier_distances() provide additional diagnostics relative to the
observed extreme, ideal, and nadir values.
# Which observed solution offers an empirical compromise?
knee <- frontier_knee(alternatives, objectives = c("cost", "spatial"))
knee[, c("solution_id", "cost", "spatial", "knee_score"), drop = FALSE]
#> solution_id cost spatial knee_score
#> 1 3 23173 5231 0.4574124
# Best and worst observed values for each objective.
frontier_extremes(alternatives, objectives = c("cost", "spatial"))
#> solution_id objective sense bound role value
#> 1 1 cost min min best 22637
#> 2 6 cost min max worst 25996
#> 3 6 spatial min min best 1090
#> 4 1 spatial min max worst 22485
# Distance and ranking relative to the empirical ideal and nadir.
distances <- frontier_distances(
alternatives,
objectives = c("cost", "spatial"),
reference = c("ideal", "nadir")
)
distances[, c(
"solution_id", "distance_to_ideal", "rank_to_ideal",
"distance_to_nadir", "rank_from_nadir"
), drop = FALSE]
#> solution_id distance_to_ideal rank_to_ideal distance_to_nadir rank_from_nadir
#> 1 1 1.0000000 5 1.000000 5
#> 2 2 0.3189474 2 1.179910 1
#> 3 3 0.2508477 1 1.164767 2
#> 4 4 0.4130194 3 1.078791 3
#> 5 5 0.6876632 4 1.015298 4
#> 6 6 1.0000000 5 1.000000 5
Linkage analysis asks whether changes in objective performance
correspond to small adjustments or substantial reconfigurations of
selected actions. frontier_neighbors() identifies neighbouring
solutions in objective space; linkage_distances() and
linkage_turnover() relate performance differences to distances or
changes in their spatial assignments.
neighbors <- frontier_neighbors(
alternatives, objectives = c("cost", "spatial")
)
linkage <- linkage_distances(
alternatives,
objectives = c("cost", "spatial"),
pairs = neighbors,
decision_metric = "jaccard"
)
linkage[, c(
"from_solution", "to_solution",
"objective_distance", "decision_distance"
), drop = FALSE]
#> from_solution to_solution objective_distance decision_distance
#> 1 6 5 0.3150517 0.2934079
#> 2 5 4 0.2952532 0.3417335
#> 3 4 3 0.2565453 0.4233703
#> 4 3 2 0.1729464 0.3609065
#> 5 2 1 0.6843989 0.6743848
turnover <- linkage_turnover(
alternatives,
objectives = c("cost", "spatial"),
pairs = neighbors,
decision_metric = "jaccard"
)
turnover[, c(
"from_solution", "to_solution", "objective_distance",
"decision_distance", "reconfiguration_rate"
), drop = FALSE]
#> from_solution to_solution objective_distance decision_distance
#> 1 6 5 0.3150517 0.2934079
#> 2 5 4 0.2952532 0.3417335
#> 3 4 3 0.2565453 0.4233703
#> 4 3 2 0.1729464 0.3609065
#> 5 2 1 0.6843989 0.6743848
#> reconfiguration_rate
#> 1 0.9313008
#> 2 1.1574252
#> 3 1.6502751
#> 4 2.0868113
#> 5 0.9853681
linkage_contrasts() identifies the pairs with the greatest
reconfiguration relative to their objective-space separation.
linkage_transition() shows which assignments changed between a
selected pair of solutions, and selection_consistency() summarises
recurring planning-unit states. To keep the README compact, we display
the contrast table and only a few rows of planning-unit consistency;
detailed transition records remain available from the function itself.
linkage_contrasts(turnover, type = "high_reconfiguration", n = 2)
#> contrast_rank contrast_type from_solution to_solution
#> 1 1 high_reconfiguration 3 2
#> 2 2 high_reconfiguration 4 3
#> objective_distance decision_similarity decision_distance objective_tie
#> 1 0.1729464 0.6390935 0.3609065 FALSE
#> 2 0.2565453 0.5766297 0.4233703 FALSE
#> reconfiguration_rate changed_assignments changed_planning_units additions
#> 1 2.086811 3822 2175 1915
#> 2 1.650275 4663 2655 2324
#> removals activated_planning_units deactivated_planning_units action_switches
#> 1 1907 268 260 1647
#> 2 2339 316 331 2008
#> composition_changes from_cost to_cost delta_cost improvement_cost
#> 1 0 23173 22765 -408 408
#> 2 0 23980 23173 -807 807
#> from_spatial to_spatial delta_spatial improvement_spatial
#> 1 5231 7865 2634 -2634
#> 2 3306 5231 1925 -1925
transition <- linkage_transition(
alternatives, from = 1, to = 2, objectives = c("cost", "spatial")
)
print(transition)
#> Spatial solution transition
#> From solution: 1
#> To solution: 2
#>
#> Planning units changed: 4571 of 11109 (41.1%)
#> Activated: 168
#> Deactivated: 149
#> Action switches: 4254
#> Composition changes: 0
#>
#> Objective changes:
#> - cost: 128 (improvement -128)
#> - spatial: -14620 (improvement 14620)
#>
#> Use `$summary`, `$objectives`, `$transitions`, `$actions`, or `$state_matrix` for details.
head(selection_consistency(alternatives))
#> pu solution_group n_solutions selected_frequency managed_frequency
#> 1 1 all 6 1.0 1.0
#> 2 10 all 6 1.0 1.0
#> 3 100 all 6 1.0 1.0
#> 4 1000 all 6 1.0 1.0
#> 5 10000 all 6 0.0 0.0
#> 6 10001 all 6 0.5 0.5
#> unmanaged_frequency dominant_state dominant_frequency dominant_tie n_states
#> 1 0.0 BAU 0.8333333 FALSE 2
#> 2 0.0 BAU 0.8333333 FALSE 2
#> 3 0.0 BAU 0.8333333 FALSE 2
#> 4 0.0 Afforestation 0.8333333 FALSE 2
#> 5 1.0 unmanaged 1.0000000 FALSE 1
#> 6 0.5 unmanaged 0.5000000 FALSE 3
#> variable entropy normalized_entropy
#> 1 TRUE 0.4505612 0.6500224
#> 2 TRUE 0.4505612 0.6500224
#> 3 TRUE 0.4505612 0.6500224
#> 4 TRUE 0.4505612 0.6500224
#> 5 FALSE 0.0000000 0.0000000
#> 6 TRUE 1.0114043 0.9206198
A complete executable example is provided in
examples/meseta_iberica.R.
The simulated
workflow
introduces additional optimisation methods and diagnostics on a smaller
dataset.
The package provides several prepared examples:
| Loader | Example |
|----|----|
| load_sim_multiaction() | 64 spatial cells, two features and two actions for quick examples |
| load_meseta() | The Meseta Ibérica inputs used in this README |
| load_ecosystem_services() | Planning units and four raster layers for the integrated-planning vignette |
Browse the function
reference for
details of create_problem(), add_actions(), add_effects(),
add_constraint_targets_absolute(), the set_method_*() functions, and
solve(). Tools for inspecting alternatives are organised as
frontier_*(), selection_*(), and linkage_*().
If you find a bug or would like to suggest an improvement, please open an issue.
Cánibe Iglesias, M., Hermoso, V., Azevedo, J. C., Campos, J. C., Salgado-Rojas, J., Sil, A., & Regos, A. (2025). Integrating multiple landscape management strategies to optimise conservation under climate and planning scenarios: a case study in the Iberian Peninsula. Ecosystem Services, 74, 101742. doi:10.1016/j.ecoser.2025.101742.
Tallis, H., Fargione, J., Game, E., et al. (2021). Prioritizing actions: spatial action maps for conservation. Annals of the New York Academy of Sciences, 1505(1), 118-141. doi:10.1111/nyas.14651.
Salgado-Rojas, J., Hermoso, V., & Alvarez-Miranda, E. (2023). prioriactions: Multi-action management planning in R. Methods in Ecology and Evolution. doi:10.1111/2041-210X.14220.
Neubert, S., McGowan, J., Metcalfe, K., et al. (2025). Multiple-use spatial planning for sustainable development and conservation. Trends in Ecology & Evolution, 40(11), 1126-1142. doi:10.1016/j.tree.2025.09.007.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.