Kalman Smoothing for Barometric Altitude

An alternative to the median-and-low-pass filter in barometric floor detection. It costs about twenty more lines and removes the four seconds of lag that make a simple filter report the wrong floor for the whole of a lift ride.

Why Lag Matters Here

Tracking lag through a three-storey lift ride Three curves over a sixty-second window containing a thirty-second, three-storey lift ride. The true altitude rises from zero to 12.6 metres between fifteen and forty-five seconds. A median-and-low-pass filter follows the same shape delayed by about 4.5 seconds, which mid-ride is a whole storey of error. A Kalman filter estimating both altitude and vertical velocity follows with under a second of lag. Same ride, same sensor, four seconds of difference 0 5 10 0 20 40 60 seconds altitude above the anchor (m) true altitude median + low-pass Kalman (h, v) 4.5 s of lag = a whole storey mid-ride

Lag is only visible while the level is changing — which is exactly when the answer matters. Both filters agree once the ride ends; only one is right during it.

A low-pass filter has no model of motion. It averages recent samples, so when altitude changes it follows the change delayed by roughly its time constant — four or five seconds for a filter tuned to reject the noise a phone barometer produces.

For most of a session that is invisible: the user is on one floor, the estimate is right, and lag is irrelevant. It becomes visible exactly when the level is changing, which is the moment the level estimate matters. A three-storey lift ride takes about thirty seconds, and a 4.5-second lag puts the reported floor a full storey behind for most of it. Users notice: the map catches up after they have already stepped out.

The two-state Kalman cycle for barometric altitude Three states in a cycle. Predict advances altitude by the estimated vertical velocity times the time step and grows the covariance by the process noise. Update computes a gain from the covariance and the measurement noise, and corrects both altitude and velocity from the new pressure sample. Output emits altitude, vertical velocity and the covariance as a confidence, then the cycle returns to predict on the next tick. Two states — altitude and vertical velocity — and one covariance one pressure sample posterior next tick Predict h += v·dt P += Q Update gain from P and R correct h, v Output h, v, and P as a confidence

The velocity state is what earns the filter. A median-and-low-pass has no model of motion, so it lags every transition; a Kalman filter that estimates vertical velocity tracks a lift ride without lagging it.

A Kalman filter with two states — altitude and vertical velocity — models the motion instead of averaging it. When the barometer starts falling consistently, the velocity state grows, and the altitude estimate keeps up with the ride rather than trailing it. When the ride ends and the pressure stabilises, the velocity decays and the filter settles.

Minimal Working Example

import logging

import numpy as np

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


class AltitudeKF:
    """Two-state (altitude, vertical velocity) Kalman filter over barometric altitude."""

    def __init__(self, *, q_h: float = 0.01, q_v: float = 0.04, r: float = 0.09,
                 p0: float = 4.0, v_max: float = 6.0):
        if r <= 0 or q_h < 0 or q_v < 0:
            raise ValueError("noise terms must be non-negative and R strictly positive")
        self.x = np.zeros(2)                       # [altitude_m, velocity_m_s]
        self.P = np.eye(2) * p0
        self.Q = np.diag([q_h, q_v])
        self.R = np.array([[r]])
        self.H = np.array([[1.0, 0.0]])            # we observe altitude only
        self.v_max = v_max

    def step(self, altitude_meas_m: float, dt: float) -> tuple[float, float, float]:
        """One predict/update cycle. Returns (altitude, velocity, 1-sigma)."""
        if dt <= 0:
            raise ValueError("dt must be positive")

        F = np.array([[1.0, dt], [0.0, 1.0]])
        self.x = F @ self.x
        self.P = F @ self.P @ F.T + self.Q * dt

        y = np.array([altitude_meas_m]) - self.H @ self.x
        S = self.H @ self.P @ self.H.T + self.R
        K = self.P @ self.H.T @ np.linalg.inv(S)
        self.x = self.x + (K @ y).ravel()
        self.P = (np.eye(2) - K @ self.H) @ self.P

        # a person does not move vertically faster than a fast lift
        self.x[1] = float(np.clip(self.x[1], -self.v_max, self.v_max))
        sigma = float(np.sqrt(self.P[0, 0]))
        return float(self.x[0]), float(self.x[1]), sigma

The velocity clamp is not decoration. Without it, a single bad pressure sample — a door closing, a phone moving from pocket to hand — produces a large innovation that the filter partly attributes to velocity, and the state then coasts at an impossible rate for several seconds. Clamping to 6 m/s, faster than any passenger lift, bounds the damage to one sample.

Tuning and Confidence

