Cell phone RTK/PPK -How important is a ground plane?

I often stress in my posts the importance of using a ground plane under the antenna when collecting observations for precision GNSS solutions to reduce the effect of multipath. This is true for traditional ceramic patch antennas but is even more important when collecting data with a cell phone.

A ceramic patch antenna typically used with a low cost GNSS receiver has some built in protection against multipath. It is directional with maximum gain in the vertical direction and is also circularly polarized which helps because the reflected signals have the opposite polarization of the direct signals. In this case, adding a ground plane under the antenna reduces multipath by attenuating signals from low elevation angles and eliminating signals from below the horizon, but it is only part of the multipath mitigation.

A cell phone antenna on the other hand is omni-directional and most likely linearly polarized. Hence it has little or no built-in protection against the reflected signals. The only defense in this case against multipath is a ground plane, and so it becomes that much more critical.

I recently stumbled across a very nice cell phone data set shared online by the same authors of the data I used in my very first post on cell phone solutions from a couple years ago. It is taken with an Xiaomi Mi8 phone and includes 40 days of static measurements, each measurement between 10 and 15 minutes long. There are measurements each day from three locations with varying sky visibility; open sky, partially forested, and forested. What makes this data particularly interesting is that for half of the 40 days, the measurements were taken with a ground plane underneath the phone and the other half were taken without a ground plane. The authors then went on to show that the solutions derived from the ground plane data were significantly more accurate than those derived from the data without ground planes. Unfortunately their paper is not available without logging into a service, but a short summary is available here.

In this post I will try to repeat their analysis using their data to confirm I get the same result. They actually used an earlier version of the demo5 RTKLIB for their analysis but I will use the latest version of the code (b34d) and a more recent configuration file. For this exercise I will analyze only the open sky measurements and will leave the more challenging measurements for another post.

The data set is very complete and includes observations from a nearby base station as well as downloaded broadcast navigation data. This last item is important, because as I’ve noted before, the Mi8 Galileo navigation data either has a bug in it or is just incompatible with RTKLIB. For this reason, I ran all solutions with the downloaded broadcast navigation data rather than the navigation data collected by the phone.

I started with the config file I described in my last cell phone post but made a few changes. I have appended the contents of the final config file I ended up with to the end of this post. I’ll describe the changes I made below.

First of all, this data is static, unlike the previous data, so I changed the solution mode from “kinematic” to “static”.

Next, looking at the residuals from an initial solution plotted with RTKPLOT, I noticed a significant difference in the magnitude of the pseudorange residuals between L1 and L5. In the plots below, the left plot shows the L1 residuals and the right plot shows the L5 residuals. The carrier phase residuals are similar but the pseudorange residuals are much smaller for L5. This is not unexpected because the newer L5 signal has improved structure and higher power. The details are explained in this article.

Solution residuals for L1(left) and L5(right) observations

To take advantage of this difference, I adjusted the code/carrier phase error ratio for L1 (stats-eratio1) from 300 to 1500 and left the L5 error ratio (stats-eratio5) at 300. This will cause the kalman filter to weight the L5 pseudorange observations more heavily than the L1 observations. In RTKPOST, this parameter is set in the Statistics tab of the Options menu. Note that prior to the b34d code, the L5 error ratio parameter existed but was not configurable through the GUI interface in RTKPOST, but that is fixed now.

Next, based on the SNR of the observations, plotted below, I chose to reduce the minimum SNR threshold from 34 dbHz to 24 dbHz. This is a somewhat arbitrary setting and may not be optimal, but leaving it at 34 dbHz for this data set would have thrown out too many usable satellites. It could also have been adjusted separately for L1 and L5 but I did not choose to do this. It is interesting that the L5 observations do not appear to have higher SNR than L1 despite the increased signal power mentioned above. This may be due to limitations of the antenna.

SNR of observations, L1 (left) and L5(right)

The last change I chose to make was to change the solution type (Filter mode in the RTKPOST options menu) from “Combined to “Combined-no phase reset”. This is a relatively new option in the demo5 code. The official 2.4.3 code always resets the phase bias estimates between the forward and backward solutions and this is a more conventional approach. Earlier versions of the demo5 code reset the phase estimates if ambiguity resolution was set to fix-and-hold but did not reset them otherwise. The reason to reset them is to insure the forward and backward solutions independent, but especially for shorter data sets, it can be advantageous to use the converged estimates as a starting point for the backwards solution. Since, in this experiment, the measurements are fairly short (10-15 minutes), I chose to not reset the biases.

