Step-Free Routing with Weighted Graphs

Step-free routing is the concrete case that motivates the whole multi-profile design in Accessible & Multi-Profile Indoor Routing: given one attributed graph, compute a path that a wheelchair user, a parent with a pushchair, or a porter with a cart can actually traverse, by making every staircase impassable rather than merely expensive.

What “Step-Free Routing” Means

Step-free routing is shortest-path search over a graph in which any edge that requires climbing or descending steps is treated as absent, not costly. The distinction is the entire subject. A step-free route may never include a stair edge; it may include a corridor, a doorway wide enough for a wheelchair, a ramp within an acceptable incline, or an elevator. Everything else about the search is ordinary Dijkstra — the only thing that makes it “step-free” is the weight function that returns an exclusion sentinel for stairs and a finite cost for step-free connectors.

The word “weighted” carries a second meaning here beyond exclusion. Among the step-free options, an elevator is not free: it has a call-and-wait cost that a flat corridor of the same length does not. A ramp near the maximum permitted incline is harder than a flat corridor. So the weight function does two jobs at once: it excludes impassable edges by returning None, and it penalises passable-but-costly edges by returning an inflated but finite number. The result is a route that is both legal (no steps) and humane (prefers the flat corridor to the long ramp when both work).

Minimal Working Example

The example below builds a small networkx graph whose edges carry the accessibility attributes described in the parent section, defines a step-free weight callable, and runs Dijkstra. The stair edge between the lobby and the mezzanine is the trap: a distance-only router takes it because it is short, and a step-free router must refuse it and take the ramp instead.

import logging
from typing import Optional

import networkx as nx

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger(__name__)

STAIR_SENTINEL = None          # returning None removes the edge from this profile's graph
ELEVATOR_WAIT_M = 8.0          # call-and-wait modelled as equivalent metres

G = nx.Graph()
#            u          v            space_class  step_free  incline  length_m
G.add_edge("lobby",    "mezz",      space_class="stair",    step_free=False, incline=0.0,  length_m=6.0)
G.add_edge("lobby",    "ramp_foot", space_class="corridor", step_free=True,  incline=0.0,  length_m=9.0)
G.add_edge("ramp_foot","mezz",      space_class="ramp",     step_free=True,  incline=4.5,  length_m=14.0)
G.add_edge("lobby",    "lift_a",    space_class="corridor", step_free=True,  incline=0.0,  length_m=5.0)
G.add_edge("lift_a",   "mezz",      space_class="elevator", step_free=True,  incline=0.0,  length_m=3.0)


def step_free_weight(u: str, v: str, attrs: dict) -> Optional[float]:
    """Return None for anything with steps; a finite, penalty-adjusted cost otherwise."""
    if not attrs["step_free"] or attrs["space_class"] == "stair":
        return STAIR_SENTINEL                       # hard exclusion, not a penalty
    base = float(attrs["length_m"])
    if attrs["space_class"] == "elevator":
        return base + ELEVATOR_WAIT_M               # passable but costs its wait
    if attrs["space_class"] == "ramp":
        return base * (1.0 + attrs["incline"] / 10.0)  # steeper ramp, higher cost
    return base


try:
    path = nx.dijkstra_path(G, "lobby", "mezz", weight=step_free_weight)
    logger.info("step-free path: %s", " -> ".join(path))
except nx.NetworkXNoPath:
    logger.warning("no step-free path to mezz; escalate, do not use stairs")
    path = []

Running it prints a route that goes lobby -> ramp_foot -> mezz (cost 9.0 + 14.0 × 1.45 ≈ 29.3) rather than the elevator branch (5.0 + 3.0 + 8.0 = 16.0)… and that comparison is the point: the elevator branch is actually cheaper, so Dijkstra returns lobby -> lift_a -> mezz. The direct lobby -> mezz stair (length 6.0) is never considered because step_free_weight returned None for it. Swap the elevator out of service — set its step_free to False — and the router correctly falls back to the ramp instead of ever touching the stair.

Step-Free Weight Reference

