Indoor Floor-Level Detection

Horizontal position is a hard problem with a well-known answer. Vertical position is an easy-looking problem with a surprisingly bad answer: a fix that is 1.5 m off horizontally is usable, and a fix that is one storey off is worse than useless, because the router will confidently give directions on the wrong floor. This topic, part of Indoor Positioning: Beacon & WiFi Fingerprinting, covers how to decide which level a user is on and how to know when that decision has gone stale.

The Problem: A Metre of Error, a Storey of Consequence

Every horizontal error degrades gracefully. Two metres off in a corridor still gives correct turn-by-turn; five metres off shows a blue dot in the neighbouring room and the route still works. Vertical error does not degrade: it is either right or it is a different floor, and on the wrong floor every instruction, every nearby-POI list and every distance estimate is wrong together.

What each sensing source can and cannot say about the floor A grid comparing four sources. A barometer cannot give an absolute level, drifts with weather, resolves three-metre storeys to about 0.3 metres, works in a lift, responds in under a second and needs no infrastructure. Radio positioning gives an absolute level and does not drift but aliases between identically laid-out floors, fails in a lift, takes three to eight seconds and needs a survey or beacons. Motion gives no absolute level, drifts, can resolve storeys by counting stairs, fails in a lift, and is fast. The fused estimate is absolute, does not drift, resolves storeys, works in lifts and responds in one to two seconds. Four sources, and only their combination answers the question Property Barometer Radio (WiFi/BLE) Motion Fused Absolute level △ no — relative only ● yes △ no ● yes Drifts △ yes, with weather ● no △ yes ● no Resolves 3 m storeys ● yes, ~0.3 m △ aliases on repeat layouts ● yes, via stairs ● yes Works in a lift ● yes △ no signal △ no steps ● yes Latency ● < 1 s 3-8 s ● < 1 s ● 1-2 s Needs infrastructure ● no △ survey or beacons ● no inherits No single source answers 'which floor'. The barometer is fast and relative; radio is absolute and slow.

Barometer and radio are exact opposites. One is fast, relative and drifting; the other absolute, slow and stable — which is why floor detection is a fusion problem and not a sensor choice.

The four available signals fail in complementary ways, which is what makes this a fusion problem.

The barometer in a modern phone resolves about 0.3 m of altitude, which is comfortably finer than a 3-4 m storey. It is also relative: atmospheric pressure at a given altitude changes with the weather by far more than a storey’s worth, so a raw pressure reading says nothing about which floor without a reference. Weather moves pressure by 1-3 hPa over hours — roughly 8-25 m of apparent altitude — which is why a barometer anchored once in the morning is a storey out by afternoon.

Radio positioning gives an absolute level, because reference points and beacons are surveyed per floor. Its weakness is aliasing: buildings with identical floor layouts produce nearly identical fingerprints, as the WiFi RSSI fingerprinting topic shows, so the radio’s own level estimate can be confidently wrong in exactly the buildings where floors repeat.

Motion contributes evidence rather than an estimate. A stair-climbing pattern in the accelerometer says the level changed by roughly one per flight; a lift produces a characteristic vertical acceleration signature with no step pattern at all.

Prerequisites & Dependencies

Dependency Version Used for
numpy ≥ 1.24 pressure smoothing and gradient detection
scipy ≥ 1.11 Butterworth filtering of the pressure trace
device API Sensor.RELATIVE_ALTITUDE (iOS), TYPE_PRESSURE (Android)

Two pieces of map data are required and are easy to overlook:

  • Level elevations, not just indices. Fusing a barometer needs to know that level 3 is 12.6 m above level 0, which comes from level mapping. A level index alone is an ordinal and cannot be compared with an altitude.
  • A building reference pressure. Either a fixed sensor in the building publishing current pressure at a known level, or the anchoring approach below. Without one, the barometer is measuring the weather as much as the building.

Not every device has a barometer. Roughly 85-90% of phones in current use do, and the remainder need the radio-only path with its aliasing caveats — so the level estimator must degrade rather than assume.

How It Works: Anchor, Track, Expire

Barometric pressure through a six-storey lift ride A single curve of measured pressure over two minutes. Pressure is flat at about 1013.25 hectopascals for the first fifteen seconds while the lift is stationary, falls smoothly by roughly 3 hectopascals between fifteen and sixty-three seconds as the lift rises 25.2 metres, then is flat again at the new level. Superimposed on the whole trace is a slow downward drift of a few hundredths of a hectopascal caused by changing weather. Relative change is trustworthy; absolute pressure is not 1010 1011 1012 1013 0 25 50 75 100 seconds pressure (hPa) doors close arrives, level 6

The shape is unambiguous; the absolute value is not. A 3 hPa fall is six storeys whatever the weather — but the pressure at the top tells you nothing about which storey without a reference.