Kalman parameters for barometric altitude and how to tune each A table of five parameters. Process noise on altitude, around 0.01 square metres, is raised when the filter lags real motion. Process noise on velocity, around 0.04 metres per second squared, is raised when lift accelerations are missed. Measurement noise of about 0.09 square metres is raised when the estimate chases sensor noise. An initial covariance of 4 square metres stops the first fix dominating. A velocity clamp at six metres per second should never be raised, because no person moves vertically faster than that. Five parameters, and two of them are really one ratio Parameter Symbol Typical Raise it when process noise (altitude) Q_h 0.01 m² the filter lags real motion process noise (velocity) Q_v 0.04 (m/s)² lift accelerations are missed measurement noise R 0.09 m² △ the fix chases sensor noise initial covariance P0 4 m² the first fix should not dominate velocity clamp |v| ≤ 6 m/s ● never — a person cannot exceed this Q and R only matter as a ratio: it sets how much the filter trusts its model over the sensor.

Only the ratio Q/R is meaningful. Scaling both by the same factor changes nothing; what you are choosing is how much the filter believes its motion model against the barometer.

Only the ratio Q/R is meaningful: scaling both by the same factor produces an identical filter. What the ratio expresses is how much the filter trusts its own motion model against the barometer.

  • Too small (R large relative to Q): the filter believes the model and ignores the sensor, so it coasts through a genuine floor change and settles late.
  • Too large: the filter believes every sample and reproduces the noise it was meant to remove.

The starting values above — Q_h 0.01 m², Q_v 0.04 (m/s)², R 0.09 m² — assume a phone barometer with about 0.3 m of measurement noise, which is typical. Tuning from there is best done against a recorded trace with a known truth: walk a stairwell, log both the pressure and the actual floor at each moment, and choose the ratio that minimises the time spent reporting the wrong floor rather than the one that minimises altitude RMS — those are different objectives and the second one prefers a laggy filter.

The covariance is worth surfacing rather than discarding. sqrt(P[0,0]) is a one-sigma altitude uncertainty in metres, which converts directly into a confidence for the level estimate: when it exceeds about a third of the storey pitch, the level should be reported with reduced confidence regardless of how clean the pressure looks.

Common Errors & Fixes

The estimate overshoots at the end of a lift ride. Velocity is still large when the ride stops, and the filter coasts past. Raising Q_v lets velocity change faster, which both catches the start of the ride and lets it decay at the end; the overshoot is usually a sign that Q_v is too small rather than too large.

The filter is confidently wrong after a phone transition. A pocket-to-hand movement produces a step of several tenths of a hectopascal, and the filter — believing its measurement noise — accepts it. An innovation gate helps: reject any sample whose innovation exceeds about three sigma rather than incorporating it.

if abs(float(y)) > 3.0 * float(np.sqrt(S)):
    logger.info("rejected outlier sample: innovation %.2f m", float(y))
    return float(self.x[0]), float(self.x[1]), float(np.sqrt(self.P[0, 0]))

Covariance grows without bound during a radio dropout. Expected and correct — the filter is saying it no longer knows. What must not happen is the level being reported at full confidence anyway, which is what the sigma-to-confidence conversion above prevents.

It performs worse than the low-pass. Almost always dt. The predict step scales process noise by the time step, and passing a fixed dt when samples actually arrive irregularly makes the filter’s uncertainty model wrong. Measure the real interval between samples and pass it.

Integration Point

This filter replaces the median-and-low-pass stage inside barometric floor detection; everything around it is unchanged. The anchor still comes from a radio fix or a level-tagged beacon, the snap to level elevations still uses hysteresis, and the drift budget still expires the anchor.

What changes is what the stage emits. Instead of a smoothed altitude it emits altitude, vertical velocity and an uncertainty — and the velocity is useful in its own right, because a non-zero vertical velocity is direct evidence that a level change is in progress. That is exactly the “vertical motion” signal the topic page uses to reject implausible level transitions, and getting it from the filter is cheaper and more reliable than deriving it from the accelerometer.

Frequently Asked Questions

Is a Kalman filter overkill for this?

For a static estimate, yes; for tracking floor changes, no. If all you need is the level of a stationary user, a median and a low-pass answer it with less code. The moment the user is in a lift or on a stair — which is precisely when the level estimate is changing and therefore worth computing — the low-pass lags by several seconds and the Kalman filter does not. Twenty lines and one matrix inverse per second is a small price for reporting the right floor during the only period when the floor is in question.

Should the accelerometer feed the same filter?

It can, and the returns are smaller than they look. Adding vertical acceleration as a second measurement makes the filter a three-state one and improves velocity tracking at the very start of a lift ride. But phone accelerometers measure gravity plus motion in a device frame that is rotating unpredictably in a pocket, so extracting a clean vertical component is its own problem — and the barometer alone already tracks the ride within a second. The accelerometer earns its place better as a coarse yes/no signal for stair climbing than as a measurement inside this filter.

How does this interact with the level snap?

It feeds it and does not replace it. The filter produces a continuous altitude with an uncertainty; the snap converts that to a discrete level using the building’s elevation table and hysteresis. Keeping them separate matters because they fail differently: a filter problem shows up as altitude that lags or overshoots, while a snap problem shows up as a level that flips at a boundary while the altitude is perfectly steady. Emitting both the altitude and the level makes the two distinguishable in a log.

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