Site Suitability Explained: Constraints, Factors and Weights

Problem statement

"Where is the best place for it?" is the oldest question in applied GIS, and a suitability analysis is how a map answers it. Every version has the same three parts:

  • Constraints โ€” yes/no rules that make a place ineligible: open water, protected land, slopes too steep to build on.
  • Factors โ€” graded qualities where more or less is better: gentler slope, a shorter distance to a main road, more people nearby.
  • Weights โ€” how much each factor counts relative to the others.

The method is simple arithmetic on a grid. Most of its mistakes come from treating those three parts as interchangeable. That was measured on a real 30 m grid of Chittenden County, Vermont: 1,704,541 land cells, two constraints (water, slope over 15ยฐ), three factors (slope, distance to a primary or secondary road, population density), and one question โ€” which places come out on top.

what went wrong                                          effect on the answer
constraint left to the factors (water scored, not masked)   8.4% of the top 5% is open water
factors summed in raw units (degrees + metres + people/kmยฒ)  7 of the 10 best sites change
0.1 of weight moved between any pair of factors              0 or 1 of the 10 best sites change

The weights โ€” the part people argue about in meetings โ€” mattered least. Treating a constraint as a factor and forgetting to standardise mattered most.

Quick answer

Mask with the constraints, rescale each factor to 0โ€“1 with thresholds you can defend, then take a weighted sum:

import numpy as np

def decreasing(x, good, bad):
    """1 at or below `good`, 0 at or beyond `bad`, linear between."""
    return np.clip((bad - x) / (bad - good), 0, 1)

def increasing(x, bad, good):
    return np.clip((x - bad) / (good - bad), 0, 1)

factors = {
    "slope":  decreasing(slope_deg, good=0, bad=15),
    "road":   decreasing(dist_road_m, good=0, bad=3000),
    "demand": increasing(people_per_km2, bad=0, good=500),
}
weights = {"slope": 1/3, "road": 1/3, "demand": 1/3}

allowed = in_county & ~water & (slope_deg <= 15)          # constraints: a mask
score = sum(weights[k] * factors[k] for k in factors)       # factors: a weighted sum
score = np.where(allowed, score, np.nan)

The two constraints removed 14.9% of the county's land; the weighted sum then ranks the remaining 1,450,723 cells. The arithmetic is cheap โ€” the judgement in the thresholds and weights is not.

Flow from constraints as a Boolean mask, through factors rescaled to 0 to 1 and a weighted sum, to candidate sites.
Constraints and factors enter at different points: one removes cells, the other ranks the cells that remain.

Step-by-step solution

1. Sort every criterion into a constraint or a factor

Ask of each criterion: is there a value beyond which the place is simply not a candidate? If so, it is a constraint. If a worse value can be made up for by a better value elsewhere, it is a factor.

Some criteria are both. Slope was used twice here: slopes over 15ยฐ were excluded outright, and below 15ยฐ gentler was better.

2. Express constraints as a mask, never as a low score

A constraint that only lowers a score can still be outscored. Leaving Lake Champlain and the county's other water bodies unmasked, and relying on the factors to rank them low, measured:

no mask: top 5% = 89,209 cells; in water blocks 7,482 (8.4%)
mean score: water cells 0.518   land cells 0.395

Water scored higher than land. On the slope factor it is perfect by definition (0.944 against 0.528 for land), and the 4 km around it is more densely settled than the county average (0.270 against 0.166). On road access the two were about level (0.339 against 0.364). Two of three factors said the lake was an excellent site.

3. Rescale each factor to a common 0โ€“1 range

Slope is measured in degrees, distance in metres and density in people per kmยฒ. Adding them as they are is adding numbers in different units, and whichever has the widest spread decides the answer (see Explanation).

Use thresholds that mean something: 0ยฐ is ideal and 15ยฐ is as bad as it gets; 0 m from a main road is ideal and 3 km is as bad as it gets. Thresholds make a factor score readable โ€” 0.5 means "halfway between ideal and unacceptable" โ€” and they do not move when the study area is extended.

4. Choose weights, and write down why