Edge attribute / symbol Type Example Role in the weight function
space_class str stair, ramp, elevator, corridor Selects the branch; stair is always excluded
step_free bool False on any stepped edge Primary exclusion switch; False returns the sentinel
incline float (deg) 4.5 Scales ramp cost; edges above the legal max should be excluded upstream
length_m float (m) 14.0 Base traversal cost for every passable edge
ELEVATOR_WAIT_M float (m) 8.0 Equivalent-distance penalty added to elevator edges
STAIR_SENTINEL None None The exclusion value Dijkstra reads as “edge absent”
return value Optional[float] 29.3 or None None excludes; a finite float is the profile cost

Common Errors & Fixes

A wheelchair route still contains a staircase. The weight function penalised the stair instead of excluding it — it returned something like attrs["length_m"] * 50 rather than None. A finite penalty is still finite, so on a link where every alternative is longer, the “avoided” stair wins. Return the sentinel:

# WRONG: a penalty is selectable when the detour is long enough.
# return attrs["length_m"] * 50
# RIGHT: None removes the edge from this profile's graph entirely.
if not attrs["step_free"] or attrs["space_class"] == "stair":
    return None

networkx.exception.NetworkXNoPath when a route visibly exists on the floor plan. Excluding the stairs disconnected the step-free subgraph — there is genuinely no ramp or elevator between the two components. This is a correct result, not a solver bug; do not “fix” it by relaxing the exclusion. Detect it and escalate to a human-readable message or the fallback routing architecture, and file the missing connector against the survey:

try:
    path = nx.dijkstra_path(G, src, dst, weight=step_free_weight)
except nx.NetworkXNoPath:
    logger.warning("step-free subgraph disconnected between %s and %s", src, dst)
    return None   # surface "no step-free route", never silently fall back to stairs

Elevators treated as free, so the router prefers a lift for a single-floor hop. Forgetting ELEVATOR_WAIT_M makes a 3 m elevator edge look cheaper than a 9 m flat corridor, and the router sends a step-free user to wait for a lift to travel one short segment. Add the call-and-wait cost so the elevator is chosen only when it genuinely shortens the journey, and tune the constant against observed dwell time rather than leaving it at zero.

Integration Point

This reference is the smallest working piece of a larger design. The step-free weight callable here is one instance of the profile weight functions compiled in Accessible & Multi-Profile Indoor Routing — in production you would register it alongside default, assisted, and service profiles that all share this same graph. The graph it runs over is built from parsed geometry and carries stair, ramp, and elevator edges only because Level Mapping & Z-Axis Logic modelled the vertical connectors as distinct edge classes with real endpoints on each floor. When the step-free search returns no path, control passes to the deterministic tiers in Fallback Routing Architectures, which keep the session alive instead of dropping it.

Frequently Asked Questions

Should stairs be a huge weight or an exclusion?

An exclusion — always return None (or omit the edge) for a stair in a step-free profile, never a large finite weight. A finite number, however big, is still comparable, so a long enough alternative route makes the penalised stair the cheapest option and it reappears in a wheelchair route. Only removing the edge from the profile’s graph guarantees the result is either step-free or an honest “no path”, which is exactly the guarantee a step-free profile must make.

How do I model elevator wait time in a distance-weighted graph?

Convert the expected call-and-wait time into an equivalent distance and add it to the elevator edge’s cost. If a user walks about 1.3 m/s and the average lift wait is roughly six seconds, an ELEVATOR_WAIT_M around 8 m makes the graph consistently comparable in metres. Tune the constant against measured dwell time per building; the goal is that a lift is preferred over stairs (which are excluded anyway) but not over a short flat corridor for a trivial hop.

Why use Dijkstra rather than A* for step-free routing?

Because the step-free weight is no longer a pure metric — it excludes edges and adds a non-geometric elevator penalty — so a straight-line Euclidean A* heuristic can overestimate the true remaining cost and become inadmissible, returning a suboptimal path. Dijkstra makes no assumption about the weight and stays correct. Reserve A* for the plain distance profile where the heuristic is provably admissible.

This page is part of the Accessible & Multi-Profile Indoor Routing guide within the Indoor Mapping Architecture & Standards reference.