# Indoor navigation when GPS dies > When GPS fails underground, Wheria walks you back with steps, heading, and pressure — how sensor fusion works and what we honestly tell users. 2026-05-28 · 21 min read · wheria, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/building-wheria-indoor.html --- Outdoors, Wheria behaves like any parked-car app: a pin on the map, a distance readout, maybe a photo of the spot. Then you drive down the ramp. Two slabs of reinforced concrete later, the receiver that reported 5 m accuracy at street level claims horizontal error past 30–80 m, or it loses fix entirely and the map keeps drawing a position that is minutes stale. Wheria watches reported accuracy and horizontal dilution of precision the whole time, and the moment either crosses our usable threshold it hides the dot. A confidently wrong blue dot costs the user more than an empty map does. The physics of why the signal dies down there is in [why GPS lies to you](https://uranashel.com/blog/why-gps-lies.html); what follows is about the thing that runs in its place. Indoor mode is **pedestrian dead reckoning**: accumulate discrete steps and heading changes from the phone's inertial sensors, add barometer floor hints, and show the resulting uncertainty on screen with a straight face. The technique predates the transistor by centuries. Ship navigators ran it with a compass, a log line and an hourglass, and they knew the property that still defines it: every error you make, you keep, and it compounds with each further step. ## What happens when you tap Park The Park tap captures an anchor. If a usable GPS fix exists, we store latitude, longitude and the reported accuracy radius, plus a timestamp and an optional photo of a pillar number or row marker. My own camera roll is mostly gray pillars now; the one from last Tuesday reads B2, pillar E9. If you are still at street level, the barometer also logs a pressure baseline at the garage entrance, which becomes the reference for floor estimation later. Entered an underground garage before parking, so no fix at all? Then the anchor is inertial-only from the entrance, and the photo matters more. The app then opens a high-rate IMU session (CoreMotion on iOS, SensorManager at game or fastest rate on Android) and starts a polyline that will record every step you take after leaving the car. All positions live in a local east-north-up frame centered on the park point. Distances underground are tens of meters, so a flat local frame is exact to well below sensor error and spares us map projections entirely. Each detected footfall advances the position estimate by one stride length along the current heading. That single sentence is the whole algorithm. Everything below is about the ways each term in it goes wrong. ## Counting steps from a noisy IMU Step detection band-pass filters the acceleration magnitude around walking cadence, roughly 1.5–3 Hz for typical walking speeds. Peak picking with an adaptive threshold marks individual footfalls, and the gyroscope confirms turn events between steps. Working on the magnitude `|a|` keeps detection independent of phone orientation, which matters because people navigate garages with the phone in a hand, in a pocket, sometimes wedged under a chin. The classic student mistake is to double-integrate acceleration into position. Consumer MEMS accelerometers carry milli-g-level biases, and integration squares time: a constant bias `b` produces position error `e = 0.5·b·t²`, so 1 mg (about 0.01 m/s²) becomes 0.5 m of drift after 10 s and 18 m after 60 s. That assumes a perfectly estimated gravity vector, which you do not have. Wheria uses raw acceleration for step events only. Position comes from counting. ``` // per IMU sample, ~100 Hz fun onSample(acc: Vec3, gyro: Vec3, dt: Float) { yaw += gyro.z * dt // integrate between steps val m = bandpass(acc.norm() - G) // biquad, 1.5-3 Hz if (isPeak(m) and m > thresh) { thresh = 0.7f * thresh + 0.3f * m // adaptive threshold var h = yaw if (compassTrustworthy()) { h = blend(yaw, compassHeading(), trustWeight()) yaw = h // absorb the correction } pos.e += stride * sin(h) pos.n += stride * cos(h) polyline.add(pos) // one vertex per footfall } } ``` The false-positive traps (escalator rides, pocket bounce, the shopping-cart shuffle) and the filter design behind `bandpass()` get their own treatment in [step detection and IMU odometry](https://uranashel.com/blog/step-detection-imu.html). ## Heading is the hard part Between steps, heading comes from integrating the gyroscope's yaw rate. Gyro integration is smooth and immune to magnetic garbage, but it drifts. Bench note: across 6 phones logging 30-minute static sessions after a figure-eight calibration, we measured yaw drift of 0.3–1.5° per minute. A 3-minute walk can therefore accumulate 4° of heading error, which at 80 m of range is up to 6 m of lateral offset from the gyro alone. The magnetometer measures Earth's field and offers absolute heading with zero drift. It also measures every steel column, every run of rebar and the ferrous body of each parked car; a garage is essentially a warehouse of large magnets that disagree with each other. When field magnitude or dip angle falls outside plausible ranges we flag the reading as distorted, down-weight compass measurements hard, run on gyro between steps and surface an explicit "compass unreliable" indicator. Faking a confident north would have been less code. The rules for when the magnetometer earns trust back are in [the compass calibration post](https://uranashel.com/blog/compass-calibration-story.html). ## The barometer and the floor question Hydrostatic equilibrium gives `dP/dh = −ρg`. With air density ρ ≈ 1.2 kg/m³ and g = 9.81 m/s², pressure drops roughly 12 Pa for every meter climbed near sea level. A typical garage floor is about 3.2 m of height, so one level is a 38 Pa step in pressure. Phone barometers, after light smoothing, hold about 0.3–1 Pa RMS of noise. The per-floor signal is therefore 40 to over 100 times the noise floor, which sounds comfortable and mostly is: take the pressure change since the entrance baseline, convert through `Δh = ΔP/(ρg)`, divide by 3.2 m per floor, round. That usually lands on the right basement level. Usually. An elevator moves you 3 floors in 15 s while the phone sleeps in a pocket. HVAC systems and stairwell doors add transients of a few pascals. A weather front can drag the baseline by 100 Pa over a few hours, which converts to roughly 8 m of phantom altitude if you parked before lunch and came back late. The mitigation math (reference baselines, drift windows, when to give up and ask the user which floor they remember) fills its own post: [barometer floor math](https://uranashel.com/blog/barometer-parking-math.html). ## Stride length and the error budget Default stride is 0.72 m. Given user height we apply the anthropometric estimate `stride ≈ 0.415·h`, which for a 1.75 m person gives 0.73 m. Users who care can calibrate: walk a known 20 m straight line, let the app count steps, and Wheria stores a personal stride coefficient for all future walks. On long walks, stride error dominates everything else in the budget. A 5% overestimate produces 4 m of longitudinal error after 80 m walked, before heading uncertainty adds its lateral component on top. Stack the terms and the honest answer is a band. Wheria says "about 45 m". Printing "44.7 m" would claim a decimal place the filter does not possess. The confidence chip beside the distance (green, yellow, red) is wired directly to the covariance of the fusion filter, and the filter itself gets the full derivation in [the Kalman filter post](https://uranashel.com/blog/kalman-filter-parking.html). ## Interface choices for a decaying estimate The path polyline ages visually. Older segments fade, so if you paused mid-garage for a 10-minute coffee, the trace looks exactly as stale as it is. When heading uncertainty exceeds 25°, which happens routinely near EV charging stations where DC cables and transformers bend the local field, Find mode suppresses turn-by-turn voice guidance and shifts to distance-first presentation: walk about 60 m, then rescan the compass somewhere clearer. We would rather stay quiet than shout wrong directions into a concrete wall. The same honesty rule shapes the visual design across all five apps, down to the monochrome palette described in [designing in strict monochrome](https://uranashel.com/blog/monochrome-ui.html). ## How we know any of this works ktuyen owns a garage test matrix and runs it every release: open slab structures like Landmark 81, narrow spiral ramps under mall basements, outdoor-to-indoor transitions where GPS dies mid-walk and the magnetometer nightmare zones around EV bays. The bar is fixed. The user finds the car within one wrong-turn correction on at least 70% of walks under 120 m, using phone sensors alone. No SLAM maps. No Bluetooth beacons. No venue partnerships. In the May regression run (2 testers, 6 phones, 148 logged walks across 4 garages) we scored 81% overall and 64% in the worst spiral-ramp basement, so spiral ramps stay on the matrix. Dead reckoning will never be magic. It is a disciplined way of being approximately right for 4 minutes, and finding a parked car needs almost exactly that. What Wheria does with these estimates on screen is on [the app page](https://uranashel.com/apps/wheria.html). --- 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/)