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).

The same origin and destination with and without the stair link A floor outline with a rectangular corridor loop drawn in teal and two vertical cross-corridors. The origin sits at the top-left node and the destination at the bottom-right. A dashed orange link runs straight across the middle, marked with a red cross: it is a stair and carries infinite weight for the step-free profile. Using it the route would be 36 metres; without it the step-free route follows the perimeter for 78 metres. The destination remains reachable either way. Blocking an edge lengthens a route; deleting it can disconnect a floor origin destination stair link — weight = inf for step_free 36 m if usable step-free detour: 78 m via the perimeter step-free edges blocked for this profile

Infinite weight, not deletion. The stair edge stays in the graph so the router can report why a route is long — and so the default profile, querying the same graph a millisecond later, still sees a 36 m path.

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
How each weight magnitude behaves, and where each one misleads A reference grid of five weight treatments. A weight of 1.0 is neutral and the edge is chosen on merit. Weights between 1.2 and 2.5 discourage an edge so it is used only when much shorter, which is wrong when the edge is actually a hard barrier. Weights in the 50 to 500 range are near-prohibitive and used as a last resort, which produces absurdly long routes when a genuine block was meant. Infinity blocks the edge while keeping it in the graph, and is the correct treatment even when a level has no lift. Removing the edge makes it invisible, can disconnect the graph, and breaks every other profile sharing the topology. Discouraged, prohibitive and blocked are three different things Edge weight Weight Effect on the solve When it is wrong 1.0 neutral edge used on merit 1.2 - 2.5 discouraged used only if much shorter △ hides a hard barrier 50 - 500 near-prohibitive used as a last resort △ produces absurd routes math.inf ● blocked never traversed, still present when a level has no lift edge removed △ invisible graph may disconnect △ breaks other profiles The last row is the anti-pattern: a shared topology must not be mutated per profile.

Large finite weights are the subtle bug. A weight of 500 says “avoid unless desperate”, so a router with no alternative will happily send a wheelchair user up a stair rather than report no route.

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:

How a profile is resolved and applied on a single routing request A sequence across four participants. The client requests a route with the step_free profile. The routing API asks the profile registry for that profile's weights and gets back a multiplier vector. It calls shortest_path on the shared graph with a weight function rather than a mutated graph; the graph skips edges whose weight is infinite and returns a node sequence. The API returns the route together with the profile it applied and the detour ratio against the default profile. One graph, resolved weights, an echoed profile Client Routing API Profile registry Graph GET /route?profile=step_free weights_for('step_free') multiplier vector shortest_path(weight=fn) skip edges weighted inf node sequence route + profile_applied + detour_ratio The response echoes the profile so a client can never silently render the wrong route.

Weights arrive as a function, not as a mutation. That is what lets one in-memory graph serve every profile concurrently, and what makes profile_applied in the response honest.

# 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.