The next step was to run the solution on all 40 days of data. RTKPOST is nice for working with a single data set and experimenting with different parameter settings, but to run many solutions quickly, it is easier to batch process them with the RNX2RTKP command line version of RTKPOST. I used a simple python script to run RNX2RTKP ( the CLI version of RTKPOST) in multiple simultaneous threads to make this process faster since a single instance of an RTKLIB app will only use a single processor . I’ll discuss this script in more detail in my next post.

I then used python to plot the error in the solutions, 40 points in all, 2o with ground planes, 20 without. To calculate the errors, I subtracted the ground truth included with the data set from each solution position.

Static PPK solutions for open sky data, with and without ground plane

Note that the ground truth was generated from a survey grade receiver but is only approximate because the exact location of the antenna within the phone was unknown. Cell phone antennas can also have large offsets between the mechanical center of the antenna (ARP) and the electrical center of the antenna (APC). No attempt was made to compensate for these.

As expected, the ground plane solutions were significantly more accurate than the no-ground plane solutions. In fact, 19 out of the 20 measurements solutions for data using a ground plane were within a few centimeters of each other. Only 7 of the 20 measurements without ground planes met the same criteria. Overall, I would say this data makes quite a compelling argument for using a ground plane. I understand it’s not always possible in a cell phone application to do this, but it’s important to at least understand the significance of this choice.

The results were also consistent enough to suggest that cell phones, despite their low performance antennas, are becoming a viable option for practical measurements, at least in open sky environments. It’s worth noting as well, that, although the errors were much larger without the ground plane, even the errors in these measurements were consistently well below a meter.

It is important to remember that this data was all taken with an open sky view and that you should not expect such good results in more challenging conditions. It’s also still true that low cost receivers with higher quality antennas will still give much more robust and reliable solutions.

There’s plenty more data in this data set that I didn’t look at in this post but hope to take a closer look at in the future.

Config file used for this experiment:

