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; 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.
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.
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.
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.
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.
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.
Ngoài trời, Wheria là app đỗ xe bình thường: pin trên bản đồ, số khoảng cách, có thể thêm ảnh chỗ đỗ. Rồi bạn chạy xuống ramp. Qua hai lớp sàn bê tông cốt thép, receiver vừa cho accuracy 5 m trên mặt đường giờ báo sai số ngang quá 30–80 m, hoặc mất fix hẳn và bản đồ cứ vẽ vị trí cũ từ mấy phút trước. Wheria theo dõi accuracy báo về cùng HDOP suốt phiên; vượt ngưỡng dùng được là ẩn chấm ngay. Chấm xanh tự tin mà sai làm user thiệt hơn bản đồ trống. Vật lý đằng sau chuyện tín hiệu chết dưới hầm nằm ở vì sao GPS nói dối; phần dưới đây nói về thứ chạy thay chỗ nó.
Chế độ indoor là pedestrian dead reckoning: cộng dồn từng bước chân và thay đổi heading từ cảm biến quán tính, thêm gợi ý tầng từ áp kế, rồi hiển thị độ bất định thẳng thắn trên màn hình. Kỹ thuật này có trước transistor nhiều thế kỷ. Hoa tiêu tàu biển từng chạy nó bằng la bàn, dây log và đồng hồ cát. Họ thuộc lòng tính chất đến giờ vẫn nguyên: lỗi nào đã mắc thì giữ mãi, mỗi bước sau lại cộng dồn thêm.
Bấm Đỗ xe thì chuyện gì xảy ra
Cú bấm Park chụp một anchor. Còn GPS fix dùng được thì lưu lat, lon cùng bán kính accuracy, kèm timestamp và ảnh tùy chọn chụp số cột hay ký hiệu hàng. Camera roll của mình giờ toàn cột xám; tấm hôm thứ Ba ghi B2, cột E9. Nếu còn ở mặt đường, áp kế ghi luôn baseline áp suất ngay cửa hầm, làm mốc tính tầng về sau. Chạy thẳng xuống hầm rồi mới đỗ, không còn fix nào? Khi đó anchor thuần quán tính tính từ cửa hầm và tấm ảnh cột càng quan trọng. App mở session IMU tốc độ cao (CoreMotion trên iOS, SensorManager mức game hoặc fastest trên Android) rồi bắt đầu polyline ghi từng bước sau khi bạn rời xe.
Mọi vị trí tính trong khung east-north-up local đặt gốc tại điểm đỗ. Quãng đường dưới hầm chỉ vài chục mét nên khung phẳng local chính xác vượt xa mức lỗi cảm biến, khỏi đụng tới map projection. Mỗi bước chân phát hiện được đẩy ước lượng vị trí đi một stride theo heading hiện tại. Một câu đó là toàn bộ thuật toán. Phần còn lại của bài nói về những chỗ từng thành phần trong câu đó hỏng.
Đếm bước từ IMU nhiễu
Phát hiện bước là lọc band-pass biên độ gia tốc quanh cadence đi bộ, cỡ 1,5–3 Hz với tốc độ đi thường. Peak picking với threshold thích ứng đánh dấu từng bước chân; con quay xác nhận cú quẹo giữa hai bước. Làm việc trên biên độ |a| giúp phát hiện không phụ thuộc hướng cầm máy, quan trọng vì người ta đi tìm xe với điện thoại trên tay, trong túi, có khi kẹp dưới cằm.
Lỗi sinh viên kinh điển: tích phân gia tốc hai lần ra vị trí. Gia tốc kế MEMS consumer mang bias cỡ milli-g, mà tích phân bình phương thời gian: bias hằng b cho lỗi vị trí e = 0,5·b·t², nên 1 mg (cỡ 0,01 m/s²) thành 0,5 m drift sau 10 s và 18 m sau 60 s. Đó là khi vector trọng trường ước lượng hoàn hảo, mà thực tế thì không bao giờ. Wheria chỉ dùng gia tốc thô cho sự kiện bước. Vị trí đến từ phép đếm.
// moi sample IMU, ~100 Hz
fun onSample(acc: Vec3, gyro: Vec3, dt: Float) {
yaw += gyro.z * dt // tich phan giua cac buoc
val m = bandpass(acc.norm() - G) // biquad, 1.5-3 Hz
if (isPeak(m) and m > thresh) {
thresh = 0.7f * thresh + 0.3f * m // threshold thich ung
var h = yaw
if (compassTrustworthy()) {
h = blend(yaw, compassHeading(), trustWeight())
yaw = h // hap thu hieu chinh
}
pos.e += stride * sin(h)
pos.n += stride * cos(h)
polyline.add(pos) // moi buoc mot dinh
}
}
Mấy bẫy dương tính giả (đi thang cuốn, máy nảy trong túi, dáng đẩy xe hàng siêu thị) cùng thiết kế filter sau bandpass() có bài riêng: phát hiện bước và IMU odometry.
Heading mới là phần khó
Giữa các bước, heading lấy từ tích phân tốc độ yaw của con quay. Tích phân gyro mượt và miễn nhiễm rác từ trường, nhưng drift. Ghi chú bench: 6 máy, log tĩnh 30 phút sau khi calibrate hình số 8, drift yaw đo được 0,3–1,5° mỗi phút. Walk 3 phút vì vậy có thể lệch 4° heading, ở tầm 80 m tương đương tới 6 m lỗi ngang chỉ riêng từ gyro.
Từ kế đo từ trường Trái Đất, cho heading tuyệt đối không drift. Nó cũng đo luôn từng cột thép, từng lớp rebar và thân nhiễm từ của mọi chiếc xe đang đỗ; hầm xe về cơ bản là cái kho chứa nam châm cỡ lớn cãi nhau liên tục. Biên độ field hay góc dip lệch khỏi khoảng hợp lý thì số đo bị đánh dấu méo; lúc đó team hạ mạnh trọng số la bàn, chạy gyro giữa các bước và hiện hẳn chỉ báo "la bàn không đáng tin". Giả vờ biết chắc hướng bắc thì đỡ tốn code hơn nhiều. Luật khi nào từ kế được tin lại nằm trong bài hiệu chuẩn la bàn.
Áp kế và câu hỏi tầng
Cân bằng thủy tĩnh cho dP/dh = −ρg. Với mật độ không khí ρ ≈ 1,2 kg/m³ và g = 9,81 m/s², áp suất giảm khoảng 12 Pa cho mỗi mét leo lên, gần mực nước biển. Một tầng hầm cao cỡ 3,2 m, nên mỗi tầng là một bậc 38 Pa. Áp kế điện thoại sau khi smooth nhẹ giữ noise khoảng 0,3–1 Pa RMS. Tín hiệu mỗi tầng vậy là gấp 40 tới hơn 100 lần noise, nghe rất thoải mái và phần lớn đúng vậy: lấy chênh áp so với baseline cửa hầm, đổi qua Δh = ΔP/(ρg), chia 3,2 m mỗi tầng, làm tròn. Thường ra đúng tầng hầm.
Thường thôi. Thang máy kéo bạn 3 tầng trong 15 s khi điện thoại ngủ trong túi. HVAC và cửa cầu thang đắp thêm transient vài pascal. Front thời tiết có thể kéo baseline trôi 100 Pa trong vài giờ, quy ra chừng 8 m độ cao ma nếu đỗ trước bữa trưa và quay lại muộn. Toán xử lý (baseline tham chiếu, cửa sổ drift, lúc nào chịu thua thì hỏi thẳng user nhớ tầng mấy) chiếm nguyên một bài: toán tầng áp kế.
Stride và định mức lỗi
Stride mặc định 0,72 m. Có chiều cao thì áp công thức nhân trắc stride ≈ 0,415·h; người 1,75 m ra 0,73 m. Ai kỹ tính có thể calibrate: đi thẳng một đoạn 20 m đã biết, app đếm bước rồi lưu hệ số stride cá nhân cho mọi walk sau.
Walk dài thì lỗi stride lấn át mọi khoản khác trong định mức. Overestimate 5% cho 4 m lỗi dọc sau 80 m đi, chưa tính heading bất định thêm phần ngang chồng lên. Cộng hết các khoản lại, câu trả lời thật thà là một dải. Wheria ghi "khoảng 45 m". In "44,7 m" là nhận vơ một chữ số thập phân mà bộ lọc không hề có. Chip confidence cạnh số khoảng cách (xanh, vàng, đỏ) nối thẳng vào covariance của bộ lọc fusion; bản thân bộ lọc được khai triển đầy đủ trong bài Kalman filter.
Giao diện cho ước lượng xấu dần
Polyline đường đi già theo thời gian. Đoạn cũ mờ dần, nên đứng giữa hầm uống cà phê 10 phút thì trace trông cũ đúng như tuổi thật của nó. Khi heading bất định vượt 25°, chuyện xảy ra thường xuyên gần trạm sạc EV nơi cáp DC và biến áp bẻ cong từ trường local, chế độ Find tắt voice chỉ đường từng ngã rẽ và chuyển sang trình bày ưu tiên khoảng cách: đi chừng 60 m rồi quét lại la bàn ở chỗ thoáng hơn. Thà im lặng còn hơn hét chỉ dẫn sai vào tường bê tông. Cùng luật thật thà đó định hình visual cả năm app, tới tận bảng màu monochrome trong thiết kế monochrome nghiêm ngặt.
Làm sao biết mọi thứ chạy thật
ktuyen giữ ma trận test hầm xe và chạy mỗi release: sàn mở kiểu Landmark 81, ramp xoắn hẹp dưới hầm mall, đoạn chuyển từ ngoài trời vào trong nhà nơi GPS chết giữa walk và mấy vùng ác mộng từ kế quanh bãi sạc EV. Mốc đạt cố định. User tìm thấy xe trong tối đa một lần sửa hướng sai trên ít nhất 70% walk dưới 120 m, chỉ bằng cảm biến điện thoại. Không map SLAM. Không beacon Bluetooth. Không bắt tay chủ tòa nhà. Đợt regression tháng Năm (2 tester, 6 máy, 148 walk có log qua 4 hầm) đạt 81% tổng thể và 64% ở hầm ramp xoắn tệ nhất, nên ramp xoắn tiếp tục nằm trong ma trận.
Dead reckoning không bao giờ thành phép màu. Nó là cách kỷ luật để đúng xấp xỉ trong 4 phút, mà tìm xe đỗ cần gần đúng chừng đó. Wheria làm gì tiếp với mấy ước lượng này trên màn hình, xem ở trang app.