Accessible & Multi-Profile Indoor Routing
A wayfinding engine that returns one shortest path for everyone is quietly wrong for a large fraction of the people who use it: the geometrically shortest route through a building routinely crosses a staircase, and a staircase is not a shorter version of a ramp — it is impassable. This guide sits inside the Indoor Mapping Architecture & Standards framework, and it treats accessibility not as a post-processing filter bolted onto a finished route but as a first-class property of the routing graph itself. The pattern is a single shared topology carrying rich per-edge attributes, plus a set of routing profiles — default walking, step-free/wheelchair, assisted, and service — each of which projects that one topology into its own weighted view before the solver ever runs.
The Problem: One Shortest Path Is the Wrong Answer
The naive indoor router models the building as a weighted graph where every edge weight is a traversal cost, usually metres, and calls shortest_path once. That call encodes exactly one policy — “minimise distance for an unconstrained pedestrian” — and silently applies it to every user. Three failures follow directly.
First, stairs are penalised when they must be excluded. A common half-fix is to multiply stair-edge weights by some factor so the solver “avoids” them. But a penalty is a preference, not a prohibition: on a floor where the only connection between two wings is a short flight of steps, a penalised stair is still cheaper than a 40-metre detour, so the solver happily routes a wheelchair user up the steps. The correct model makes a stair edge non-existent for a step-free profile — an infinite or absent weight, not a scaled one.
Second, accessibility is multi-dimensional and per-user. “Accessible” is not one flag. A wheelchair user needs step-free edges and adequate door width; a user pushing a delivery cart needs step-free edges but does not care about door-open force; a user with low vision wants well-lit, signage-dense corridors and does not care about incline. Baking a single is_accessible boolean onto edges collapses all of these into one lossy bit and satisfies none of them precisely.
Third, maintaining N separate graphs drifts. The tempting shortcut — build one graph for walking and a second, hand-pruned graph for wheelchair routing — doubles the surface area that can rot. A corridor closure has to be applied to both; a topology fix lands in one and not the other; the two graphs disagree about which nodes even exist. The moment there is more than one physical graph, the topology reconciliation problem multiplies by the number of profiles.
The resolution to all three is the same: model accessibility as per-edge attributes on one authoritative topology, and derive each profile’s traversal cost from those attributes with a pure weight function at query time.
Prerequisites & Dependencies
Before any per-profile routing is meaningful the upstream pipeline must already have produced a connected, attributed graph:
- A routing graph with accessibility metadata on every edge.
networkx>=3.0for in-process graphs, where each edge carries at minimumspace_class(corridor,door,stair,elevator,ramp), a booleanstep_free, aninclinein degrees, adoor_width_m, asurfaceclass, and a numeric baselength_m. These attributes originate in the attribute pass described in POI Taxonomy & Classification and the geometry parsed upstream; the router never invents them. - A consistent metric datum. Every
length_mand every snapping tolerance is in metres under a well-defined Indoor Coordinate Reference System, so weights are comparable across floors and buildings. - Correct vertical connectors. Stair, ramp, and elevator edges must be modelled as distinct
space_classvalues with real endpoints on each level, which is exactly the job of Level Mapping & Z-Axis Logic. A profile can only prefer an elevator over a stair if both exist as separate edges. networkx>=3.0for the graph and its Dijkstra/A* implementations; the same code runs onigraphat campus scale with an integer-indexed adjacency, a trade-off covered in the companion comparison on NetworkX vs. igraph for large routing graphs.
How It Works: One Topology, Many Weighted Views
The core idea is a separation between structure and cost. The topology — which nodes connect to which, and the physical attributes of each connection — is authored once and shared. A profile is nothing more than a named weight function w(u, v, attrs) -> float | None that reads those attributes and returns either a finite traversal cost or a hard-exclusion sentinel. The default profile returns length_m for almost everything and a mild multiplier for stairs; the step-free profile returns None (exclude) for any stair and a length-plus-wait cost for elevators and ramps. Both call the same Dijkstra over the same node set, and they diverge only because the weight function projects a different graph.
The load-bearing consequence: because both profiles share the node set, a route computed under either profile can be snapped, cached, and reconciled by the same downstream machinery. Nothing about the fallback routing architecture has to know how many profiles exist.
Step-by-Step Implementation
The implementation has three ordered steps: attach accessibility attributes to the edges of the shared graph, express each profile as a pure weight function that excludes or penalises, and run Dijkstra (or A*) once per profile to obtain divergent routes over one topology.
Step 1 — Attach accessibility attributes to every edge
The graph builder writes physical attributes onto each edge exactly once. Downstream profiles read these and never mutate them, which is what keeps one topology authoritative. Model the attributes as a typed structure so a missing tag is caught at build time, not at 14:30 during a live route.
import logging
from dataclasses import dataclass, asdict
from typing import Optional
import networkx as nx
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EdgeAccess:
space_class: str # corridor | door | stair | elevator | ramp
step_free: bool
incline_deg: float # 0.0 for flat corridors
door_width_m: float # inf when the edge is not a doorway
surface: str # smooth | tactile | uneven
length_m: float
def attach_access_attributes(graph: nx.Graph, edge_access: dict[tuple[str, str], EdgeAccess]) -> nx.Graph:
"""Write physical accessibility attributes onto every edge of the shared topology."""
missing = 0
for u, v in graph.edges():
access = edge_access.get((u, v)) or edge_access.get((v, u))
if access is None:
# Fail closed: an untagged edge is treated as NOT step-free until proven otherwise.
access = EdgeAccess("corridor", step_free=False, incline_deg=0.0,
door_width_m=0.0, surface="uneven", length_m=1.0)
missing += 1
graph[u][v].update(asdict(access))
if missing:
logger.warning("%d edges lacked accessibility tags; defaulted to non-step-free", missing)
logger.info("attached access attributes to %d edges", graph.number_of_edges())
return graph
Step 2 — Build a profile weight function that excludes or penalises
A profile is a pure callable with the exact signature networkx expects for a weight function: w(u, v, attrs) -> Optional[float]. Returning None tells Dijkstra the edge does not exist for this profile — that is the hard exclusion that a multiplier can never express. Returning a finite float applies a soft preference. Build profiles from a small declarative policy so a product owner can read them.
import logging
from typing import Callable, Optional
logger = logging.getLogger(__name__)
# A weight function matches networkx's contract: (u, v, edge_attrs) -> cost or None.
WeightFn = Callable[[str, str, dict], Optional[float]]
ELEVATOR_WAIT_M = 8.0 # model lift wait as an equivalent-distance cost
def make_profile(name: str, *, exclude_stairs: bool, stair_multiplier: float,
min_door_width_m: float, max_incline_deg: float) -> WeightFn:
"""Compile an accessibility policy into a networkx weight function over the shared graph."""
def weight(u: str, v: str, attrs: dict) -> Optional[float]:
cls = attrs["space_class"]
# Hard exclusions: return None so the edge is absent from THIS profile's graph.
if cls == "stair" and exclude_stairs:
return None
if attrs["door_width_m"] < min_door_width_m:
return None
if attrs["incline_deg"] > max_incline_deg:
return None
base = attrs["length_m"]
if cls == "stair":
return base * stair_multiplier # soft penalty only when stairs are allowed
if cls == "elevator":
return base + ELEVATOR_WAIT_M # elevators cost their wait, but are preferred vs. None
return base
logger.info("compiled profile '%s' (exclude_stairs=%s)", name, exclude_stairs)
return weight
DEFAULT: WeightFn = make_profile("default", exclude_stairs=False, stair_multiplier=1.4,
min_door_width_m=0.0, max_incline_deg=90.0)
STEP_FREE: WeightFn = make_profile("step_free", exclude_stairs=True, stair_multiplier=1.0,
min_door_width_m=0.85, max_incline_deg=6.0)
Step 3 — Run Dijkstra per profile for divergent routes
With the topology attributed and the profiles compiled, a route request runs the same solver once per profile. networkx accepts the weight callable directly, so no graph is copied and no edge is deleted; the exclusion happens lazily as the solver relaxes edges. Guard the two exceptions that actually fire — a disconnected step-free subgraph (NetworkXNoPath) and an endpoint that is not a node (NodeNotFound).
import logging
from typing import Optional
import networkx as nx
logger = logging.getLogger(__name__)
def route_for_profile(graph: nx.Graph, source: str, target: str, weight: WeightFn,
profile_name: str) -> Optional[list[str]]:
"""Shortest path under one accessibility profile; None when unreachable for that profile."""
try:
path = nx.dijkstra_path(graph, source=source, target=target, weight=weight)
except nx.NetworkXNoPath:
logger.warning("no %s route from %s to %s (subgraph disconnected)", profile_name, source, target)
return None
except nx.NodeNotFound as exc:
logger.error("endpoint missing for %s route: %s", profile_name, exc)
return None
logger.info("%s route: %d nodes", profile_name, len(path))
return path
def route_all_profiles(graph: nx.Graph, source: str, target: str) -> dict[str, Optional[list[str]]]:
"""Return one route per registered profile over the single shared topology."""
return {
"default": route_for_profile(graph, source, target, DEFAULT, "default"),
"step_free": route_for_profile(graph, source, target, STEP_FREE, "step_free"),
}
Edge Cases & Gotchas
| Failure pattern | Symptom | Mitigation |
|---|---|---|
| Stairs-only region traps a step-free profile | NetworkXNoPath for a target that is clearly on the map |
Detect it explicitly and surface “no step-free route”, never fall back to a stair path; audit the wing for a missing ramp or lift edge |
| Elevator flagged out of service | Step-free users routed into a dead lift | Feed live BMS state into the weight function and return None for an offline elevator edge, so it is excluded, not just penalised |
| Missing accessibility tags | Untagged edge silently used by every profile | Default untagged edges to step_free=false (fail closed) at build time so they are excluded from step-free routing until surveyed |
| Penalty used where exclusion is required | Wheelchair route still crosses steps on a short link | Return None, never a large finite weight — a big number is still finite and still chosen over a long detour |
| Incline just under threshold | A 5.9° ramp accepted, a 6.1° rejected inconsistently | Pin max_incline_deg to your regulatory basis (commonly ~4.8° / 1:12) and unit-test the boundary |
| Door width absent on a real doorway | door edge excluded from step-free even though it is wide |
Populate door_width_m in the attribute pass; treat 0.0 as “unknown, excluded”, inf as “not a doorway” |
Validation Output
Assert on the properties of each profile’s route, not just that a path exists. The two invariants that matter: a step-free route contains no stair edges, and the reachable set genuinely differs between profiles. A test that only checks “a path was returned” will pass on the exact bug — a stair in a wheelchair route — that this whole design exists to prevent.
def test_step_free_route_excludes_all_stairs() -> None:
routes = route_all_profiles(graph, "L2-lobby", "L2-clinic-204")
step_free = routes["step_free"]
assert step_free is not None, "a step-free route must exist for this pair"
# Invariant 1: not a single edge on the step-free path is a staircase.
stair_edges = [
(a, b) for a, b in zip(step_free, step_free[1:])
if graph[a][b]["space_class"] == "stair"
]
assert stair_edges == [], f"step-free route crossed stairs: {stair_edges}"
# Invariant 2: the profiles genuinely diverge; the default may legitimately take the stair.
assert routes["default"] != step_free
A correct run logs two different routes over one graph:
INFO | default route: 4 nodes
INFO | step_free route: 9 nodes
The failure to catch is the opposite — identical routes where the default should have taken a staircase shortcut — which usually means the stair edge was never tagged space_class="stair" and so was invisible to both the penalty and the exclusion.
Performance & Scale Notes
- Cache the weight function, not a graph copy. The whole point of the pure-callable model is that N profiles share one graph in memory; never
graph.copy()per profile. A weight callable is a closure of a few floats — cache the compiled function per profile name and reuse it across every request. - Dijkstra, not A*, once weights are non-metric. A step-free weight that adds an 8 m elevator wait or excludes edges is no longer a pure metric, so a Euclidean A* heuristic can become inadmissible and return a suboptimal path. Use Dijkstra for profiles that reweight, and reserve A* for the default distance profile where the heuristic is safe.
- Precompute per-profile reachable sets offline. For a fixed topology, run one multi-source Dijkstra per profile at build time to label every node with which profiles can reach it. A step-free “unreachable” answer then costs a dictionary lookup instead of a failed search, and a node reachable by nobody flags a topology defect before publish.
- One index, many profiles. A
scipy.spatial.cKDTreefor endpoint snapping is built pertopology_hash, not per profile — the profiles share nodes, so they share the snap index. At campus scale, the graph library itself becomes the bottleneck, which is where the NetworkX vs. igraph comparison matters. - Version profiles with the graph. Bake the profile policy constants (
min_door_width_m,max_incline_deg) into the artifact and hash them with the topology, so a policy change forces a re-route and cannot silently diverge from the published map.
Frequently Asked Questions
Why exclude stairs by returning None instead of a very large weight?
Because a large weight is still finite, and a finite weight is still selectable. If the only connection between two wings is a staircase, a step-free profile that assigns that stair a weight of 10,000 will still route across it the moment every alternative sums to more than 10,000 — a long enough detour, a multi-floor path, and the “avoided” stair reappears in a wheelchair route. Returning None removes the edge from that profile’s graph entirely, so the solver reports “no path” rather than an unsafe one, which is the honest and correct answer when no step-free route exists.
Do I need a separate graph per accessibility profile?
No, and maintaining one is the anti-pattern this design exists to avoid. One authoritative topology carries physical attributes on each edge; each profile is a pure weight function that reprojects that single graph at query time. Separate physical graphs double every closure, every topology fix, and every snap index, and they inevitably drift out of agreement about which nodes exist. Share the structure, vary only the cost.
How should the router behave when an elevator goes offline for a step-free user?
Feed live building-management state into the weight function and return None for the offline elevator edge, so it is excluded exactly like a stair. If that exclusion disconnects the step-free subgraph, the correct response is to surface “no step-free route currently available” and hand off to the fallback routing architecture, never to quietly route the user onto a staircase. Treat a missing or stale feed as “offline” — fail closed.
What happens to a destination that only a wheelchair user cannot reach?
The step-free profile returns NetworkXNoPath while the default profile returns a route, which is a signal, not a bug: some part of the building is genuinely inaccessible. Precompute per-profile reachable sets at build time so this surfaces as a labelled topology defect before publish, giving facilities a chance to add a ramp or lift edge rather than discovering the gap when a user is already standing in the lobby.
Related
- Step-Free Routing with Weighted Graphs — the focused companion that builds the minimal step-free weight callable and runs Dijkstra over it.
- Fallback Routing Architectures — where a disconnected step-free subgraph hands off to deterministic degradation.
- Level Mapping & Z-Axis Logic — how the stair, ramp, and elevator edges these profiles distinguish are modelled across floors.
- POI Taxonomy & Classification — the attribute layer that supplies each edge’s accessibility metadata.
This page is part of the Indoor Mapping Architecture & Standards reference — return there for the end-to-end architecture these routing profiles plug into.