Direct weights are fine for three or four factors. For more, a pairwise comparison (the Analytic Hierarchy Process) forces one judgement at a time: is slope more important than road access, and by how much?

The judgements used here โ€” slope moderately more important than roads (3), slightly more than demand (2), demand slightly more than roads (2) โ€” give:

AHP weights slope/road/demand = [0.54  0.163 0.297]  lambda_max 3.0092  CR 0.0079

A consistency ratio (CR) below 0.1 means the judgements agree with each other. A deliberately circular set โ€” slope beats roads, roads beat demand, demand beats slope โ€” gave CR 1.149, which is the method telling you the weights are incoherent.

5. Combine, then apply the mask

The weighted linear combination is score = ฮฃ weight ร— factor, and the mask sets excluded cells to NoData. Keep NoData as NaN in a float array so it cannot be mistaken for a score of zero.

6. Select sites at the scale of the decision

A single 30 m cell is not a site. Summarise the score over the footprint you would actually build on โ€” here, a moving 1 km window with at least 90% eligible land โ€” and pick the best non-overlapping windows.

That also makes the result more stable than a cell-level top 5%, because one unusual cell cannot promote a whole site.

7. Test how much the answer depends on the weights

Move 0.1 of weight between each pair of factors, starting from equal weights, and count how many of the ten best sites survive:

+0.1 slope  -0.1 road  : 9/10 kept
+0.1 slope  -0.1 demand: 9/10 kept
+0.1 road   -0.1 slope : 10/10 kept
+0.1 road   -0.1 demand: 9/10 kept
+0.1 demand -0.1 slope : 10/10 kept
+0.1 demand -0.1 road  : 10/10 kept
AHP weights: 8/10 kept
raw units:   3/10 kept

If a site only appears under one weighting, report it as sensitive rather than as a recommendation.

Code examples

Example 1 โ€” factors with explicit thresholds and a constraint mask

import numpy as np
from scipy import ndimage


def distance_to(mask, cell_size):
    """Euclidean distance in map units from every cell to the nearest True cell."""
    return ndimage.distance_transform_edt(~mask) * cell_size


def standardise(x, bad, good):
    """Linear rescale to 0..1; works for increasing (good > bad) and decreasing factors."""
    out = (np.asarray(x, dtype="float32") - bad) / (good - bad)
    return np.clip(out, 0, 1)


def weighted_overlay(factors, weights, allowed):
    """Weighted linear combination; excluded cells become NaN, not 0."""
    total = sum(weights.values())
    if not np.isclose(total, 1.0):
        raise ValueError(f"weights sum to {total:.3f}, not 1")
    missing = set(weights) ^ set(factors)
    if missing:
        raise KeyError(f"factor/weight names do not match: {sorted(missing)}")
    score = sum(weights[name] * factors[name] for name in weights)
    return np.where(allowed, score, np.nan).astype("float32")
factors = {
    "slope": standardise(slope_deg, bad=15, good=0),
    "road": standardise(distance_to(on_major_road, 30), bad=3000, good=0),
    "demand": standardise(people_per_km2, bad=0, good=500),
}
allowed = in_county & ~water & (slope_deg <= 15)
score = weighted_overlay(factors, {"slope": 1/3, "road": 1/3, "demand": 1/3}, allowed)

On the 2,220 ร— 1,611 grid โ€” 3.6 million cells โ€” the three factors, the distance transform and the overlay took 0.13โ€“0.33 s across three runs on a shared machine. The two checks catch the silent errors that otherwise produce a plausible-looking map:

ValueError: weights sum to 1.100, not 1

Example 2 โ€” AHP weights and the consistency ratio

import numpy as np

RANDOM_INDEX = {3: 0.58, 4: 0.90, 5: 1.12, 6: 1.24, 7: 1.32, 8: 1.41}


