# Kalman filters for parking > Combining noisy steps, heading, and pressure without panic — an intuitive tour of the filter Wheria uses underground. 2026-01-21 · 21 min read · wheria, math · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/kalman-filter-parking.html --- A Kalman filter sounds like graduate-school machinery. The idea underneath is almost embarrassingly practical. You keep a guess about where you are, plus an honest record of how wrong that guess might be. Take a step and the filter predicts a new position, deliberately widening the uncertainty, because steps are never perfect. When a sensor offers a measurement — a compass heading, a hint that you stopped moving — the filter blends it into the guess, weighted by how much each source deserves to be trusted. Replace with something concrete, e.g.: "The math is ordinary linear algebra; what makes it useful is that the filter keeps an honest record of its own error." Wheria runs a reduced extended Kalman filter. It never runs alone: it sits between the step detector and the confidence chip you see in Find mode. Steps push it forward, the compass and barometer argue with it, and the covariance it carries ends up on screen as a color. What follows is the whole loop: state, predict, update, the tuning numbers from our garage logs, and the fancier alternatives we rejected. ## The state: four numbers and their doubt The state vector holds four elements: horizontal position `x` and `y` in local meters, heading `θ` in radians, and a slowly drifting gyroscope bias `b_g`. That is the entire world model. The filter earns the word extended because heading enters the position equations through `cos θ` and `sin θ`, which are nonlinear, so every predict linearizes around the current estimate. On a phone the cost is a handful of 4×4 matrix multiplies per step. My mid-range Android test unit finishes one predict-update cycle in about 11 µs, averaged over 10,000 cycles on the bench. The battery never notices the filter exists. Next to the state lives the covariance matrix `P`, also 4×4. It encodes how uncertain each element is and how the errors correlate, and the correlations are where the value hides. Heading error and lateral position error are tightly coupled: walk 80 m with a heading estimate off by 10° and you end up roughly `80·sin 10° ≈ 14 m` sideways from where the map thinks you are. The cross terms in `P` predict that drift before you feel lost, which is exactly what the UI needs to know. ## Predict: every step makes you less sure Each detected step triggers a prediction. Position advances by stride length `L` along the current heading, `x ← x + L·cos θ` and `y ← y + L·sin θ`, while heading advances by the integrated gyroscope rate minus the bias estimate. Then the doubt grows: `P ← F·P·Fᵀ + Q`, with `F` the Jacobian of the motion model and `Q` the process noise. The growth is the point. A filter that only ever tightened its uncertainty would be a liar with good posture. `Q` did not come out of a textbook appendix. We tuned it from field logs: 3 phones, 14 garages, about 60 logged walks over two weeks, with predict and update traces dumped at every step and replayed offline. Stride uncertainty landed near 5 cm standard deviation per step. Heading process noise sits around 2° per step while compass trust is high, and closer to 8° per step in steel-heavy zones where magnetometer corrections cannot be believed. That asymmetry matters. In an open-air slab garage, compass measurements arrive frequently and the filter tightens heading after almost every few steps. In a spiral ramp wrapped in rebar we inflate the magnetometer measurement noise by an order of magnitude, which quietly tells the filter to lean on gyroscope integration between steps and to treat any compass spike as a suspect. ## Update: measurements and when to believe them Magnetometer heading enters as the measurement `z = atan2(m_y, m_x)`, the arctangent of the horizontal field components after tilt compensation. The update computes an innovation `ν = z − θ`, a gain `K = P·Hᵀ·(H·P·Hᵀ + R)⁻¹`, then nudges the state by `K·ν` and pulls `P` down. Everything hinges on `R`, the measurement noise. When the field magnitude and inclination look like Earth's expected range, `R` stays small and the correction bites hard. When they do not — steel columns can rotate effective north by 40° while the user walks dead straight — we grow `R` and the correction turns gentle instead of yanking the estimate around. The compass failure zoo has its own post: [magnetometers and compass nightmares](https://uranashel.com/blog/compass-calibration-story.html). We also gate. Any innovation beyond 3 standard deviations of its predicted spread is rejected outright and logged. The worst bug in this filter's history lived one line away from that gate: an innovation computed across the ±π seam once produced a 359° correction that teleported the estimate across the garage. Angle wrapping is not optional. The `wrapAngle` call in the listing below is load-bearing. Barometer floor hints get handled with more caution. One garage floor is about 3.2 m of height, and with `Δh = ΔP/(ρg)` giving roughly 12 Pa per metre near sea level, that is close to 38 Pa of signal. Phone barometers show 0.3–1 Pa RMS of noise at rest, so the floor change itself stands out clearly. The trouble is everything else that moves pressure: elevators, HVAC gusts, a weather front crossing mid-walk. So we surface barometer inference as parallel UI guidance, "likely B3" with a ±1 floor band, and keep altitude out of the Kalman state entirely, because a false floor lock would corrupt the horizontal confidence users rely on for distance-to-car. The derivation and the ugly pressure logs live in [the barometer math post](https://uranashel.com/blog/barometer-parking-math.html). Zero-velocity updates are a classic pedestrian trick. No step for about 2 s usually means the user is standing still, so we damp any legacy velocity drift in integration paths that still carry speed states from earlier prototypes. A small stabilizer. Escalators still defeat it. ## The loop, in about 20 lines Stripped of platform code, the whole filter fits on one screen: ``` state = [x, y, theta, gyroBias] P = initialCovariance() onStep(L, gyroDelta, dt): theta += gyroDelta - gyroBias * dt x += L * cos(theta) y += L * sin(theta) F = jacobianAt(theta, L) P = F * P * transpose(F) + Q(compassTrust) onCompass(mx, my, fieldLooksEarthlike): z = atan2(my, mx) R = fieldLooksEarthlike ? R_low : R_high // ~10x apart nu = wrapAngle(z - theta) S = H * P * transpose(H) + R if nu * nu > 9 * S: return // 3-sigma gate K = P * transpose(H) / S state += K * nu P = (I - K * H) * P onQuiet(t > 2 s): dampLegacyVelocityDrift() ``` The Swift and Kotlin builds of this loop have to behave identically, down to the covariance trace, because ktuyen's regression matrix replays the same logged walks through both and diffs the outputs. Fixed evaluation order and explicit float widths keep the two platforms within about 1e-6 of each other over a full walk. ## Why not a particle filter or full SLAM Particle filters represent uncertainty as a cloud of hypothetical positions with weights. They shine when ambiguity is genuinely multimodal: you truly do not know whether the user turned left or right at a fork, and one Gaussian cannot hold both answers. They also burn CPU, and resampling injects randomness into a pipeline we keep deterministic for those cross-platform regression tests. For walks under roughly 120 m inside a single garage, an extended Kalman filter with adaptive measurement noise has been sufficient. We would revisit particles if we added map matching against painted parking lines in open lots, where fork ambiguity is common. Full SLAM with camera or lidar sits even farther outside our constraints. Garages change signage, lighting, and parked-car layout daily, so yesterday's visual map is already partly fiction. Phone-only SLAM without infrastructure beacons also fails the offline, no-account, privacy-first requirements laid out in the [on-device post](https://uranashel.com/blog/on-device-first.html). We picked the boring filter on purpose. ## One walk, replayed A concrete trace from the tuning set. Car at floor B2, pillar E9. The walk back to the stairs is 74 m with two 90° turns and one spiral ramp segment. At step 0 the position sigma is 1.2 m, inherited from the parking-spot anchor. Across the first 30 steps on the open slab, compass updates land steadily, heading stays tight, and position sigma climbs gently on stride noise alone. Then the ramp. `R` inflates 10×, heading rides on the gyroscope for about 40 steps, and its sigma climbs the whole way up. The chip drops from green to yellow near the top. Back on a clean slab, three compass updates pull heading in again and the chip recovers within 8 steps. Position sigma at the stairs: a bit over 2 m, most of it lateral, nearly all of it heading's fault. ## What users actually see The trace of the position covariance block maps straight to the green, yellow, or red confidence chip on the Find screen. Past a threshold, the UI stops drawing a precise arrow and switches to a coarse distance ring: walk about 60 m, then reassess. Outdoors, while a GPS segment is still valid, the 2×2 position block of `P` draws an uncertainty ellipse on the map, because a pinpoint dot at that error level would be a lie. If you want to push these matrices around yourself, Phyzix ships a Kalman demonstration that steps through predict and update with sliders for `Q` and `R`. The full indoor pipeline is documented in [indoor navigation when GPS dies](https://uranashel.com/blog/building-wheria-indoor.html), and the step odometry that drives every predict lives in [step detection and IMU odometry](https://uranashel.com/blog/step-detection-imu.html). Bring a phone to a garage and watch the chip change color. The covariance is doing that. --- uranashel · [Home](https://uranashel.com/) · [Apps](https://uranashel.com/apps.html) · [Lab](https://uranashel.com/lab.html) · [About](https://uranashel.com/about.html) · [Blog](https://uranashel.com/blog/) · [Developers](https://uranashel.com/developers/) · [API docs](https://uranashel.com/docs/) · [Privacy](https://uranashel.com/privacy.html) Machine-readable: [llms.txt](https://uranashel.com/llms.txt) · [sitemap.xml](https://uranashel.com/sitemap.xml) · [openapi.json](https://uranashel.com/openapi.json) · [API](https://uranashel.com/api/v1/)