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
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.
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
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 (
Rlarge relative toQ): 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.
Related
- Indoor Floor-Level Detection — the fusion this filter sits inside.
- Barometric Floor Detection in Python — the simpler filter this one replaces, and everything around it.
- Implementing a Particle Filter for Indoor Positioning — the horizontal equivalent, and why it needs particles rather than a Kalman filter.
This page is a companion to Indoor Floor-Level Detection, part of the Indoor Positioning: Beacon & WiFi Fingerprinting section.