The pressure trace through a lift ride shows the whole strategy. The change in pressure during the ride is unambiguous — 3 hPa is 25 m is six storeys, whatever the weather is doing — while the absolute value at the end is worth nothing on its own. So the barometer is used for relative change and something else establishes the reference.

The state machine that keeps a floor estimate honest Four states. The estimate starts Unknown with no level. The first radio fix moves it to Radio-fixed, which is absolute but coarse. Once a barometric reference is captured at that known level it moves to Tracked, where the barometer follows relative changes cheaply. If more than twenty minutes pass without a radio fix the drift budget is spent and the state becomes Stale. A new radio fix re-acquires and returns it to Radio-fixed. Anchor with radio, track with pressure, expire on drift first radio fix reference captured > 20 min, no radio radio re-acquires Unknown no level yet Radio-fixed absolute, coarse Tracked barometer follows Stale drift budget spent

The stale state is what stops silent drift. A barometer tracked for an hour with no radio anchor can be a whole storey out, and a system with no stale state will report that with full confidence.

Anchor. When a radio fix arrives with a confident level, record the current pressure alongside it. That pair — level 2, 1011.87 hPa — is the reference, and it is valid for as long as the weather holds.

Track. Between radio fixes, convert the pressure difference from the reference into an altitude difference and then into a level difference using the building’s actual level elevations. This is sub-second and works in lifts, stairwells and radio dead zones.

Expire. Weather drift accumulates. After roughly twenty minutes without a radio anchor the accumulated drift approaches half a storey, and the estimate should be marked stale rather than reported confidently. A stale level still drives the map — showing nothing is worse — but it is flagged, so the client can prompt a re-anchor and the router can widen its assumptions.

Step-by-Step Implementation

Step 1 — convert pressure to relative altitude.

import logging
import math
from dataclasses import dataclass

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


def altitude_delta_m(p_now: float, p_ref: float, temp_c: float = 20.0) -> float:
    """Altitude difference implied by two pressures, via the hypsometric equation."""
    if p_now <= 0 or p_ref <= 0:
        raise ValueError("pressures must be positive hPa")
    t_k = temp_c + 273.15
    return (t_k / 0.0065) * (1.0 - (p_now / p_ref) ** (1.0 / 5.25588))

Step 2 — hold an anchor and derive the level from it.

@dataclass
class FloorEstimate:
    level: float
    source: str            # "radio" | "barometer" | "stale"
    confidence: float


class LevelTracker:
    """Anchor on radio, track on pressure, expire on drift."""

    def __init__(self, level_elevations: dict[float, float], drift_budget_s: float = 1200.0):
        if not level_elevations:
            raise ValueError("level elevations are required to convert altitude to a level")
        self.elev = dict(sorted(level_elevations.items()))
        self.drift_budget_s = drift_budget_s
        self._anchor: tuple[float, float, float] | None = None   # (level, pressure, t)

    def on_radio_level(self, level: float, pressure_hpa: float, t: float,
                       confidence: float) -> FloorEstimate:
        if confidence >= 0.7:
            self._anchor = (level, pressure_hpa, t)
            logger.info("anchored: level %+g at %.2f hPa", level, pressure_hpa)
        return FloorEstimate(level, "radio", confidence)

    def on_pressure(self, pressure_hpa: float, t: float) -> FloorEstimate | None:
        if self._anchor is None:
            return None                                # nothing to be relative to yet
        anchor_level, anchor_p, anchor_t = self._anchor
        d_alt = altitude_delta_m(pressure_hpa, anchor_p)
        target = self.elev[anchor_level] + d_alt
        level = min(self.elev, key=lambda lv: abs(self.elev[lv] - target))

        age = t - anchor_t
        margin = abs(self.elev[level] - target)
        stale = age > self.drift_budget_s
        conf = max(0.15, 1.0 - margin / 1.5) * (0.4 if stale else 1.0)
        return FloorEstimate(level, "stale" if stale else "barometer", round(conf, 2))

Step 3 — use motion to reject impossible transitions. A level change with no vertical motion signature is a radio error, not a floor change:

def plausible_transition(prev: float, nxt: float, seconds: float,
                         vertical_motion: bool) -> bool:
    """Reject level jumps that no stair or lift could have produced."""
    if prev == nxt:
        return True
    if not vertical_motion:
        return False                                   # user did not move vertically
    storeys = abs(nxt - prev)
    fastest = storeys * 2.5                            # ~2.5 s per storey in a fast lift
    return seconds >= fastest

Edge Cases & Gotchas

Pattern Symptom Handling
Weather front passing Level drifts up or down over an hour Expire the anchor; re-anchor on radio
Phone in a pocket vs. in hand 0.5-1.0 m altitude step Smooth over 5-10 s; ignore steps faster than that
Air-conditioned lobby Pressure differs from the rest of the floor Anchor away from revolving doors and AC diffusers
Fast lift Pressure changes faster than the filter follows Detect the ramp and bypass smoothing during it
Mezzanine Half-storey altitude, no matching level Level elevations must include fractional levels
Device with no barometer Radio-only, aliasing Degrade explicitly; report source: radio
Building with a stack effect Persistent offset in tall buildings Calibrate per building, not per portfolio

