Barometric Floor Detection in Python

The sensor half of Indoor Floor-Level Detection: converting a pressure reading into a level index, and the filtering that has to happen in between so the answer does not flicker.

Pressure, Altitude and the Constant You Should Not Use

How many metres one hectopascal of pressure change represents A gently rising curve showing the altitude equivalent of one hectopascal of pressure change, against height above sea level. At sea level one hectopascal is 8.43 metres; at 500 metres it is about 8.87; at 1,000 metres it is 9.35. The variation across the range a building occupies is about eleven percent, which matters when a storey is only three metres and a rule of thumb is being applied. One hectopascal is about 8.4 m — but only near sea level 8.5 8.75 9 9.25 0 250 500 750 1000 altitude above sea level (m) metres per hPa 8.4 m/hPa at sea level

The rule of thumb is 8.4 m/hPa and it drifts. Using the hypsometric equation rather than a constant costs one line and removes an 11% error for a building on a plateau — a third of a storey.

The relationship between pressure and altitude is the hypsometric equation, and the version worth implementing is the one that takes both pressures rather than assuming a sea-level reference:

Δh = (T / L) · [1 − (p_now / p_ref)^(1/5.25588)]

with T in kelvin and L the standard lapse rate of 0.0065 K/m. Everything about it is relative, which suits the problem: the anchor supplies p_ref, and the result is a height difference from whatever level the anchor was taken on.

The common shortcut — “1 hPa is 8.4 metres” — is accurate near sea level and drifts by about 11% across the first kilometre of altitude. For a building on a plateau with 3 m storeys, that is a third of a storey of systematic error, which is enough to matter after two or three floors. The full equation is one line and removes the whole class of problem.

Temperature enters through T. A ±10 °C error moves the result by about 3%, so using the device’s own temperature sensor where one exists is worth doing; where it does not, 20 °C is a reasonable constant and its error is comfortably inside the snap margin.

Minimal Working Example

