Modelling Lift and Escalator Outages

An operational companion to Accessible Routing Profiles. A lift going out of service is the most common live change an indoor routing system has to absorb, and it is the one where the difference between profiles stops being academic.

Three States, Not Two

The three service states a vertical transition can be in Three states for a lift or escalator edge. In service, its weight multiplier is 1.0. Degraded — reported queueing or running slow — raises the multiplier to about 2.5 while keeping the edge usable. Out of service sets the weight to infinity, but the edge stays in the graph so the router can explain why a route is long. A cleared fault returns it directly to in service. In service, degraded, out — and the middle one is the useful addition queue or slow BMS reports fault fault cleared In service weight 1.0 Degraded weight x2.5, still usable Out weight inf, still in the graph

Two states, not one. A lift with a four-minute queue is not out of service, and modelling it as binary either sends everyone into the queue or forbids a lift that works.

Modelling a lift as working or broken loses the case that occurs most often: a lift that works and is heavily queued. At a shift change or after an event, a four-minute wait makes a lift a poor choice for anyone who can take the stairs and still the only choice for anyone who cannot.

Three states express that. In service is the normal multiplier. Degraded raises the cost so the default profile prefers stairs while the step-free profile still routes through it — because for that profile a slow lift beats no route. Out is infinite weight for every profile, with the edge kept in the graph so the router can explain the result rather than merely fail.

The Same Outage, Different Consequences

How an outage affects each routing profile differently A grid of six conditions against three profiles. A lift in service costs 1.6 for the default profile and 1.0 for step-free and service. A degraded lift costs between 1.8 and 2.5. A lift out of service is infinite for every profile. When the only lift is out and stairs exist, the default and service profiles route via stairs while the step-free profile has no route at all and must say so. An escalator out is infinite for all profiles; a degraded escalator costs 1.4 for the default profile and is already infinite for the others. One outage, three different consequences Condition default step_free service Lift in service 1.6 ● 1.0 ● 1.0 Lift degraded 2.4 2.5 1.8 Lift out △ inf △ inf △ inf Only lift out, stairs exist route via stairs △ no route — say so route via stairs Escalator out △ inf △ already inf △ inf Escalator degraded 1.4 △ already inf △ already inf Row four is the case that matters: for step-free users an outage is a hard failure, not a detour.

The same outage is a detour for one profile and a wall for another. A system that reports “no route” identically in both cases has thrown away the only information a user could act on.

The fourth row is the one worth building around. When the only lift serving a wing goes out:

  • the default profile routes via the stairs, a few metres longer, and the user notices nothing;
  • the step-free profile has no route at all.

Both are correct. What is not correct is reporting them identically. A step-free user who receives “no route available” learns nothing; one who receives “no step-free route: lift L-04 out of service between levels 0 and 6” can decide to wait, to ask staff, or to go to another entrance.

That is why the graph keeps out-of-service edges rather than deleting them: the router can see which edge blocked the path and name it.

Minimal Working Example

import logging
from dataclasses import dataclass

import networkx as nx

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

STATE_MULTIPLIER = {
    ("elevator", "in_service"): {"default": 1.6, "step_free": 1.0, "service": 1.0},
    ("elevator", "degraded"): {"default": 2.4, "step_free": 2.5, "service": 1.8},
    ("escalator", "in_service"): {"default": 1.0, "step_free": float("inf"),
                                  "service": float("inf")},
    ("escalator", "degraded"): {"default": 1.4, "step_free": float("inf"),
                                "service": float("inf")},
}


@dataclass(frozen=True)
class NoRoute(Exception):
    profile: str
    reason: str
    blocking: list[str]


def set_transition_state(g: nx.Graph, transition_id: str, state: str) -> int:
    """Apply an outage to the live graph as an attribute change. No rebuild."""
    if state not in ("in_service", "degraded", "out"):
        raise ValueError(f"unknown service state {state!r}")
    touched = 0
    for a, b, data in g.edges(data=True):
        if data.get("transition_id") != transition_id:
            continue
        data["state"] = state
        touched += 1
    if not touched:
        raise KeyError(f"transition {transition_id!r} has no edges in the graph")
    logger.info("transition %s -> %s (%d edge(s))", transition_id, state, touched)
    return touched


def weight_fn(profile: str):
    def w(u, v, data):
        cls, state = data.get("cls", "corridor"), data.get("state", "in_service")
        if state == "out":
            return None                          # networkx treats None as impassable
        mult = STATE_MULTIPLIER.get((cls, state), {}).get(profile, 1.0)
        return None if mult == float("inf") else data.get("length", 0.0) * mult
    return w


