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, , 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, .
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
where is the span the satellite counts in (a week, or GLONASS's day), is the speed of light, and is the pseudorange in metres.
The traps are all in the edges.
- A signal sent just before the week's or day's end and received just after would come out nearly a whole long, so a travel time over half of is folded back. One of walker's tests sends a GLONASS signal at 23:59:59.96 in Moscow and receives it at 00:00:00.03, and the range comes out at 70 ms of travel.
- Galileo's E1 signal carries a code that repeats every 100 ms, E1C's secondary code. Locked to that code, the chip knows the transmit time within 100 ms before it has decoded the time of week. A signal's whole flight fits in that, so walker uses it, with of 100 ms.
- A pseudorange is only made when the measurement's state bits, Android's flags for how far the chip has synchronised with the signal, say that the full time of week is known (for GLONASS, the time of day), or Galileo's 100 ms above. Otherwise the chip only knows the code phase: where it is within one repeat of the code.
- GPS time in nanoseconds needs 61 bits, and an
f64holds 53. walker subtracts the integers first and adds the fractions after.
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 |
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 is a clock hand turning at a constant rate, and the satellite's real angle has to be worked out from it:
where is the orbit's eccentricity and the eccentric anomaly. It has no closed-form answer, so walker solves it by Newton's method from , each step correcting the guess by how far it misses:
where 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.
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.
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,
is the satellite's clock error, the satellite's position and the receiver's, the receiver's clock bias in metres, the delay in the troposphere, the receiver's bias for this signal against GPS L1 (more below), and 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 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).
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 (), 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 and lines, where in any turning frame it changes sign between them; and the 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 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: 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 from −7 to +6, and the channel sets the carrier:
where is channel 's carrier. A receiver's delay depends on the frequency, and the residuals ran from about −4 m at to +3–5 m at until the bias got a slope in as well as an offset. The slope came out at +0.7 to +0.8 m per in all three logs. That's this chip; another may not be linear in .
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.
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:
in metres, where TEC is the total electron content along the path in electrons per square metre and 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 on L1 and on L5, where is a pseudorange, the above, on one band. Because the delay falls with the square of the frequency, a weighted difference of the two cancels it:
with MHz and MHz. The delays cancel exactly. The noise doesn't: with equal, independent noise σ on both ranges, the combination carries
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 |
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 |
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 |
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.