Source code: Least-cost path analysis in Python: cost surfaces and walking routes up Yr Wyddfa

Runnable Python scripts, requirements and console output for the tutorial “Least-cost path analysis in Python: cost surfaces and walking routes up Yr…

Last updated

These are the exact scripts used to produce every figure and number in Least-cost path analysis in Python: cost surfaces and walking routes up Yr Wyddfa. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python least_cost_path.py

least_cost_path.py

least_cost_path.py
"""Least-cost path analysis in Python: Pen-y-Pass to the summit of Yr Wyddfa (Snowdon).
Builds a walking cost surface from real slope (Copernicus GLO-30 DEM) and land cover
(ESA WorldCover 2021), computes the accumulated cost (travel time) surface from Pen-y-Pass
with scikit-image's MCP_Geometric, traces the least-cost path to the summit, and compares it
with the straight line and with the footpaths mapped in OpenStreetMap. It then changes the
cost weights to show how each choice moves the route.
Data: Copernicus DEM GLO-30 and ESA WorldCover from the Microsoft Planetary Computer (no
account needed) and OpenStreetMap footpaths from the Overpass API.
Run with: python least_cost_path.py
"""
import json
import platform
import time
from importlib.metadata import version
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import planetary_computer
import pystac_client
import rasterio
import requests
import skimage
from matplotlib.colors import BoundaryNorm, LightSource, ListedColormap, LogNorm
from matplotlib.lines import Line2D
from pyproj import Transformer
from rasterio import features
from rasterio.transform import from_origin, rowcol
from rasterio.warp import Resampling, reproject
from scipy import ndimage, sparse
from scipy.sparse import csgraph
from shapely.geometry import LineString
from skimage.graph import MCP_Geometric
HERE = Path(__file__).parent
OUT = HERE / "output"
DATA = HERE / "data"
OUT.mkdir(exist_ok=True)
DATA.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
SPECTRAL = plt.get_cmap("Spectral_r")
def section(title):
print(f"\n=== {title} ===")
def tobler_kmh(s):
"""Tobler's hiking function. Pass a signed gradient for direction, or abs(gradient) for the isotropic form."""
return 6.0 * np.exp(-3.5 * np.abs(s + 0.05))
section("versions")
print(f"python {platform.python_version()} | scikit-image {skimage.__version__} | numpy {np.__version__} | "
f"rasterio {rasterio.__version__} | networkx {nx.__version__} | scipy {version('scipy')}")
# ------------------------------------------------------------------ 1. study area and end points
RES = 10.0 # metres: WorldCover's native cell size
X0, Y0, X1, Y1 = 258_000, 351_000, 267_000, 359_000 # 9 km x 8 km around Yr Wyddfa, British National Grid
W, H = int((X1 - X0) / RES), int((Y1 - Y0) / RES)
TRANSFORM = from_origin(X0, Y1, RES, RES)
TO_BNG = Transformer.from_crs(4326, 27700, always_xy=True)
TO_LL = Transformer.from_crs(27700, 4326, always_xy=True)
START = ("Pen-y-Pass", 53.08066, -4.01934) # car park and youth hostel at the top of the Llanberis Pass
END = ("Yr Wyddfa summit", 53.06850, -4.07620) # summit trig point, 1,085 m
sx, sy = TO_BNG.transform(START[2], START[1])
ex, ey = TO_BNG.transform(END[2], END[1])
section("end points")
print(f"start {START[0]}: {sx:.0f} E, {sy:.0f} N | end {END[0]}: {ex:.0f} E, {ey:.0f} N | "
f"straight-line distance {np.hypot(ex - sx, ey - sy):.0f} m")
lon0, lat0, lon1, lat1 = TO_LL.transform_bounds(X0, Y0, X1, Y1)
catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace)
def mosaic(collection, asset, path, resampling, dtype, query=None):
"""Reproject every STAC item that touches the box onto the 10 m BNG grid."""
if not path.exists():
items = [i for i in catalog.search(collections=[collection], bbox=[lon0, lat0, lon1, lat1]).items()
if query is None or query in i.id]
print(f"{collection}: {', '.join(i.id for i in items)}")
nodata = np.nan if dtype == "float32" else 0
out = np.full((H, W), nodata, dtype=dtype)
for item in items:
with rasterio.open(item.assets[asset].href) as src:
part = np.full((H, W), nodata, dtype=dtype)
reproject(rasterio.band(src, 1), part, dst_transform=TRANSFORM, dst_crs="EPSG:27700",
dst_nodata=nodata, resampling=resampling)
empty = np.isnan(out) if dtype == "float32" else out == 0
out = np.where(empty, part, out)
with rasterio.open(path, "w", driver="GTiff", width=W, height=H, count=1, dtype=dtype, crs="EPSG:27700",
transform=TRANSFORM, compress="deflate") as dst:
dst.write(out, 1)
with rasterio.open(path) as src:
return src.read(1)
section("rasters on a 10 m British National Grid")
dem = mosaic("cop-dem-glo-30", "data", DATA / "glo30_snowdon_10m.tif", Resampling.bilinear, "float32").astype("float64")
lc = mosaic("esa-worldcover", "map", DATA / "worldcover_snowdon_10m.tif", Resampling.nearest, "uint8", query="2021")
print(f"grid {W} x {H} cells | DEM {np.nanmin(dem):.0f}-{np.nanmax(dem):.0f} m, missing {np.isnan(dem).sum()} | "
f"WorldCover classes {sorted(np.unique(lc).tolist())}")
# ------------------------------------------------------------------ 2. OpenStreetMap footpaths
OVERPASS = ["https://overpass-api.de/api/interpreter", "https://overpass.private.coffee/api/interpreter",
"https://maps.mail.ru/osm/tools/overpass/api/interpreter"]
QUERY = f"""[out:json][timeout:90];
way["highway"~"^(path|footway|track|bridleway|steps)$"]({lat0},{lon0},{lat1},{lon1});
(._;>;);
out;"""
def overpass(query, cache):
if cache.exists():
return json.loads(cache.read_text())
for attempt in range(4):
for url in OVERPASS:
try:
r = requests.post(url, data={"data": query}, timeout=120,
headers={"User-Agent": "technicalgis-tutorial"})
r.raise_for_status()
els = r.json()["elements"]
cache.write_text(json.dumps(els))
return els
except (requests.RequestException, ValueError):
continue
time.sleep(15 * (attempt + 1))
raise RuntimeError("all Overpass mirrors failed")
section("OpenStreetMap footpaths")
els = overpass(QUERY, DATA / "osm_paths_snowdon.json")
nodes = {e["id"]: TO_BNG.transform(e["lon"], e["lat"]) for e in els if e["type"] == "node"}
ways = [e for e in els if e["type"] == "way"]
SCRAMBLES = {"demanding_mountain_hiking", "alpine_hiking", "demanding_alpine_hiking", "difficult_alpine_hiking"}
def z_at(x, y):
"""Bilinear DEM height at British National Grid coordinates."""
col = (np.asarray(x) - X0) / RES - 0.5
row = (Y1 - np.asarray(y)) / RES - 0.5
return ndimage.map_coordinates(dem, [np.atleast_1d(row), np.atleast_1d(col)], order=1, mode="nearest")
G = nx.Graph()
for w in ways:
nd, tags = w["nodes"], w.get("tags", {})
for a, b in zip(nd[:-1], nd[1:]):
(xa, ya), (xb, yb) = nodes[a], nodes[b]
length = float(np.hypot(xb - xa, yb - ya))
grade = abs(float(z_at(xb, yb)[0] - z_at(xa, ya)[0])) / max(length, 0.1)
G.add_edge(a, b, length=length, minutes=length * 3.6 / tobler_kmh(grade) / 60,
name=tags.get("name", ""), sac=tags.get("sac_scale", ""))
paths_len = sum(d["length"] for *_, d in G.edges(data=True))
print(f"{len(ways)} ways, {G.number_of_nodes()} nodes, {paths_len / 1000:.1f} km of path in the box")
named = sorted({w["tags"]["name"] for w in ways if "name" in w.get("tags", {})})
print("named paths:", "; ".join(named))
sac = {}
for *_, d in G.edges(data=True):
sac[d["sac"] or "(no sac_scale)"] = sac.get(d["sac"] or "(no sac_scale)", 0) + d["length"] / 1000
print("sac_scale:", ", ".join(f"{k} {v:.1f} km" for k, v in sorted(sac.items(), key=lambda kv: -kv[1])))
comps = sorted(nx.connected_components(G), key=len, reverse=True)
print(f"{len(comps)} connected pieces; the largest holds {len(comps[0]) / G.number_of_nodes() * 100:.0f}% of the nodes")
node_ids = np.array(list(comps[0])) # snap only to the main network, not to stray fragments
node_xy = np.array([nodes[n] for n in node_ids])
def snap(x, y):
return node_ids[np.argmin(np.hypot(node_xy[:, 0] - x, node_xy[:, 1] - y))]
n_start, n_end = snap(sx, sy), snap(ex, ey)
print(f"start snapped {np.hypot(*(np.array(nodes[n_start]) - (sx, sy))):.0f} m, "
f"end snapped {np.hypot(*(np.array(nodes[n_end]) - (ex, ey))):.0f} m to the path network")
(sx, sy), (ex, ey) = nodes[n_start], nodes[n_end] # every route below uses the same two snapped points
def osm_route(graph, weight, label):
ids = nx.shortest_path(graph, n_start, n_end, weight=weight)
used = []
for a, b in zip(ids[:-1], ids[1:]):
nm = graph[a][b]["name"] or "(unnamed)"
if not used or used[-1][0] != nm:
used.append([nm, 0.0])
used[-1][1] += graph[a][b]["length"]
line = LineString([nodes[n] for n in ids])
mins = sum(graph[a][b]["minutes"] for a, b in zip(ids[:-1], ids[1:]))
hard = sum(graph[a][b]["length"] for a, b in zip(ids[:-1], ids[1:]) if graph[a][b]["sac"] in SCRAMBLES)
print(f"{label}: {line.length:.0f} m, {mins:.0f} min, {hard:.0f} m graded as scrambling | via " +
", ".join(f"{n} {l:.0f} m" for n, l in used if l > 100))
return line
shortest_line = osm_route(G, "length", "shortest OSM route")
walkers = G.edge_subgraph([(a, b) for a, b, d in G.edges(data=True) if d["sac"] not in SCRAMBLES])
osm_line = osm_route(walkers, "minutes", "fastest OSM route without scrambles")
# ------------------------------------------------------------------ 3. cost surface
section("cost surface")
gy, gx = np.gradient(dem, RES)
slope = np.hypot(gx, gy) # rise over run
slope_deg = np.degrees(np.arctan(slope))
speed = tobler_kmh(slope) # km/h; slope >= 0, so up and down cost the same
slope_cost = 3.6 / speed # seconds per metre
print(f"slope {np.percentile(slope_deg, 50):.1f} deg median, {np.percentile(slope_deg, 99):.1f} deg 99th percentile, "
f"{slope_deg.max():.1f} deg max")
print(f"Tobler speed: {tobler_kmh(0):.2f} km/h on the flat, {tobler_kmh(np.tan(np.radians(20))):.2f} km/h at 20 deg, "
f"{tobler_kmh(np.tan(np.radians(40))):.2f} km/h at 40 deg")
WC = {10: ("Tree cover", "#2b8a3e", 1.5), 20: ("Shrubland", "#a9c25d", 1.3), 30: ("Grassland", "#f3e48c", 1.0),
40: ("Cropland", "#f0a73a", 1.2), 50: ("Built-up", "#d7301f", 1.0), 60: ("Bare / sparse", "#bdbdbd", 1.25),
70: ("Snow and ice", "#f7f7f7", 1.5), 80: ("Permanent water", "#3182bd", np.inf),
90: ("Herbaceous wetland", "#5ab4ac", 2.0), 100: ("Moss and lichen", "#e0c9a6", 1.1)}
lc_mult = np.ones_like(dem)
for code, (_, _, m) in WC.items():
lc_mult[lc == code] = m
for code in np.unique(lc):
if code in WC:
print(f" {code:>3} {WC[code][0]:<20} {(lc == code).mean() * 100:5.1f}% of cells multiplier {WC[code][2]}")
cost = slope_cost * lc_mult
print(f"combined cost: {np.nanmin(cost):.2f}-{np.nanmax(cost[np.isfinite(cost)]):.2f} s/m, "
f"{(~np.isfinite(cost)).sum()} impassable water cells")
# ------------------------------------------------------------------ 4. accumulated cost and least-cost path
section("accumulated cost and least-cost path")
r0, c0 = rowcol(TRANSFORM, sx, sy)
r1, c1 = rowcol(TRANSFORM, ex, ey)
xs = X0 + (np.arange(W) + 0.5) * RES
ys = Y1 - (np.arange(H) + 0.5) * RES
osm_mask = features.rasterize([(osm_line, 1)], out_shape=(H, W), transform=TRANSFORM, all_touched=True)
all_paths = [LineString([nodes[a], nodes[b]]) for a, b in G.edges]
paths_mask = features.rasterize([(g, 1) for g in all_paths], out_shape=(H, W), transform=TRANSFORM, all_touched=True)
dist_to_osm = ndimage.distance_transform_edt(osm_mask == 0) * RES
dist_to_any = ndimage.distance_transform_edt(paths_mask == 0) * RES
def least_cost(costs):
t = time.perf_counter()
mcp = MCP_Geometric(costs, fully_connected=True, sampling=(RES, RES))
acc, _ = mcp.find_costs([(r0, c0)])
rc = np.array(mcp.traceback((r1, c1)))
return acc, rc, time.perf_counter() - t
def describe(rc):
line = LineString(np.c_[xs[rc[:, 1]], ys[rc[:, 0]]])
z = dem[rc[:, 0], rc[:, 1]]
d_osm = dist_to_osm[rc[:, 0], rc[:, 1]]
d_any = dist_to_any[rc[:, 0], rc[:, 1]]
stats = dict(length=line.length, ascent=float(np.clip(np.diff(z), 0, None).sum()),
within50=float((d_osm <= 50).mean() * 100), within50_any=float((d_any <= 50).mean() * 100),
mean_d=float(d_osm.mean()))
return line, stats
def walk(line, step=10.0):
"""Walk a line uphill from Pen-y-Pass: Tobler speed from the gradient along the line, sampled every 10 m."""
d = np.arange(0, line.length + step, step).clip(max=line.length)
p = np.array([line.interpolate(v).coords[0] for v in d])
z = z_at(p[:, 0], p[:, 1])
ds = np.diff(d)
ok = ds > 0
grade = np.diff(z)[ok] / ds[ok]
rr, cc = rowcol(TRANSFORM, p[:, 0], p[:, 1])
terrain = slope_deg[np.array(rr), np.array(cc)]
return dict(minutes=float(np.sum(ds[ok] * 3.6 / tobler_kmh(grade)) / 60),
ascent=float(np.clip(np.diff(z), 0, None).sum()),
water=float((lc[np.array(rr), np.array(cc)] == 80).mean() * line.length),
steepest_terrain=float(terrain.max()), over30=float((terrain > 30).mean() * 100))
acc, rc, dt = least_cost(cost)
lcp, st = describe(rc)
print(f"MCP_Geometric on {W * H:,} cells: {dt:.2f} s | accumulated cost at the summit {acc[r1, c1] / 60:.0f} min")
print(f"least-cost path: {st['length']:.0f} m, {len(rc)} cells | {st['within50']:.0f}% within 50 m of the OSM route, "
f"{st['within50_any']:.0f}% within 50 m of any mapped path, mean distance from the OSM route {st['mean_d']:.0f} m")
straight = LineString([(sx, sy), (ex, ey)])
naismith = lambda length, w: length / 5000 * 60 + w["ascent"] / 100 * 10
print(f"{'route':<22} {'length':>8} {'Tobler':>8} {'Naismith':>9} {'ascent':>7} {'water':>6} {'>30 deg':>8} {'steepest':>9}")
for name, line in (("straight line", straight), ("OSM walkers' route", osm_line),
("OSM shortest route", shortest_line), ("least-cost path", lcp)):
w = walk(line)
print(f"{name:<22} {line.length:7.0f}m {w['minutes']:6.0f}mn {naismith(line.length, w):7.0f}mn "
f"{w['ascent']:6.0f}m {w['water']:5.0f}m {w['over30']:7.0f}% {w['steepest_terrain']:7.0f}deg")
# ------------------------------------------------------------------ 5. how the weights change the route
section("cost weights and the route")
steep = np.where(slope_deg > 30, 4.0, 1.0) # extra penalty for scrambling ground
scenarios = {
"Distance only": np.where(lc == 80, np.inf, 1.0),
"Slope only (Tobler)": slope_cost,
"Slope + land cover": cost,
"Slope + land cover + 4x above 30 deg": cost * steep,
"As above + 0.5x on mapped paths": cost * steep * np.where(paths_mask == 1, 0.5, 1.0),
}
results = {}
for name, cst in scenarios.items():
_, rcs, dts = least_cost(cst)
line, s = describe(rcs)
s.update(walk(line)) # every route walked with the same Tobler rule
results[name] = (line, s)
print(f"{name:<37} {s['length']:5.0f} m | {s['minutes']:4.0f} min | ascent {s['ascent']:4.0f} m | water "
f"{s['water']:4.0f} m | {s['over30']:3.0f}% over 30 deg | {s['within50']:3.0f}% within 50 m of the OSM route, "
f"{s['within50_any']:3.0f}% of any path | {dts:.2f} s")
conn = {}
for fc in (False, True):
mcp = MCP_Geometric(cost, fully_connected=fc, sampling=(RES, RES))
mcp.find_costs([(r0, c0)])
conn[fc] = describe(np.array(mcp.traceback((r1, c1))))[0].length
print(f"4-connected moves: {conn[False]:.0f} m | 8-connected moves: {conn[True]:.0f} m "
f"(diagonals cut the zigzag by {(1 - conn[True] / conn[False]) * 100:.1f}%)")
# ------------------------------------------------------------------ 6. direction-aware (anisotropic) cost
section("anisotropic cost with scipy.sparse.csgraph")
t = time.perf_counter()
idx = np.arange(H * W).reshape(H, W)
src_l, dst_l, w_l = [], [], []
for dr, dc in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
rs = slice(max(0, -dr), H - max(0, dr)); cs_ = slice(max(0, -dc), W - max(0, dc)) # source cells
rd = slice(rs.start + dr, rs.stop + dr); cd = slice(cs_.start + dc, cs_.stop + dc) # their neighbours
step_len = RES * np.hypot(dr, dc)
grade = (dem[rd, cd] - dem[rs, cs_]) / step_len # signed: positive is uphill
secs = step_len * 3.6 / tobler_kmh(grade) * (lc_mult[rs, cs_] + lc_mult[rd, cd]) / 2
ok = np.isfinite(secs)
src_l.append(idx[rs, cs_][ok]); dst_l.append(idx[rd, cd][ok]); w_l.append(secs[ok])
graph = sparse.csr_matrix((np.concatenate(w_l), (np.concatenate(src_l), np.concatenate(dst_l))), shape=(H * W, H * W))
t_build = time.perf_counter() - t
t = time.perf_counter()
acc_aniso, pred = csgraph.dijkstra(graph, directed=True, indices=idx[r0, c0], return_predecessors=True)
t_dij = time.perf_counter() - t
node, seq = idx[r1, c1], []
while node != idx[r0, c0]:
seq.append(node); node = pred[node]
seq.append(node)
rc_an = np.array(np.unravel_index(np.array(seq[::-1]), (H, W))).T
aniso, st_an = describe(rc_an)
st_an.update(walk(aniso))
print(f"graph: {graph.shape[0]:,} nodes, {graph.nnz:,} directed edges, built in {t_build:.2f} s | Dijkstra {t_dij:.2f} s")
print(f"accumulated cost at the summit {acc_aniso[idx[r1, c1]] / 60:.0f} min (isotropic: {acc[r1, c1] / 60:.0f} min)")
print(f"anisotropic least-cost path: {st_an['length']:.0f} m | Tobler {st_an['minutes']:.0f} min | ascent "
f"{st_an['ascent']:.0f} m | {st_an['over30']:.0f}% over 30 deg | {st_an['within50']:.0f}% within 50 m of the OSM "
f"route, {st_an['within50_any']:.0f}% of any path | mean offset {st_an['mean_d']:.0f} m")
results["Anisotropic Tobler + land cover"] = (aniso, st_an)
# ------------------------------------------------------------------ 6. figures
section("figures")
hs = LightSource(azdeg=315, altdeg=45).hillshade(dem, vert_exag=1.5, dx=RES, dy=RES)
ext = (X0, X1, Y0, Y1)
zoom = (259_500, 266_000, 352_500, 357_500)
water = np.ma.masked_where(lc != 80, lc)
BLUE = ListedColormap(["#3182bd"])
def base(ax, z=True):
ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
ax.imshow(water, cmap=BLUE, extent=ext, alpha=0.9)
if z:
ax.set_xlim(zoom[0], zoom[1]); ax.set_ylim(zoom[2], zoom[3])
ax.set_xticks([]); ax.set_yticks([])
def pts(ax):
ax.plot(sx, sy, "o", ms=8, mfc="white", mec="black", zorder=6)
ax.plot(ex, ey, "^", ms=9, mfc="white", mec="black", zorder=6)
ax.annotate("Pen-y-Pass", (sx, sy), xytext=(6, -14), textcoords="offset points", fontsize=8,
bbox=dict(fc="white", ec="none", alpha=0.85, pad=1))
ax.annotate("Yr Wyddfa", (ex, ey), xytext=(-24, 10), textcoords="offset points", fontsize=8,
bbox=dict(fc="white", ec="none", alpha=0.85, pad=1))
# Figure 1: study area
fig, ax = plt.subplots(figsize=(8.5, 7))
ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
im = ax.imshow(dem, cmap=SPECTRAL, extent=ext, alpha=0.55)
ax.imshow(water, cmap=BLUE, extent=ext)
for g in all_paths:
ax.plot(*g.xy, color="black", lw=0.6, alpha=0.8)
ax.plot(*osm_line.xy, color="white", lw=4.5); ax.plot(*osm_line.xy, color="#7b3294", lw=2.5)
ax.plot(*straight.xy, color="black", lw=1.5, ls="--")
ax.plot(*shortest_line.xy, color="#d53e4f", lw=2, ls=":")
pts(ax)
ax.set_xlim(X0, X1); ax.set_ylim(Y0, Y1); ax.set_xticks([]); ax.set_yticks([])
fig.colorbar(im, ax=ax, shrink=0.7, label="elevation (m, Copernicus GLO-30)")
ax.legend(handles=[Line2D([], [], color="black", lw=0.6, label="OSM footpaths and tracks"),
Line2D([], [], color="#7b3294", lw=2.5, label=f"fastest OSM route without scrambles ({osm_line.length / 1000:.1f} km)"),
Line2D([], [], color="#d53e4f", lw=2, ls=":", label=f"shortest OSM route, over Crib Goch ({shortest_line.length / 1000:.1f} km)"),
Line2D([], [], color="black", lw=1.5, ls="--", label=f"straight line ({straight.length / 1000:.1f} km)"),
Line2D([], [], color="#3182bd", lw=6, label="permanent water (WorldCover)")],
loc="lower left", fontsize=7.5, framealpha=0.9)
ax.set_title("Yr Wyddfa (Snowdon): 9 km x 8 km on British National Grid, 10 m cells")
fig.savefig(OUT / "01-study-area.png"); plt.close(fig)
# Figure 2: cost-surface building steps
fig, axs = plt.subplots(2, 3, figsize=(13, 8.4))
a = axs[0, 0]
a.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
im = a.imshow(slope_deg, cmap=SPECTRAL, extent=ext, vmin=0, vmax=50, alpha=0.75)
a.set_xticks([]); a.set_yticks([]); fig.colorbar(im, ax=a, shrink=0.75, label="slope (degrees)")
a.set_title("1. Slope from the DEM")
a = axs[0, 1]
s = np.linspace(0, 1.2, 200)
a.plot(np.degrees(np.arctan(s)), tobler_kmh(s), color="#d7301f", lw=2)
a.set_xlabel("slope (degrees)"); a.set_ylabel("walking speed (km/h)"); a.grid(alpha=0.3)
a.set_title("2. Tobler's hiking function (isotropic)")
for deg in (0, 10, 20, 30, 40):
v = tobler_kmh(np.tan(np.radians(deg)))
a.annotate(f"{v:.1f} km/h", (deg, v), xytext=(4, 4), textcoords="offset points", fontsize=7.5)
a.plot(deg, v, "o", color="#d7301f", ms=4)
a = axs[0, 2]
im = a.imshow(slope_cost, cmap=SPECTRAL, extent=ext, norm=LogNorm(0.7, 15))
a.set_xticks([]); a.set_yticks([]); fig.colorbar(im, ax=a, shrink=0.75, label="seconds per metre")
a.set_title("3. Slope cost = 3.6 / speed")
codes = sorted(WC)
a = axs[1, 0]
a.imshow(np.ma.masked_equal(lc, 0), cmap=ListedColormap([WC[c][1] for c in codes]), extent=ext,
norm=BoundaryNorm([c - 5 for c in codes] + [codes[-1] + 5], len(codes)), interpolation="nearest")
a.set_xticks([]); a.set_yticks([]); a.set_title("4. ESA WorldCover 2021")
present = [c for c in codes if (lc == c).any()]
a.legend(handles=[Line2D([], [], marker="s", ls="", mfc=WC[c][1], mec="grey", ms=7, label=WC[c][0]) for c in present],
loc="lower left", fontsize=6.5, framealpha=0.9)
a = axs[1, 1]
im = a.imshow(np.where(np.isfinite(lc_mult), lc_mult, np.nan), cmap=SPECTRAL, extent=ext, vmin=1, vmax=2)
a.imshow(water, cmap=ListedColormap(["black"]), extent=ext)
a.set_xticks([]); a.set_yticks([]); fig.colorbar(im, ax=a, shrink=0.75, label="multiplier (water impassable, black)")
a.set_title("5. Land-cover multiplier")
a = axs[1, 2]
im = a.imshow(np.where(np.isfinite(cost), cost, np.nan), cmap=SPECTRAL, extent=ext, norm=LogNorm(0.7, 15))
a.imshow(water, cmap=ListedColormap(["black"]), extent=ext)
a.set_xticks([]); a.set_yticks([]); fig.colorbar(im, ax=a, shrink=0.75, label="seconds per metre")
a.set_title("6. Cost = slope cost x multiplier")
fig.suptitle("Building the cost surface, step by step", fontsize=11)
fig.tight_layout(); fig.savefig(OUT / "02-cost-surface-steps.png"); plt.close(fig)
# Figure 3: accumulated cost surfaces, isotropic and anisotropic
fig, axs = plt.subplots(1, 2, figsize=(13, 5.6))
for a, surf, line, title in ((axs[0], acc, lcp, "Isotropic: MCP_Geometric on slope + land cover"),
(axs[1], acc_aniso.reshape(H, W), aniso, "Anisotropic: Dijkstra on uphill/downhill edges")):
mins = np.where(np.isfinite(surf), surf / 60, np.nan)
a.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
im = a.imshow(mins, cmap=SPECTRAL, extent=ext, alpha=0.65, vmin=0, vmax=240)
cs = a.contour(xs, ys, mins, levels=np.arange(30, 241, 30), colors="black", linewidths=0.6)
a.clabel(cs, fmt="%d min", fontsize=7)
a.imshow(water, cmap=BLUE, extent=ext)
a.plot(*line.xy, color="white", lw=4); a.plot(*line.xy, color="#d7301f", lw=2)
pts(a); a.set_xticks([]); a.set_yticks([])
a.set_title(f"{title}\n{surf.flat[idx[r1, c1]] / 60:.0f} min to the summit", fontsize=9)
fig.colorbar(im, ax=axs, shrink=0.8, label="minutes from Pen-y-Pass (accumulated cost)")
fig.savefig(OUT / "03-accumulated-cost.png"); plt.close(fig)
def profile(line):
d = np.arange(0, line.length, 10.0)
p = np.array([line.interpolate(v).coords[0] for v in d])
return d / 1000, z_at(p[:, 0], p[:, 1])
# Figure 4: least-cost paths vs straight line vs OSM route, with profiles
ROUTES = ((straight, "black", "--", "straight line"), (osm_line, "#7b3294", "-", "OSM walkers' route (Pyg Track)"),
(lcp, "#f46d43", "-", "isotropic least-cost path"), (aniso, "#9e0142", "-", "anisotropic least-cost path"))
fig = plt.figure(figsize=(12, 6.2))
ax = fig.add_axes([0.0, 0.0, 0.5, 1.0]); base(ax)
for g in all_paths:
ax.plot(*g.xy, color="black", lw=0.5, alpha=0.6)
for line, col, ls_, lab in ROUTES:
if ls_ == "-":
ax.plot(*line.xy, color="white", lw=4.2)
ax.plot(*line.xy, color=col, lw=2.2 if col == "#7b3294" else 1.7, ls=ls_)
pts(ax)
ax.legend(handles=[Line2D([], [], color=c, lw=2, ls=s, label=l) for _, c, s, l in ROUTES],
loc="lower left", fontsize=7.5, framealpha=0.9)
ax2 = fig.add_axes([0.57, 0.12, 0.42, 0.8])
for line, col, ls_, lab in ROUTES:
d, z = profile(line)
ax2.plot(d, z, color=col, ls=ls_, lw=1.8, label=f"{lab}: {line.length / 1000:.2f} km")
ax2.set_xlabel("distance from Pen-y-Pass (km)"); ax2.set_ylabel("elevation (m)"); ax2.grid(alpha=0.3)
ax2.legend(fontsize=8); ax2.set_title("Elevation profiles")
fig.savefig(OUT / "04-route-comparison.png"); plt.close(fig)
# Figure 5: how the weights change the route
cols = ["#5e4fa2", "#3288bd", "#d7301f", "#f46d43", "#66c2a5", "#9e0142"]
fig = plt.figure(figsize=(12.5, 6.4))
ax = fig.add_axes([0.0, 0.0, 0.5, 1.0]); base(ax)
ax.plot(*osm_line.xy, color="#fdbf2d", lw=7, alpha=0.85, solid_capstyle="round")
for (name, (line, s)), col in zip(results.items(), cols):
ax.plot(*line.xy, color="white", lw=3.2); ax.plot(*line.xy, color=col, lw=1.7)
pts(ax)
ax.legend(handles=[Line2D([], [], color="#fdbf2d", lw=6, alpha=0.85, label="OSM walkers' route (Pyg Track)")] +
[Line2D([], [], color=c, lw=1.7, label=n) for n, c in zip(results, cols)],
loc="lower left", fontsize=7, framealpha=0.9)
names = list(results)
y = np.arange(len(names))
short = [n.replace("Slope + land cover + 4x above 30 deg", "Slope + LC + 4x above 30 deg")
.replace("Anisotropic Tobler + land cover", "Anisotropic Tobler + LC") for n in names]
for i, (key, lab, fmt) in enumerate((("minutes", "walking time uphill, Tobler along the route (min)", "{:.0f}"),
("within50", "% of route within 50 m of the OSM walkers' route", "{:.0f}%"))):
b = fig.add_axes([0.66, 0.56 - i * 0.48, 0.32, 0.38])
vals = [results[n][1][key] for n in names]
b.barh(y, vals, color=cols)
for yy, v in zip(y, vals):
b.text(v, yy, " " + fmt.format(v), va="center", fontsize=7.5)
b.set_yticks(y, short, fontsize=7.5)
b.invert_yaxis(); b.set_xlabel(lab); b.set_xlim(0, max(vals) * 1.25); b.grid(axis="x", alpha=0.3)
fig.savefig(OUT / "05-cost-weights.png"); plt.close(fig)
print("saved 5 figures to", OUT)