def route_or_explain(g: nx.Graph, src: str, dst: str, profile: str) -> list[str]:
    """Route, or raise a NoRoute that names the transitions that blocked it."""
    try:
        return nx.shortest_path(g, src, dst, weight=weight_fn(profile))
    except nx.NetworkXNoPath:
        blocking = sorted({d["transition_id"] for _, _, d in g.edges(data=True)
                           if d.get("state") == "out" and d.get("transition_id")})
        raise NoRoute(profile, "vertical_transport_unavailable", blocking) from None

The None return from the weight function is what keeps an out-of-service edge present but untraversable — networkx skips it entirely — so the edge remains available for the explanation pass that follows the failure.

Propagation and Freshness

How a building-management fault reaches a routing response A sequence across five participants. The building management system reports a fault on lift L-04 serving levels 0 to 6. The ingest service sets the state attribute on the corresponding graph edges and is told twelve edges were updated. A subsequent step-free route request finds no path, and the router returns a structured no-route response carrying the reason — lift out of service — and the levels affected. A fault, an attribute, and a response that explains itself BMS Ingest Graph Router Client lift L-04 fault, level 0-6 set edge attrs: state=out 12 edge(s) updated solve, step_free no path no_route + reason: lift_out + affected levels No rebuild: an outage is an attribute change applied to the live graph in microseconds.

The reason field is the product. “No route available” is a dead end; “no step-free route: lift L-04 is out of service between levels 0 and 6” tells a user what to do next.

Outages arrive from a building-management system, a lift vendor’s API, or a facilities ticket, and the important property of the path is that no rebuild happens. Setting state on a dozen edges is microseconds; rebuilding the graph is seconds per level and would make live operational data as slow as a map release.

Two operational details matter:

  • Freshness. An outage feed that stops delivering leaves the graph asserting a stale state. Treat a missing heartbeat as a reason to revert transitions to in_service after a timeout — optimistic, but far better than routing everyone around a lift that was fixed last week.
  • Scope. A lift fault applies to the edges of one transition, not to a level or a building. Recording transition_id on every vertical edge, as routing graph construction does, is what makes the update precise.

Common Errors & Fixes

Everyone is routed via stairs when a lift is merely busy. The degraded state is being treated as out. Degraded should raise the cost, not block the edge, and the step-free profile’s multiplier should stay low enough that it still uses the lift.

Step-free users get “no route” with no explanation. The blocking transitions are not being collected. Keeping out-of-service edges in the graph, as above, is what makes that possible; a system that deletes them cannot say why.

Outages persist after a fix. No heartbeat timeout. Any state other than in_service should expire, and the expiry should be logged so a genuinely long outage is visible as repeated renewal rather than as silence.

An escalator outage blocks a step-free route. Escalators are already impassable for step-free profiles, so their state should not affect that profile at all. The multiplier table above encodes this explicitly rather than by arithmetic, which makes it reviewable.

Integration Point

Outage state is an attribute on the vertical edges built by routing graph construction, consumed by the weight function that accessible routing profiles defines. Nothing about the graph’s shape changes, which is what lets an outage be applied to a running service.

The structured no_route reason is what fallback routing architectures turns into its vertical-fallback tier — reweighting vertical edges when mechanical transport is offline — and what wayfinding API observability counts, since a rise in vertical-transport no-routes is one of the clearest signals that an estate has a maintenance problem rather than a software one.

Frequently Asked Questions

Should an out-of-service lift be removed from the graph?

No — set its weight to infinity and leave the edge in place. Removing it produces the same routes and loses the ability to explain them: once the edge is gone, the router cannot tell a step-free user that their route failed because a specific lift is out, only that no route exists. Keeping the edge also makes restoring service a single attribute change rather than a graph mutation that has to reconstruct exactly the edges that were deleted, which is a reliable source of subtle corruption.

How quickly should an outage reach the router?

Within a minute, and the constraint is the feed rather than the graph. Applying the change costs microseconds, so the latency is entirely how fast the building-management system reports and how often it is polled. A minute is fast enough that a user starting a route just after a lift fails gets at most one wrong answer, and slow enough that a flapping sensor does not thrash the graph. Where the feed is push-based, debounce it: a lift that reports fault and clear three times in a minute should settle before the graph follows.

What should the client show when a step-free route is impossible?

The reason, the affected levels, and an alternative if one exists. “No route available” is the worst possible response because it is indistinguishable from a bug. Naming the lift and the levels it serves lets a user decide to wait or to ask; offering the nearest alternative vertical transition, even if it is in another part of the building, is usually more useful still. The structured reason in the API response is what makes any of that possible client-side.

This page is a companion to Accessible Routing Profiles, part of the Indoor Mapping Architecture & Standards section.