Computing Least-Cost Interconnection Routes with scikit-image
The scenario: route_through_array returns a route, its length is plausible, and a reviewer notices
it clips the corner of a designated wetland for 180 metres. The optimiser did exactly what it was
asked. This page covers the mechanics that sit between a cost surface and a defensible corridor, and
it is the execution half of
grid routing and least-cost path analysis.
Root-cause analysis
Four mechanical faults account for nearly every bad route that comes out of an otherwise correct surface.
- Infinity substitution without a check.
route_through_arraycannot traversenp.inf, so the surface is usually converted to a large finite value. Without checking the returned weight afterwards, “there is no route” becomes “here is a route through the exclusion”. - Geometric weighting left off. With
geometric=Falsea diagonal step costs the same as an orthogonal one, so diagonal movement is under-priced by a factor of 1.414 and routes acquire a staircase bias that is easy to mistake for terrain following. - Endpoints inside excluded cells. A substation polygon overlapping a developed land-cover class puts the destination in an infinite-cost cell, and the failure message is unhelpful.
- Cell-centre geometry. Converting indices to coordinates without the half-cell offset shifts the whole route by half a pixel, which is invisible at national scale and matters when the route is compared against a parcel boundary.
Pre-flight validation
import numpy as np
def prepare_for_routing(
cost: np.ndarray,
origin_rc: tuple[int, int],
dest_rc: tuple[int, int],
*,
blocked_value: float = 1e9,
) -> tuple[np.ndarray, dict]:
"""Substitute infinities, rescue endpoints, and report what was changed."""
report = {"blocked_cells": int(np.isinf(cost).sum()), "endpoint_overrides": []}
finite = np.where(np.isinf(cost), np.float32(blocked_value), cost).astype("float32")
for name, rc in (("origin", origin_rc), ("destination", dest_rc)):
if finite[rc] >= blocked_value:
# Rescue rather than fail: take the cheapest finite cell in a 5x5 window.
r, c = rc
window = finite[max(r - 2, 0):r + 3, max(c - 2, 0):c + 3]
if not np.isfinite(window).any() or window.min() >= blocked_value:
raise ValueError(f"{name} is inside a large excluded region — move it or relax a constraint")
finite[rc] = float(window.min())
report["endpoint_overrides"].append(name)
return finite, report
Fix implementation
import numpy as np
from shapely.geometry import LineString
from skimage.graph import route_through_array
def least_cost_route(
cost: np.ndarray,
transform,
origin_rc: tuple[int, int],
dest_rc: tuple[int, int],
*,
blocked_value: float = 1e9,
cell_size_m: float | None = None,
) -> dict:
"""Route, then prove the route is buildable before returning it."""
finite, report = prepare_for_routing(cost, origin_rc, dest_rc, blocked_value=blocked_value)
indices, weight = route_through_array(
finite, origin_rc, dest_rc,
fully_connected=True, # allow diagonal movement
geometric=True, # and price it at sqrt(2), not 1
)
if weight >= blocked_value:
raise ValueError("no traversable route: every path crosses an excluded region")
# Half-cell offset puts the vertex at the cell centre, where the cost applies.
coords = [transform * (c + 0.5, r + 0.5) for r, c in indices]
line = LineString(coords)
cs = cell_size_m or abs(transform.a)
return {
"geometry": line,
"length_m": float(line.length),
"accumulated_cost": float(weight),
"cells": len(indices),
"mean_cost_per_cell": float(weight) / max(len(indices), 1),
"straight_line_m": float(
LineString([coords[0], coords[-1]]).length
),
"circuity": float(line.length) / max(
LineString([coords[0], coords[-1]]).length, 1e-9
),
"cell_size_m": cs,
**report,
}
Returning the circuity factor alongside the length is what connects this stage back to the straight-line screen in proximity and distance calculations: a route with a circuity of 1.9 is a project whose screen was optimistic, and that fact should travel with the number.
Fallback routing and performance tuning
- One Dijkstra, many destinations.
route_through_arraydiscards the accumulated-cost array, butskimage.graph.MCP_Geometricexposes it: runfind_costsonce from the origin and read the cost to each candidate point of interconnection, thentracebackonly the ones worth drawing. - Route coarse, then refine. A 30-metre pass to find the corridor and a 10-metre pass inside a buffer around it is roughly twenty times faster than a single fine pass and more accurate where it matters.
- Simplify the output line, not the cost. A raster route has a vertex per cell; simplifying with a tolerance of about one cell removes the staircase without changing the corridor. Compute the cost from the unsimplified path.
- Watch memory on wide corridors. The MCP structures are several arrays the size of the surface; at 44 million cells that is gigabytes, which is the practical reason for the corridor clip.
Downstream validation
import geopandas as gpd
def assert_route_is_buildable(route: dict, exclusions: gpd.GeoSeries, *, tol_m: float = 1.0) -> None:
"""The four assertions that separate an optimal route from a buildable one."""
line = route["geometry"]
assert route["accumulated_cost"] < 1e9, "route traverses an excluded region"
assert route["length_m"] >= route["straight_line_m"] - tol_m, (
"routed length below the straight line — a transform or CRS error"
)
hit = exclusions[exclusions.intersects(line)]
assert hit.empty, f"route intersects {len(hit)} exclusion geometries despite a finite cost"
assert route["circuity"] < 4.0, (
f"circuity {route['circuity']:.2f} — the corridor is almost certainly blocked, not merely expensive"
)
What the accumulated-cost array is worth on its own
route_through_array throws away the most useful thing it computes. MCP_Geometric.find_costs
keeps it: an array holding the cost of reaching every cell from the origin, which answers several
questions the route alone cannot.
The first is comparison. Five candidate points of interconnection cost five array lookups rather than five routing runs, and the ranking is exact rather than approximate because all five costs come from the same pass. The second is shape. Contouring the array shows where the cheap corridors run and where an obstacle splits the surface into two basins that only connect a long way round — which is the map a routing engineer wants when deciding whether a constraint is worth challenging. The third is uncertainty: differencing two accumulated-cost arrays computed under two plausible weightings shows which parts of the study area are robustly cheap and which are cheap only under one set of assumptions.
Keeping the array costs nothing beyond memory, since it was computed either way. Publishing it alongside the route turns an argument about a line into an argument about the surface, which is the one that can be settled.
Frequently asked questions
Why does the route hug the edge of an exclusion?
Because the cheapest traversable cells are the ones immediately outside it, and nothing in the model says a line needs working room. Buffer the exclusions by a construction offset before rasterising — 30 to 50 metres is typical — and the optimiser keeps its distance without any special-case logic.
Should fully_connected ever be False?
Only when the movement model genuinely forbids diagonals, which for a transmission line it does not. With diagonals disabled every route becomes a staircase of orthogonal steps whose length is systematically overstated by up to 41 percent on diagonal runs.
How do I route to several substations at once?
Use MCP_Geometric.find_costs from the project location, which fills the accumulated-cost array for
the whole surface in one pass, then read the cost at each substation cell. Only trace back the routes
you intend to draw. For five candidate points of interconnection this is roughly five times faster
than five independent routes, and the comparison is exact rather than approximate.
What does a very high circuity factor mean?
Usually that the destination is effectively unreachable under the current constraints rather than merely expensive. A circuity above about three says the optimiser is taking a long way round an obstacle that spans the direct line, and the useful output is the name of that obstacle — which comes from intersecting the route’s bounding corridor with the exclusion layers rather than from the route itself.
Should the routed geometry be smoothed?
Simplified, not smoothed. Simplification with a one-cell tolerance removes the raster staircase while keeping every vertex on the routed path; smoothing with a spline moves vertices off it, which can push the line into a cell the model excluded. Simplify for presentation, keep the raw path for the cost.
Can this handle a route that must pass through a waypoint?
Yes — route origin to waypoint and waypoint to destination, then concatenate. The concatenation is exact because the accumulated cost is additive, and it is the standard way to honour a landowner agreement or a mandated crossing point without distorting the cost surface to force the outcome.
How long should a route take to compute?
Under a second for a clipped 30-metre corridor, and a few seconds for a refined 10-metre pass inside a buffer. A route that takes minutes is almost always running over an unclipped surface, and the fix is the corridor buffer rather than a faster machine. Wall-clock is a useful smoke test for exactly that reason: a sudden increase usually means the clip stopped working, not that the terrain changed.
Can the same code route a distribution feeder or an access road?
Yes — only the weights change. An access road cares about slope far more and about land cover far less, and a distribution feeder can use narrower corridors and cross land a transmission line cannot. The machinery is identical, which is a good argument for keeping the weights in configuration where a second profile is a file rather than a fork.
Related
- Grid Routing & Least-Cost Path Analysis — the parent workflow
- Building a Transmission Cost Surface Raster in NumPy — the surface this page consumes
- Proximity & Distance Calculations — the straight-line screen the circuity factor refers back to
- Grid Capacity Buffer Analysis — choosing which substations are worth routing to