import logging
from collections import deque

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:
    """Height difference implied by two pressures, via the hypsometric equation."""
    if p_now <= 0 or p_ref <= 0:
        raise ValueError("pressure 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))


class BarometricFloor:
    """Median-then-low-pass filtered pressure, snapped to known level elevations."""

    def __init__(self, level_elevations: dict[float, float], *, median_n: int = 5,
                 alpha: float = 0.25, hysteresis_frac: float = 0.25):
        if len(level_elevations) < 2:
            raise ValueError("need at least two level elevations to snap between")
        self.elev = dict(sorted(level_elevations.items()))
        pitches = [b - a for a, b in zip(list(self.elev.values()), list(self.elev.values())[1:])]
        self.pitch = min(p for p in pitches if p > 0.5)     # ignore mezzanine half-steps
        self.window: deque[float] = deque(maxlen=median_n)
        self.alpha = alpha
        self.hysteresis = hysteresis_frac * self.pitch
        self._smooth: float | None = None
        self._level: float | None = None
        self._anchor: tuple[float, float] | None = None     # (level, pressure)

    def anchor(self, level: float, pressure_hpa: float) -> None:
        if level not in self.elev:
            raise KeyError(f"level {level} is not in the elevation table")
        self._anchor = (level, pressure_hpa)
        self._level = level
        logger.info("anchored at level %+g, %.2f hPa", level, pressure_hpa)

    def update(self, pressure_hpa: float, temp_c: float = 20.0) -> float | None:
        """Feed one sample; returns the current level, or None before an anchor."""
        self.window.append(pressure_hpa)
        median = sorted(self.window)[len(self.window) // 2]
        self._smooth = median if self._smooth is None else (
            self.alpha * median + (1 - self.alpha) * self._smooth)

        if self._anchor is None:
            return None
        anchor_level, anchor_p = self._anchor
        target = self.elev[anchor_level] + altitude_delta_m(self._smooth, anchor_p, temp_c)

        best = min(self.elev, key=lambda lv: abs(self.elev[lv] - target))
        if self._level is not None and best != self._level:
            # only move once the candidate is clearly better than the incumbent
            if abs(self.elev[best] - target) + self.hysteresis > abs(
                    self.elev[self._level] - target):
                return self._level
            logger.info("level %+g -> %+g (target %.2f m)", self._level, best, target)
        self._level = best
        return best
Raw and filtered pressure across a two-storey stair climb Two curves over sixty seconds. The raw pressure trace carries visible high-frequency noise of a few hundredths of a hectopascal from sensor quantisation and hand movement, superimposed on a clear 0.9 hectopascal fall between twenty and forty-six seconds as the user climbs two storeys. The filtered trace, after a five-second median followed by a low-pass, follows the same fall smoothly with the noise removed and no visible lag at the start or end of the climb. Noise is fast, floor changes are slow — that gap is what the filter uses 1012.25 1012.5 1012.75 1013 0 20 40 60 seconds pressure (hPa) raw pressure (hPa, offset) after a 5 s median + low-pass

Median first, then low-pass. A median rejects the single-sample spikes a pocket transition produces; a low-pass smooths what is left. Reversing the order lets one spike drag the whole window.

Two design choices in update are worth calling out. The median comes before the exponential smoother, because a median rejects the isolated spikes a phone produces when it moves between a pocket and a hand, while a smoother would let one spike bias the whole window. And hysteresis is applied to the level decision rather than to the pressure, so a user standing on a landing halfway between two floors keeps whichever level they arrived on instead of flipping between them every second.

Parameter Reference

Parameters for a barometric floor detector A table of six parameters. Sampling at one hertz is sufficient because faster sampling costs battery without improving the estimate. A five-sample median window rejects the spikes a phone produces when moved between pocket and hand. A low-pass cutoff of 0.15 hertz is well above the 8 to 30 seconds a floor change takes. Temperature should come from the device where available, since a ten-degree error is about three percent of altitude. The level snap margin should be about 0.4 of the storey pitch, and hysteresis of about a quarter of the pitch prevents the reported level flipping at a boundary. Six parameters, and four of them scale with the storey pitch Parameter Value Why sample rate 1 Hz faster costs battery, buys nothing median window 5 samples rejects pocket-transition spikes low-pass cutoff 0.15 Hz a floor change takes 8-30 s temperature device or 20 °C ±10 °C is ±3% of altitude level snap margin 0.4 x storey pitch △ wider and levels flip hysteresis 0.25 x storey pitch ● stops flapping at a boundary The hysteresis row is what stops a user standing on a stair landing flipping between floors.

Every value is derived from the storey pitch, not fixed. A 3.0 m pitch and a 4.5 m pitch need different margins, and hard-coding either produces a detector that works in one building and not the next.

The storey pitch is derived from the level elevation table rather than configured, which is what lets one detector serve a portfolio of buildings with different floor-to-floor heights. Note the if p > 0.5 filter when computing it: a building with a mezzanine has one gap that is half a storey, and taking the minimum without filtering would set the pitch from the mezzanine and make every margin far too tight.

Common Errors & Fixes

The reported level walks upward over an afternoon. Weather drift with no anchor expiry. The anchor is only valid while the atmosphere is stable; after twenty minutes or so the accumulated drift approaches half a storey and the estimate should be marked stale rather than reported. This is the state machine described in the topic page.

The level flips every few seconds on a landing. Hysteresis is missing or too small. A quarter of the storey pitch is a good starting point; the symptom disappears immediately and the cost is that a genuine floor change is reported a second or two later.

Altitude is systematically wrong on one building. Either the level elevation table is wrong — nominal 3.0 m storeys entered for a building whose real pitch is 4.2 m — or the building has a stack effect. The two are distinguishable: a wrong table produces an error proportional to the number of floors travelled, while a stack effect produces a roughly constant offset.

Readings are quantised into visible steps. Some sensors report to 0.01 hPa, which is about 8 cm — fine — and some report far coarser. Check the actual resolution before tuning the filter; if the quantisation step exceeds about a fifth of a storey the barometer cannot resolve floors on that device and the radio path should be used instead:

step = min(abs(b - a) for a, b in zip(samples, samples[1:]) if a != b)
if step * 8.4 > 0.2 * pitch:
    logger.warning("barometer resolution %.3f hPa is too coarse for a %.1f m pitch", step, pitch)

Integration Point

The detector consumes two things the map supplies: the level elevation table from level mapping, and an anchor from the radio estimate produced by WiFi fingerprinting or BLE positioning.

Its output — a level index with a confidence and a source — joins the horizontal fix in the PositionFix that snapping to the routing graph consumes. That is where getting the level wrong becomes visible: the snapper is level-strict, so a wrong level means every candidate edge is on the wrong floor and the fix is refused rather than misplaced, which is the right failure but an opaque one without the level’s own confidence in the payload.

Frequently Asked Questions

Do I need the device's temperature sensor?

It helps and it is not essential. Temperature enters the hypsometric equation through the absolute temperature term, and a ten-degree error moves the computed altitude by about three percent — roughly 12 centimetres per four-metre storey, which is comfortably inside any sensible snap margin. Where it does start to matter is a tall building traversed in one go: a twenty-storey ride at three percent error accumulates to about two and a half metres, which is over half a storey. If the device exposes a temperature reading, use it; if not, a fixed 20 °C is fine for typical buildings.

Can I detect the floor without an anchor at all?

Only by assuming a sea-level reference, which is wrong by the weather. Standard atmosphere puts 1013.25 hPa at sea level, but real pressure at a given altitude varies by 20 to 30 hPa with weather systems — 170 to 250 metres of apparent altitude, or dozens of storeys. Some regions publish local sea-level-adjusted pressure from nearby weather stations, which narrows it to a few metres and is genuinely usable as a coarse anchor, but a radio fix inside the building is both more accurate and always available where positioning already works.

What sample rate does the barometer need?

One hertz, and faster buys nothing. A floor change takes between eight and thirty seconds whether by stairs or lift, so the signal being measured is well under a tenth of a hertz — sampling at 1 Hz gives ten samples across the fastest transition, which is ample for a five-sample median and a low-pass. Sampling at 10 or 50 Hz, which some sensor APIs default to, costs measurably more battery and adds only noise for the filter to remove.

This page is a companion to Indoor Floor-Level Detection, part of the Indoor Positioning: Beacon & WiFi Fingerprinting section.