The stack effect is the one that catches people in tall buildings. A heated tower behaves like a chimney: warm air rises, producing a pressure gradient inside the building that differs from the outside atmosphere. The effect is small but systematic — commonly 0.1-0.3 hPa across thirty storeys, roughly one storey’s worth of apparent altitude — and it is a constant offset per building, so it can be calibrated once rather than fought continuously.

Validation Output

A healthy sequence through a lift ride:

[
  {"t": 12.0, "level": 0, "source": "radio",     "confidence": 0.91},
  {"t": 18.0, "level": 0, "source": "barometer", "confidence": 0.94},
  {"t": 34.0, "level": 3, "source": "barometer", "confidence": 0.88},
  {"t": 63.0, "level": 6, "source": "barometer", "confidence": 0.93},
  {"t": 71.0, "level": 6, "source": "radio",     "confidence": 0.86}
]

The last line is the important one: the radio re-acquired at the destination and agreed with the barometer, which both confirms the track and re-anchors it. Disagreement there is the signal that matters — it means either the barometer drifted or the radio aliased, and the tie is broken by which floors the building’s layouts repeat on.

The metric worth watching in production is the level agreement rate: the fraction of radio fixes that agree with the barometric track at the moment they arrive.

def test_track_agrees_with_radio(session):
    checks = [(e.level, r.level) for e, r in paired_estimates(session)]
    agree = sum(1 for a, b in checks if a == b) / max(len(checks), 1)
    assert agree > 0.95, f"level agreement is {agree:.0%}; anchor or elevations are wrong"

An agreement rate below about 95% almost always means the level elevations are wrong rather than the sensors — a storey height entered as a nominal 3.0 m when the building’s actual pitch is 4.2 m produces exactly this signature, drifting further from the truth the higher a user goes.

Performance & Scale Notes

Floor detection is cheap on the device and free on the server, which is the main argument for doing it on the device.

Operation Cost Where
Pressure sample 1 Hz, negligible device
Smoothing + level lookup ~20 µs device
Radio level estimate 3-8 s, part of the position fix device or server
Anchor update negligible device

The only server-side cost is publishing level elevations with the map, which is a handful of floats per building and belongs in the same metadata endpoint that serves the level list to client SDKs.

The battery consideration is real but small: a barometer sampled at 1 Hz costs roughly 1-2 mA, against 60-120 mA for continuous WiFi scanning. That asymmetry is the second argument for the anchor-and-track design — it lets the radio scan interval be lengthened substantially without losing vertical accuracy, which is a meaningful battery saving on a long navigation session.

Frequently Asked Questions

Can I detect the floor from the radio alone?

In buildings whose floors differ, yes; in buildings whose floors repeat, not reliably. Reference points and beacons are surveyed per floor, so the radio estimate is genuinely absolute — but a fingerprint from the second-floor corridor of a tower with identical floor plates is within a decibel or so of the fourth-floor corridor, and the estimator has almost no evidence to separate them. Since repeating floor plates are the norm in exactly the tall buildings where getting the floor wrong matters most, radio-only floor detection should be treated as a fallback for devices without a barometer rather than as the primary method.

How long can a barometric anchor be trusted?

Fifteen to thirty minutes in ordinary conditions, and much less when weather is moving. Atmospheric pressure drifts by roughly 0.1-0.5 hPa per hour in settled conditions, which is 0.8-4 m of apparent altitude — so a 4.2 m storey pitch is safe for a while and a 3.0 m pitch is not. Rather than picking a fixed timeout, the honest approach is to track the accumulated drift budget explicitly: expire the anchor when the elapsed time could have produced half a storey of error at the building’s actual pitch, which makes the timeout a property of the building rather than a constant.

What happens on a mezzanine?

It works if the level elevations include the mezzanine, and fails confusingly if they do not. The tracker snaps the computed altitude to the nearest known level, so a mezzanine absent from the elevation table pulls users standing on it to the floor above or below — and because the snap is to the nearest, they will flip between the two as they move. Including the mezzanine with its fractional index and its real elevation fixes it, which is one more reason the level mapping stage should not quietly drop half-levels.

Should the level be decided on the device or on the server?

On the device, with the server supplying the map data it needs. The barometer samples at 1 Hz and the whole computation is microseconds, so doing it locally gives sub-second response and works during a radio dropout — exactly the situation, a lift ride, where the floor is changing. A server-side decision would need the pressure stream uploaded, adding both latency and traffic to produce a worse answer. What the server owes the device is the level elevation table and the building’s calibrated pressure offset, both of which are small and change rarely.

This page is part of the Indoor Positioning: Beacon & WiFi Fingerprinting section.