# rtkpost options for b34d code, static Mi8 cell phone
pos1-posmode       =static     # (0:single,1:dgps,2:kinematic,3:static,4:static-start,5:movingbase,6:fixed,7:ppp-kine,8:ppp-static,9:ppp-fixed)
pos1-frequency     =l1+l2+l5   # (1:l1,2:l1+l2,3:l1+l2+l5,4:l1+l2+l5+l6)
pos1-soltype       =combined-nophasereset # (0:forward,1:backward,2:combined,3:combined-nophasereset)
pos1-elmask        =15         # (deg)
pos1-snrmask_r     =on         # (0:off,1:on)
pos1-snrmask_b     =on         # (0:off,1:on)
pos1-snrmask_L1    =24,24,24,24,24,24,24,24,24
pos1-snrmask_L2    =34,34,34,34,34,34,34,34,34
pos1-snrmask_L5    =24,24,24,24,24,24,24,24,24
pos1-dynamics      =on         # (0:off,1:on)
pos1-tidecorr      =off        # (0:off,1:on,2:otl)
pos1-ionoopt       =brdc       # (0:off,1:brdc,2:sbas,3:dual-freq,4:est-stec,5:ionex-tec,6:qzs-brdc)
pos1-tropopt       =saas       # (0:off,1:saas,2:sbas,3:est-ztd,4:est-ztdgrad)
pos1-sateph        =brdc       # (0:brdc,1:precise,2:brdc+sbas,3:brdc+ssrapc,4:brdc+ssrcom)
pos1-posopt1       =off        # (0:off,1:on)
pos1-posopt2       =off        # (0:off,1:on)
pos1-posopt3       =off        # (0:off,1:on,2:precise)
pos1-posopt4       =off        # (0:off,1:on)
pos1-posopt5       =off        # (0:off,1:on)
pos1-posopt6       =off        # (0:off,1:on)
pos1-exclsats      =           # (prn ...)
pos1-navsys        =45         # (1:gps+2:sbas+4:glo+8:gal+16:qzs+32:bds+64:navic)
pos2-armode        =fix-and-hold # (0:off,1:continuous,2:instantaneous,3:fix-and-hold)
pos2-gloarmode     =fix-and-hold # (0:off,1:on,2:autocal,3:fix-and-hold)
pos2-bdsarmode     =on         # (0:off,1:on)
pos2-arfilter      =on         # (0:off,1:on)
pos2-arthres       =3
pos2-arthresmin    =3
pos2-arthresmax    =3
pos2-arthres1      =0.1
pos2-arthres2      =0
pos2-arthres3      =1e-09
pos2-arthres4      =1e-05
pos2-varholdamb    =0.1        # (cyc^2)
pos2-gainholdamb   =0.01
pos2-arlockcnt     =5
pos2-minfixsats    =4
pos2-minholdsats   =5
pos2-mindropsats   =10
pos2-rcvstds       =off        # (0:off,1:on)
pos2-arelmask      =15         # (deg)
pos2-arminfix      =10
pos2-armaxiter     =1
pos2-elmaskhold    =15         # (deg)
pos2-aroutcnt      =20
pos2-maxage        =30         # (s)
pos2-syncsol       =off        # (0:off,1:on)
pos2-slipthres     =0.05       # (m)
pos2-rejionno      =1          # (m)
pos2-rejgdop       =30
pos2-niter         =1
pos2-baselen       =0          # (m)
pos2-basesig       =0          # (m)
out-solformat      =llh        # (0:llh,1:xyz,2:enu,3:nmea)
out-outhead        =on         # (0:off,1:on)
out-outopt         =on         # (0:off,1:on)
out-outvel         =off        # (0:off,1:on)
out-timesys        =gpst       # (0:gpst,1:utc,2:jst)
out-timeform       =hms        # (0:tow,1:hms)
out-timendec       =3
out-degform        =deg        # (0:deg,1:dms)
out-fieldsep       =
out-outsingle      =off        # (0:off,1:on)
out-maxsolstd      =0          # (m)
out-height         =ellipsoidal # (0:ellipsoidal,1:geodetic)
out-geoid          =internal   # (0:internal,1:egm96,2:egm08_2.5,3:egm08_1,4:gsi2000)
out-solstatic      =all        # (0:all,1:single)
out-nmeaintv1      =0          # (s)
out-nmeaintv2      =0          # (s)
out-outstat        =residual   # (0:off,1:state,2:residual)
stats-weightmode   =elevation  # (0:elevation,1:snr)
stats-eratio1      =1500
stats-eratio2      =300
stats-eratio5      =300
stats-errphase     =0.006      # (m)
stats-errphaseel   =0.006      # (m)
stats-errphasebl   =0          # (m/10km)
stats-errdoppler   =1          # (Hz)
stats-snrmax       =52         # (dB.Hz)
stats-stdbias      =30         # (m)
stats-stdiono      =0.03       # (m)
stats-stdtrop      =0.3        # (m)
stats-prnaccelh    =3          # (m/s^2)
stats-prnaccelv    =1          # (m/s^2)
stats-prnbias      =0.001      # (m)
stats-prniono      =0.001      # (m)
stats-prntrop      =0.0001     # (m)
stats-prnpos       =0          # (m)
stats-clkstab      =5e-12      # (s/s)
ant1-postype       =llh        # (0:llh,1:xyz,2:single,3:posfile,4:rinexhead,5:rtcm,6:raw)
ant1-pos1          =0          # (deg|m)
ant1-pos2          =0          # (deg|m)
ant1-pos3          =0          # (m|m)
ant1-anttype       =
ant1-antdele       =0          # (m)
ant1-antdeln       =0          # (m)
ant1-antdelu       =0          # (m)
ant2-postype       =rinexhead  # (0:llh,1:xyz,2:single,3:posfile,4:rinexhead,5:rtcm,6:raw)
ant2-pos1          =0          # (deg|m)
ant2-pos2          =0          # (deg|m)
ant2-pos3          =0          # (m|m)
ant2-anttype       =
ant2-antdele       =0          # (m)
ant2-antdeln       =0          # (m)
ant2-antdelu       =0          # (m)
ant2-maxaveep      =1
ant2-initrst       =on         # (0:off,1:on)
misc-timeinterp    =on         # (0:off,1:on)
misc-sbasatsel     =0          # (0:all)