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.
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
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.
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.
Related
- Barometric Floor Detection in Python — the pressure-to-level conversion and the filter that makes it usable.
- Resolving Floor Ambiguity with Beacon Hints — breaking the tie when identical floor plates alias in signal space.
- Kalman Smoothing for Barometric Altitude — separating genuine vertical motion from sensor noise and handling.
- WiFi RSSI Fingerprinting — the radio estimate that anchors the barometric track, and why it aliases.
- Level Mapping & Z-Axis Logic — the level elevations this topic converts altitudes into.
This page is part of the Indoor Positioning: Beacon & WiFi Fingerprinting section.