def ahp_weights(matrix, names):
    """Weights from a reciprocal pairwise comparison matrix (principal eigenvector)."""
    A = np.asarray(matrix, dtype=float)
    n = A.shape[0]
    if not np.allclose(A * A.T, 1.0):
        raise ValueError("matrix is not reciprocal: A[j, i] must equal 1 / A[i, j]")
    values, vectors = np.linalg.eig(A)
    i = int(np.argmax(values.real))
    w = np.abs(vectors[:, i].real)
    w /= w.sum()
    lam = values[i].real
    cr = ((lam - n) / (n - 1)) / RANDOM_INDEX[n]
    print(f"lambda_max {lam:.4f}  CR {cr:.4f}  "
          f"{'consistent' if cr < 0.1 else 'INCONSISTENT - revisit the judgements'}")
    return {name: round(float(v), 3) for name, v in zip(names, w)}
>>> ahp_weights([[1, 3, 2], [1/3, 1, 1/2], [1/2, 2, 1]], ["slope", "road", "demand"])
lambda_max 3.0092  CR 0.0079  consistent
{'slope': 0.54, 'road': 0.163, 'demand': 0.297}

>>> ahp_weights([[1, 3, 1/3], [1/3, 1, 3], [3, 1/3, 1]], ["slope", "road", "demand"])
lambda_max 4.3333  CR 1.1494  INCONSISTENT - revisit the judgements
{'slope': 0.333, 'road': 0.333, 'demand': 0.333}

The circular matrix returns perfectly equal weights. Without the CR check, incoherent judgements look like a deliberate choice of equal weighting.

Example 3 โ€” how many of the best sites survive a change of weights

import numpy as np
from scipy import ndimage


def best_sites(score, allowed, k=10, window=33, min_eligible=0.9):
    """The k best non-overlapping windows (window x window cells) by mean score."""
    filled = np.nan_to_num(score, nan=0.0)
    share = ndimage.uniform_filter(allowed.astype("float32"), size=window)
    mean = ndimage.uniform_filter(filled, size=window) / np.maximum(share, 1e-6)
    mean = np.where(share >= min_eligible, mean, -1.0)
    picks = []
    for _ in range(k):
        r, c = np.unravel_index(np.argmax(mean), mean.shape)
        picks.append((int(r), int(c)))
        mean[max(0, r - window):r + window, max(0, c - window):c + window] = -1.0
    return picks


def sites_kept(base, other, tolerance=17):
    return sum(any(abs(a[0] - b[0]) <= tolerance and abs(a[1] - b[1]) <= tolerance
                   for b in other) for a in base)


def weight_sensitivity(factors, weights, allowed, step=0.1):
    base = best_sites(weighted_overlay(factors, weights, allowed), allowed)
    for up in weights:
        for down in weights:
            if up == down or weights[down] < step:
                continue
            w = dict(weights, **{up: weights[up] + step, down: weights[down] - step})
            kept = sites_kept(base, best_sites(weighted_overlay(factors, w, allowed), allowed))
            print(f"+{step} {up:7s} -{step} {down:7s}: {kept}/{len(base)} kept")

On a 30 m grid a 33-cell window is about 1 km, and a 17-cell tolerance treats two windows whose centres are within about 500 m as the same site. The baseline and six shifted runs took 0.5โ€“1.3 s together across two runs, so there is no reason to skip the test.

Bar chart of how many of the ten best sites survive: 9 or 10 after weight shifts, 8 with AHP weights, 3 with raw units.
Standardisation changed the answer far more than any disagreement about weights did.

Explanation

Why a Boolean overlay and a weighted overlay disagree

A Boolean overlay turns every criterion into a threshold and keeps the cells that pass all of them. With slope โ‰ค 8ยฐ, within 1 km of a main road and at least 100 people per kmยฒ, it kept 13.6% of the eligible land (177.2 kmยฒ) โ€” and it treats a cell that barely passes all three exactly like one that passes them easily.

A weighted overlay lets a strength in one factor compensate for a weakness in another. Of the weighted top 5% (72,537 cells), 5.4% would have failed a Boolean threshold, nearly all of them (5.2%) because they were more than 1 km from a main road while being flat and densely settled.

Neither is wrong. Compensation is the point of a weighted overlay; if a shortfall should never be compensated, it belongs in the constraints.

Why unstandardised factors are decided by their units

Adding degrees, metres and people per kmยฒ gives a number dominated by whichever input has the largest spread:

factor   range              sd        corr with score:  all cells   top 5%
slope    0 .. 15.0 deg        4.3                          0.087     -0.005
road     0 .. 11,384.6 m  2,189.9                          0.995     -0.094
demand   0 .. 2,354.3/kmยฒ   235.5                          0.442      0.939

Across the whole county the raw score is almost exactly distance to the road, because metres vary by thousands. Among the cells that reach the top 5%, which all lie near a road, distance varies with a standard deviation of only 197 m โ€” and density, varying by hundreds, takes over.

So the raw-unit map is not merely weighted wrongly: which factor decides the answer changes depending on where you look, and slope decides nothing at all. The top 5% overlapped the standardised top 5% by a Jaccard index of only 0.539, and 3 of the 10 best sites matched.

Why the weights mattered less than expected

Shifting 0.1 of weight between any two factors kept 9 or 10 of the 10 best sites. Moving from equal weights to the AHP weights โ€” slope from 0.33 to 0.54 โ€” kept 8, and the two top-5% sets overlapped by a Jaccard index of 0.755.

The best cells were good on everything. Their mean factor scores were 0.85 for slope, 0.87 for roads and 0.93 for demand, against 0.62, 0.38 and 0.18 across all eligible land. When a site wins on every factor, reweighting reorders the winners without replacing them.

Part of that is geography: road access and demand correlate at 0.540 here, because people live along main roads. Slope was nearly independent of both (0.073 and 0.136). Where factors pull in opposite directions โ€” cheap land far from customers โ€” the same test shows much more movement, which is why it is worth running rather than assuming.

Why water scored better than land

A constraint encodes knowledge that none of the factors contain. Nothing in slope, road distance or population density says "you cannot build on a lake", and on two of the three the lake is attractive: flat by definition, and bordered by the county's densest settlement.

That is the general failure. Any exclusion that coincides with good factor values โ€” floodplains in flat valleys, protected land beside towns, airfields next to highways โ€” rises to the top unless it is masked.

Two panels comparing water left to the factors, where water fills 8.4 per cent of the top five per cent, with water and steep slopes masked.
The lake was not a borderline case: its average score was 0.518 against 0.395 for land.

Edge cases or notes

  • Minโ€“max rescaling depends on the extent. Stretching each factor between its own minimum and maximum changes every score when the study area grows; fixed thresholds do not.
  • NoData must survive the sum. A NaN factor makes that cell NaN, which is correct; filling NaN with 0 first silently makes missing data look unsuitable.
  • A surface model is not bare ground. The slope here came from the Copernicus GLO-30 DSM, which includes tree canopy and buildings; check which elevation model a slope factor was derived from.
  • Distance transforms assume square cells in a projected CRS. In degrees they measure nothing in particular.
  • "Top 5%" is relative. It always returns 5% of the eligible cells, however poor they are; add an absolute floor if a site must meet a standard.
  • Weights are value judgements. AHP makes them consistent, not correct; record who made them.
  • Cell-level rankings are noisy. Aggregate to the footprint of the decision before naming sites.
  • Suitability is not demand. A suitable site says nothing about whether customers will come; that is a gravity model's question.

FAQ

What is the difference between a constraint and a factor?

A constraint excludes a place outright and is applied as a mask. A factor grades places that remain eligible and can be traded off against other factors through the weights.

Do I have to standardise factors before combining them?

Yes. Summed in raw units on the Chittenden grid, the county-wide score correlated 0.995 with distance to road alone, and only 3 of the 10 best sites matched the standardised result.

How should I choose the weights?

State them directly for a handful of factors, or use pairwise comparisons (AHP) and check the consistency ratio is below 0.1. Then test how many of the best sites survive a shift of 0.1 between factors.

Should I use a Boolean overlay or a weighted overlay?

Use Boolean rules for true exclusions and a weighted overlay for everything that can be traded off. A pure Boolean overlay kept 13.6% of the eligible land and could not rank any of it.

Why did my best sites end up in a lake?

Because an exclusion was left to the factors instead of being masked. Unmasked, water filled 8.4% of the top 5% here, since flat and central is what the factors reward.

What cell size should a suitability analysis use?

Fine enough to resolve the narrowest factor that matters, usually slope, and then aggregate the score to the footprint of the real decision before ranking sites.