← Blog

NA model constellation24 satellites, 6 planes at 55°6 above the 10° maskHollow: above the horizon,below the maskLines: the next two hoursCentre: straight upRings: 0°, 30°, 60° elevation
A sky plot over Innsbruck: where the satellites of a model GPS constellation stand, by direction and height above the horizon, and where they move in the next two hours. A model, not the broadcast orbits of a real day.
Under the hood

Using every satellite your phone measures

How walker uses Android's raw GNSS measurements from five satellite systems and two bands on the phone, and what it gained on a real ride.

· Malik · 16 min read

Outdoors, my phone measured 26 to 36 satellites at a time, from five systems and on two bands. The first satellite cleaner I wrote used about 12 of them, GPS on L1 and BeiDou on B1I. More than half of what the phone measured never reached the solver.

walker, the walk, hike and ride tracker I make for Android, cleans every track on the phone. For a while it cleaned the chip's finished fixes. Now it also reads the satellites themselves: every system and band my phone measures that a broadcast orbit describes. This post is what that took, and what it gained. The factor graph that weights it all is covered in Cleaning GPS tracks with a factor graph; Writing a whole Android app in Rust is where the series starts.

Below the fix

A phone's fix is the output of a filter inside the GNSS chip, and my two worst problems came from that filter, not from the sky. The first is the static hold: some chips hold the fix still while you walk on. The raw measurements keep changing per satellite while the fix is held, so a solution built from them doesn't freeze. The second is that a fix carries one number for its quality, a single accuracy. The raw measurements give each satellite's signal strength (C/N0), its band and a multipath indicator, a flag for signs that the signal also arrived by reflections.

What Android hands you

Raw measurements have been in android.location since Android 7 (API level 24): a GnssMeasurementsEvent carries a GnssClock and one GnssMeasurement per signal. Since Android 10 they are mandatory. What you don't get is a pseudorange, the distance the signal's travel time implies. You build it from two times, as Google's gps-measurement-tools and the European GNSS Agency's (GSA) white paper do.

The first is when the receiver took the measurement, in GPS time, which counts from 6 January 1980. Android hands it over in pieces. TimeNanos is the receiver's clock. FullBiasNanos and BiasNanos are the chip's estimate of how far that clock is from GPS time, in whole nanoseconds and the fraction left over, and come off it. TimeOffsetNanos is this measurement's offset from the epoch's clock, and Android defines it as added to TimeNanos, so it belongs to the receive time, not the satellite's.

The second, ttxt_{tx}, is when the satellite sent the signal, by the satellite's clock: ReceivedSvTimeNanos. It counts from the start of the system's week, or of its day for GLONASS, and in the system's time. Galileo and QZSS keep GPS time. BeiDou time runs 14 s behind it: it started in 2006, when 14 leap seconds had passed since GPS time began, and never took another. GLONASS counts Moscow's day, which follows UTC and its leap seconds. The receive time is moved onto the satellite's system time first, trxsyst_{rx}^{sys}.

Both times count within a span that repeats, a week or a day, so their difference has to be taken within that span too. The travel time and the pseudorange are then

Δt=[(trxsys mod W)−ttx] mod W,ρ=c Δt\Delta t = \left[\left(t_{rx}^{sys} \bmod W\right) - t_{tx}\right] \bmod W, \qquad \rho = c\,\Delta t
(1)

where WW is the span the satellite counts in (a week, or GLONASS's day), cc is the speed of light, and ρ\rho is the pseudorange in metres.

The traps are all in the edges.

The rate needs no building: PseudorangeRateMetersPerSecond is Doppler in metres per second. walker also asks for full tracking while recording. Otherwise the chip duty cycles, switching its receiver off between fixes to save power, and the measurements lose their continuity.

What my phone actually measures

My phone is a OnePlus 7 Pro. The first probe, three seconds standing still with a weak signal, saw only GPS L1 and BeiDou B1I, 8–9 satellites. The phone's capability list claims neither measurements nor navigation messages, yet measurements arrive, so the list can't be trusted.

Outdoors it was a different picture. Three GnssLogger logs and a ride on one day showed 26–36 satellites an epoch (one measurement instant):

System Satellites an epoch Bands Median Doppler σ
GPS 11–15 L1, L5 0.05–0.67 m/s
Galileo 10–13 E1, E5a 0.59–0.86 m/s
GLONASS 4–6 G1 0.26–1.01 m/s
BeiDou 1–2 B1I 0.67–0.81 m/s
QZSS 0–0.4 L1 0.85–4.36 m/s
Table 1

Of those measurements, 84–85 % had the time of week decoded, and 0 % a valid carrier phase, so everything below is code and Doppler only. Against these 26–36, the first cleaner's GPS L1 and BeiDou B1I came to about 12 satellites an epoch. That is the half that never reached the solver.

Orbits without leaking where you were

A pseudorange is useless without the satellite's position, and Android doesn't hand over the chip's ephemerides, its copy of the orbits. walker has two sources.

The first is the phone's navigation messages, the data each satellite sends about its orbit. walker decodes GPS LNAV, the legacy message on L1, and BeiDou's D1 and D2. Android doesn't say whether GPS's data are still inverted or BeiDou's words still interleaved, so each subframe is read every way and kept where every word's parity checks. In practice my phone sent none in four recordings.

The second is the day's broadcast file from BKG, a merged navigation file in RINEX 3, the receiver-independent exchange format, with GPS, GLONASS, Galileo, BeiDou and QZSS, updated every 15 minutes. walker downloads it only in Settings, under Download satellite orbits: Always, Ask (the default) or Never. It caches the day, and a walk keeps the few hundred bytes of orbit per satellite it needs, so it never needs a download again.

The download widened walker's rule of network only when you ask, so it's worth being exact. The file is identical for everyone on a given day. The host learns your internet address and which day you asked for. It never learns a position or a route.

For each measurement walker picks the satellite's nearest healthy record, within the time its system's records hold.

From elements to a range

GPS, Galileo, QZSS and BeiDou broadcast Keplerian elements: an ellipse, and how it drifts. walker turns them into a position as IS-GPS-200 does, with each system's constants. The heart of it is Kepler's equation. The mean anomaly MM is a clock hand turning at a constant rate, and the satellite's real angle has to be worked out from it:

E−esin⁡E=ME - e \sin E = M
(2)

where ee is the orbit's eccentricity and EE the eccentric anomaly. It has no closed-form answer, so walker solves it by Newton's method from E0=ME_0 = M, each step correcting the guess by how far it misses:

Ej+1=Ej−Ej−esin⁡Ej−M1−ecos⁡EjE_{j+1} = E_j - \frac{E_j - e \sin E_j - M}{1 - e \cos E_j}
(3)

where jj counts the steps. The orbits are nearly round, so a few steps do it. The broadcast corrections, the drift of the node (where the orbit crosses the equator) and the turn into Earth-fixed coordinates follow as the ICD writes them.

MEνe = 0.6, exaggeratedsatelliteEarth, in a focusNewton, E from M = 50°:step 0: 50.0000°step 1: 92.8675°step 2: 84.5831°step 3: 84.2024°step 4: 84.2016°ν = 122.1°GPS, e ≈ 0.01: 3 steps
Figure 1. Kepler's equation in a picture, with an eccentricity of 0.6: real navigation orbits are far rounder. MM turns at a constant rate; EE is the angle at the centre to the point on the circle straight above the satellite; ν\nu is the satellite's real angle, seen from the Earth in the ellipse's focus. The steps are Newton's method from E0=ME_0 = M, computed for this drawing.

The geostationary exception

BeiDou's geostationary satellites are the exception. A truly equatorial orbit has no well-defined node, so their elements are given in a tilted frame, and the position has to be turned back. walker's test puts such a satellite on its geostationary radius and checks it stays over one place, within a metre, for an hour.

The satellite's clock

The satellite's clock runs off its system's time by a broadcast polynomial, plus a term for relativity and one for the signal's delay inside the satellite, which differs by band. For GPS L5 walker deviates from the ICD. IS-GPS-705 corrects L5 with an inter-signal correction from CNAV, the newer message on L5, and the broadcast file walker reads has only the older LNAV figure for that delay, scaled by frequency. The difference, a few decimetres per satellite, lands in L5's receiver bias below.

The Earth turns while the signal flies

The orbit gives the satellite in the Earth-fixed frame of the moment it sent the signal, and in the 70 ms or so of flight the Earth turns under it. This is the Sagnac effect. walker turns the satellite into the frame of the moment of reception. That moves a GPS satellite by up to about 150 m, and a range by up to a few tens of metres.

θas sentturnedreceiverNReal, for this satellite:elevation 49°flight τ = 71.4 msθ = 5.21 µradat orbit: 138 mrange: +21.8 mDrawn:θ about 40,000×too large; orbit andEarth to scale
Figure 2. The Earth turns by θ=ωeτ\theta = \omega_e \tau while the signal flies for τ\tau, where ωe\omega_e is the Earth's rotation rate. In the frame of reception, the satellite's position as sent turns back by θ\theta. A model: a receiver on the equator and a satellite at the GPS orbit's radius in the same plane. The distances are to scale, the angle is drawn many times too large, and its real size is computed on the right.

The measurement model

The graph needs each range written in terms of what it solves for. With the satellite's clock taken out, what walker's graph weights for each signal is, in outline,

ρ+c δts=∥rs−rr∥+br+T+bsig+ϵ\rho + c\,\delta t^s = \left\lVert \mathbf{r}^s - \mathbf{r}_r \right\rVert + b_r + T + b_{sig} + \epsilon
(4)

δts\delta t^s is the satellite's clock error, rs\mathbf{r}^s the satellite's position and rr\mathbf{r}_r the receiver's, brb_r the receiver's clock bias in metres, TT the delay in the troposphere, bsigb_{sig} the receiver's bias for this signal against GPS L1 (more below), and ϵ\epsilon the noise. For the troposphere a simple sea-level model by elevation is enough: about 2.4 m straight up and about 9 m at 15°. The ionosphere is missing, deliberately, and gets a section below. The Doppler rates get a model too, from the satellite's and the receiver's velocities along the line between them.

The receiver's clock bias brb_r is why a pseudorange is "pseudo": it lengthens every range of an epoch by an equal amount, so it is solved together with the position (Figure 3).

measuredminus the biassolved positionsatelliteBias: 30 msolved: 31.3 mPositionoff by 1.3 mNoise σ = 1.5 m
Figure 3. Why the clock bias is solved with the position, in a made-up flat example: three satellites, and ranges each 30 m too long by the receiver's clock, plus a little noise. The measured arcs (dashed) don't meet. A least-squares fit of the position and one shared bias shortens them all by the same amount, and they meet. A phone solves the same problem in three dimensions and time.

The details that bit

GLONASS isn't Keplerian

GLONASS broadcasts a position, a velocity and the Sun's and Moon's pull (the luni-solar acceleration) every 30 minutes, valid for about 15 minutes either side. You integrate it yourself: gravity, the Earth's flattening (J2J_2), that pull, and the centrifugal and Coriolis terms of a turning Earth-fixed frame, stepped by fourth-order Runge–Kutta as the ICD says.

Look up those equations in the ICD's 2008 edition and two terms look like misprints. In the Earth-fixed form, the Coriolis term has the same sign in the xx and yy lines, where in any turning frame it changes sign between them; and the zz line's flattening term differs from the inertial form of those equations a few pages earlier. walker follows the physics. The test takes a real state and carries it 15 minutes to meet the next broadcast. It lands within 5 m. With the Coriolis sign as printed it misses by 31 km, and without J2J_2 by 14 m. Real records checked against each other replaced the reference values I had planned to take from RTKLIB.

GLONASS's clock is simpler than the others, with relativity built in. The orbit file gives its reference times in UTC, so walker moves them onto GPS time with the leap seconds, and GLONASS's orbits run on one clock with the rest.

Galileo broadcasts each satellite twice

Galileo's open service has two navigation messages, I/NAV and F/NAV. They share the orbit but not the group delay, because each message's clock is for a different pair of frequencies. E1 takes the I/NAV record, E5a the F/NAV one; RINEX's data-source bits say which is which.

One bias per signal

The receiver's biases turned out to be per signal, not per system. Against GPS L1, the receiver delayed Galileo E1 by −219 m, GPS L5 by −2,350 m, Galileo E5a by −2,348 m, GLONASS by +1,141 m and BeiDou by −459 m, within a few metres in every log. Galileo's E5a sits with GPS L5, not with Galileo's E1. One offset per system and a shared L5 − L1 left 45 m residuals. The graph therefore has one bias per signal: bsigb_{sig} above is one of five numbers, for B1I, E1, E5a, L5 and G1.

GLONASS's channels drift apart

GLONASS splits its satellites by frequency. Each sends G1 on a channel kk from −7 to +6, and the channel sets the carrier:

fk=1602+0.5625 k MHzf_k = 1602 + 0.5625\,k\ \text{MHz}
(5)

where fkf_k is channel kk's carrier. A receiver's delay depends on the frequency, and the residuals ran from about −4 m at k=−7k = -7 to +3–5 m at k=+6k = +6 until the bias got a slope in kk as well as an offset. The slope came out at +0.7 to +0.8 m per kk in all three logs. That's this chip; another may not be linear in kk.

An elevation mask

Low satellites carry the worst multipath, and with 25 or more there are enough without them, so signals from near the horizon are dropped.

15°air1×3.9×receiver510150°30°60°90°2.4 m9.3 m at 15°delay, melevation
Figure 4. A low satellite's signal crosses more air, and skims more of the ground and buildings on its way. Left: the path through a layer of air at 15° elevation against straight up. Right: the delay by elevation from 2.4 m straight up, with a generic 1/sin⁡ε1/\sin\varepsilon mapping for elevation ε\varepsilon, a textbook approximation that fails near the horizon.

Weighting the ranges

The first ride with satellites came out 13 % longer than without them. Each range's weight now accounts for its signal strength and for errors that last, and an epoch's ranges count together as a few independent ones, because the surroundings bend them all at once. The factor graph article has the full story.

The bands surprised me. L5 and E5a fit each epoch twice as well as L1 and E1: median residuals of 1.7–2.6 m against 3.2–5.4 m. Yet the tuning gave them a wider floor on their σ than L1's, not a narrower one, because their errors last longer. GLONASS also gets a wider one, for the channel bias the model leaves.

The ionosphere I left out

The ionosphere delays each signal by an amount that depends on its frequency, which is what lets two bands measure it:

If=40.3 TECf2I_f = \frac{40.3\ \text{TEC}}{f^2}
(6)

in metres, where TEC is the total electron content along the path in electrons per square metre and ff the carrier in hertz. The broadcast Klobuchar model comes from GPS navigation messages, which my phone never delivers. BKG's file had no ionosphere header lines. On this phone there was no model at all.

Dual-frequency satellites can measure it. The classic answer is the ionosphere-free combination of the pseudoranges P1P_1 on L1 and P5P_5 on L5, where PP is a pseudorange, the ρ\rho above, on one band. Because the delay falls with the square of the frequency, a weighted difference of the two cancels it:

PIF=f12P1−f52P5f12−f52=γ1P1−γ5P5,γ1=f12f12−f52≈2.26,γ5=f52f12−f52≈1.26P_{IF} = \frac{f_1^2 P_1 - f_5^2 P_5}{f_1^2 - f_5^2} = \gamma_1 P_1 - \gamma_5 P_5, \qquad \gamma_1 = \frac{f_1^2}{f_1^2 - f_5^2} \approx 2.26, \quad \gamma_5 = \frac{f_5^2}{f_1^2 - f_5^2} \approx 1.26
(7)

with f1=1575.42f_1 = 1575.42 MHz and f5=1176.45f_5 = 1176.45 MHz. The 1/f21/f^2 delays cancel exactly. The noise doesn't: with equal, independent noise σ on both ranges, the combination carries

σIF=γ12+γ52 σ≈2.59 σ\sigma_{IF} = \sqrt{\gamma_1^2 + \gamma_5^2}\ \sigma \approx 2.59\,\sigma
(8)

On a phone's code, whose noise and multipath are metres, that's a poor trade for a delay of a few metres. walker never computes it.

So I probed something gentler: fit one smooth ionosphere, a thin shell with gradients, to a whole log's L1 and L5 pairs at once, together with the receiver's L5 − L1 bias, where the code noise can average out. It fitted vertical delays of −0.5, 1.6 and 2.7 m over three logs, with the pairs' σ at 5–6.5 m. Fitted to five minutes at a time, it swung from −2.3 to 9.3 m: the pairs' noise dwarfs the delay. Taking it out moved the per-epoch solution by 0.7 m at most, either way, and changed the ride's cleaned distance by only 2 m. It stays in the probe, out of the app. The per-signal biases carry the dual-band ranges.

Storing it

walker keeps the raw measurements, not a velocity derived from them, so a better solver later improves old walks too. That costs space. The ride's 1 h 16 min of recorded fixes packed to 8.5 MB at 64 bytes a measurement: four to five times my estimate.

Packing Bytes
walker's packing 8,541,131
LZ4, fast (lz4_flex) 3,201,331
LZ4 HC level 9 2,397,753
deflate level 6 2,300,428
Table 2

Deflate wins on size, but lz4_flex was in the build anyway, through the database, so it added no new crate. Each recording's raw GNSS is now an LZ4 file beside the database, written aside and renamed, and checked by SHA-256. The ride's file came to 3.2 MB, written in 38 ms and read in 26 ms on my desktop. Packing by column would compress much further; the files carry a version, so that can come later.

Backups stream each file in sealed chunks, so a year of walks never sits in memory. An app with no server explains how they work.

What it bought

Per epoch, on three logs, every signal against the old GPS and BeiDou set:

Log Signals an epoch Median distance from the chip's fix
9 min 9.6 → 27.6 14.6 → 7.3 m
4 min 8.4 → 25.1 17.7 → 12.0 m
28 min 11.2 → 30.7 7.8 → 4.9 m
Table 3

The distance from the chip's fix halves on the first log and falls by about a third on the other two. That's agreement with the chip, not accuracy: I have no true path for these logs.

In the cleaned ride, with the factor graph article's two scores: how far the fixes lie from the mapped ways (median and 90th percentile), and how well held-out satellites agree:

Ride, cleaned From the ways Held-out ranges σ
without satellites 2.37 / 5.98 m 7.61 m
GPS L1 and BeiDou only 2.29 / 5.28 m 7.57 m
every signal 2.24 / 5.34 m 7.51 m
Table 4

Every signal beats the old set on the median and on held-out satellites, not on the 90th percentile. Honestly, that's small: the gain is mostly in the worst fixes, and a good fix from the chip was already close to the way. And it costs time: an hour with 29 signals an epoch cleaned in 3.5 s against 2.4 s with 10, in a debug build on my desktop.

What I'd like next is a real reference: points I walk to and mark on the map, and a drive matched to the middle of its lane, to tune the weights against a truth rather than a map. Until then, the numbers above are the ones I'll stand behind.

walker Record walks, hikes and rides with offline maps. Nothing leaves your phone. Coming soon to Google Play.