(Also lomonster.com and clomont.com)

Rtk Gps Project


I built a RTK-GPS gadget and wrote enough software to do basic GNS tasks such as finding distances, decoding GPS NMEA and RTCM messages, finding intersections, and doing boundary detection.


I recently wanted to play with RTK GPS to learn how to do precise location, mostly for robots I want to let loose on my property so they have better knowledge of where they are.

What is RTK GPS?

The Global Positioning System (GPS) uses satellites talking to local receivers (in phones, cameras, and all sorts of gadgets) to let a device know where it is on the earth, often to within a 1-10 meters. Since the original US based GPS was built, several other systems have been built, offering multiple sets of satellites, each with differing options, that can be used. However none let a user get centimeter precision without some other help. The acronym GPS refers specifically to the US based system; GNS (Global Navigation System) is the acronym used to mean any of the current systems.

Current systems include the Russian GLONASS, China’s BeiDou, European Galileo, India’s NavIC, and Japan’s QZSS.

The best systems now provide consumer accuracy to about 30cm without other hardware. To get high precision, state-of-the-art RTK (Realtime Kinematic) gives centimeter (and even sub centimeter) accuracy. It works like this: a big part of the error in consumer systems comes from variability in the atmosphere (moisture, electrical changes, density, temperature, etc.) which changes the signal reaching the receiver enough to add lots of uncertainty. To get around this unknown quantity, the same signal from space is read by another nearby location that knows precisely where it is, and it can use it’s knowledge of it’s location to figure out the error in the space signals through current local atmosphere, and these corrections are sent to the moving RTK system, so it too can correct the atmosphere errors.

This used to be quite expensive, but recent RTK-GPS receivers have been dropping. The one I decided to play with is a $219.95 SparkFun module based on the u-blox ZED-F9P RTK GPS module.


The Plan

The first issue is the need to get a RTK correction stream from a known location. One way this is done is to use two modules and set one up as a known location, and let it run a few days to get a decent lock on where it is. Then the roving module can compute its position relative to the fixed position. This does not give as accurate of a position since the known position module only thinks it knows where it is; it is not precisely known like a precisely located source would be.

The second method is to find a RTK source nearby. Most states have some form of free method through services like Department of Transportation. The Sparkfun article has other places to look. I was able to find one available for free in my state of Indiana from the Indiana DoT (https://incors.in.gov/rtk.aspx). All I had to do was submit an application for services, and a few weeks later they sent me an IP address with login credentials to access the service. The RTK stream I used was served from around 10 miles away - the closer the better.

Then this stream will be fed into the ZED (initially by Bluetooth through a phone, later by a LoRa network), which uses the stream to correct location.

And supposedly this will give centimeter accuracy and even better precision.

The Build


From reading, it seems better GNS antennas will give better results, and since I wanted as good of results as I can get, I opted for the more expensive ($59.95) antenna that handles multiple GNS systems. Since the base ZED-F9P chip handles multiple systems simultaneously to increase precision, this antenna is the best on the SparkFun site. I also bought the cheaper single channel $12.95 antenna for comparison (not yet tested).

A 4” ground plane is supposed to make a much stronger signal, and at $4.95, it seemed a no brainer to get.

There are two ZED-F9P based boards that differ mainly in their antenna connector - I chose the board with the much more rugged SMA connector.

To get RTK data from the internet, through my phone, to the ZED-F9P module, I opted for the Bluetooth serial module ($27.95). So I placed an order:

SparkFun items (Order Oct 16, arrived soon therafter)
SparkFun GPS-RTK-SMA Breakout - ZED-F9P (Qwiic) GPS-16481 219.95
SparkFun Bluetooth Mate Silver WRL-12576 ROHS 27.95
GPS/GNSS Embedded Antenna - 1m (SMA) GPS-14987 59.95
GPS/GNSS Magnetic Mount Antenna - 3m (SMA) 12.95
GPS Antenna Ground Plate 4.95



I started following the SparkFun Hookup guide https://learn.sparkfun.com/tutorials/gps-rtk2-hookup-guide, but found there were a lot of things that didn’t work. Here’s a summary of how I got the system to work:

First, the Bluetooth module is hooked directly, pin for pin, to the board. This did not work. Look at the left of the board at the TX2, RX2, 3V3, GND portion, then look at the Bluetooth module from the bottom:



These are supposed to line up. Mine did not work, and I had to do a bit of debugging with my trusty Saleae logic analyzer


and it showed that the transmit and receive signals needed crossed over. Once I did this I was able to get signal between my components. I built a test board by soldering headers on each component and then using a breadboard to hook things up. Be sure to hook up ground and power between the two boards.

For power I during testing I simply used the USB-C connector on the board as power and routed it to the Bluetooth module.

Software setup

The Bluetooth serial module defaults to 115,20 baud, and the ZED-F9P defaults to 38,400, so the first task is to get them at the same baud rate. To modify the ZED-F9P, use the u-blox free software u-center (it’s an amazing piece of software!). The pics below are from version 20.10, in Dec 2020.

Select the COM port your device shows up as, and connect to it. Open a configuration window (under View), and note there are TONS of options. To get a handle on them , if needed, consult the ZED-F9P technical manual.

If you hook up the antenna at this point, you should get some messages that the u-connect software can visualize. If you view the GPS location, you’ll notice how much it drifts at the moment, like normal (non-RTK) GPS does.

Select PRT(Ports) (#1), then select UART2 (#2) from the dropdown, then select the protocol in and out (#3) with NMEA and RTCM3 options. This is important! The SparkFun solution does not do this, and you’ll get errors later in your phone NTRIP software that it cannot connect. The reason is that the ZED-F9P default message filters don’t pass along the needed messages to make it work. You’ll get NTRIP errors stating it it is waiting on GGA messages, which never come.

Finally, select a baud rate of 115200 (#3). Select Send Message (#4) at the bottom.

THIS IS NOT YET SAVED PERMANENTLY TO THE ZED-F9P! It has only changed the settings. You can fiddle with ZED-F9P settings without making them permanent.


To get the highest amount of precision, you also need to make the ZED output more digits of precision. Select the NMEA Protocol on the left, select High Precision Mode, and make sure the 82 char limit is not selected. Press Send. You will need to same this setting out below (TODO - integrate into doc later…).

While we’re in here, we’ll make one more change to stop some messages that make parsing harder later. The GPS log later will get an occasional binary message with the text “WUP idle on ch” in the stream, breaking normal parsing. This is a u-blox INFormation message. Go to the INF configure section, select the 0-UBX dropdown, and deselect all selected items in column numbered 2.

See https://portal.u-blox.com/s/question/0D52p00008HKDa1/debug-information-wup-on-idle-ch-6-what-does-it-mean for some details.

If you want a higher than 1Hz update rate, go to HNR and set a rate. 20Hz is the highest possible, but may be lower in practice based on chip use. Check the manual. Again be sure to press SEND at the bottom before leaving the page. I’ve not yet tested this setting - there may be some need to change baud rates and disable messages on other ports (performance and buffer issues) to get it working.

Now to save all changes to the chip. Changes are saved a little at a time by messages. Go to the VALSET (#1) option. Select group CFG-UART2 (#2), keyname with BAUDRATE (#3), and select add to list. That adds the command to a list. Next group UART2INPROT, add keys UBX, NMEA, RCTM3 (do not add RCTM2, an older protocol). Do this for the UARTOUTPROT also.

Continue, getting the messages in the pic (UART2 baud rate, in and out protocols, and ubx-infmsg for uart2). Select them one at a time, and click “get current value” (#4) to see everything is correct. Then select Send at the bottom.

Info to save

Make a change to the UBX UART2 Output setting (which is a 1) . Select that line (#5), click false (#6), to disable UBX messages coming out the UART port to our GPS receiver.

After hitting Send (#7), this should be saved in the chip. Remove power for a few seconds, reconnect, and come back to here and check settings saved.

If somehow you think you bricked the chip, there are various levels of reset you can do. See https://www.ardusimple.com/simplertk2b-hack-3/ for an intro and google for more info.


The next piece of the puzzle is to get the RTK stream from your provider to the ZED-F9P. Using an Android phone makes this easy: use the free NTRIP app. First pair the Bluetooth module to your phone. Mine showed up as device RNBT-DB31.

Go under settings, go to Receiver settings, select type “External under Bluetooth”, select your device. While you’re here you can log all data to the phone by selecting the “Save GPS Data to File” and “Save NTRIP Data to File” checkboxes. If you want to use your precise GPS in other apps, select that option too.

Under the NTRIP settings, select your caster IP (where you get RCTM messages) and port, user name and password, and I selected stream as RTCM3_MAX from my available streams. You’ll have to see what options you have. You’ll want RCTM3 and not RCTM2.

Finish build

For a portable build for testing, I used a USB battery to power the entire assembly. Finally, I hooked up the antenna. With the antenna isolated somewhat on the wooden board (less RF noise), I figured I could get a decent signal to work. I powered it up, and watched the signal lights on the ZED board:

  1. PWR (red) lights when 3.3v achieved from USB or Qwiic bus
  2. PPS (yellow) 1 pulse per second when GPS locked
  3. RTK (green) constantly on power up. Blinks when RTCM data received. Once fix obtained, turns off
  4. FENCE (blue) can be configured for geofencing configs!

First build

Start NTRIP, select connect. Take the device outside where you have a clear view of the sky. Wait a little, watching the lights to see if you get a RCTM lock. If NTRIP sends a message that it’s waiting for a GGA message, then doesn’t progress past that, the settings from above are wrong. It means the phone is not seeing the GNS location messages sent from the ZED, over the Bluetooth module, to the phone.


My first tests were to see how repeatable the coordinates are, and how little drift I could get. I soon learned there are different kinds of signal lock, shown in the NTRIP app in large letters. The three that were common were GPS (normal mode), DGPS (differential, slightly better), FloatRTK (when RTK is present but the system has too much noise and doesn’t get a lock), and the best, RTK (excellent lock).

With the NTRIP saving both the GPS and RCTM logs, I could pull them to a PC for analyzing. Since I usually like understanding all the details of a system, I wrote both a GPS and a RCTM message decoder and some analysis software in C# (some details below).

I soon found that I get essentially no drift over hours on end for RTK mode. For this I would sit the device outside and record, then later look at the RTK tagged positions.

Repeatability was also outstanding. I marked several places around a 14 acre lot, and over the course of a few weeks took walks and visited the spots. Each time I would place the antenna at the marked spot and let it sit. Again, there was no change in the positions (other than perhaps slightly misplacing the antenna). I could make this precise by 3D printing pegs with specific mounting orientation, and then printing a piece for the antenna, to ensure a very precise position each time, but I have not done this.


The next piece I wanted was to bound my property with some visual or audial method to detect edges. So I used an ESP32 to listen to the messages and compare to my property boundaries (I read the four corners, and linearly interpolated lat and long, which is not perfectly precise, but is precise enough for this size plot - more on that later).

To make it easy to set up, I used a M5Stack Core2 gadget - which is a very nice ESP32 in a small module, battery powered, with a touchscreen.

M5Stack Core2

ESP32 device

I originally started a full production - touchscreen, menu, graphical image, zoom and pan, save to SD card, and more, then decided to just get this project out the door for now, and settled on a very simple interface: it shows the distance to each corner and to each edge, and the items are color coded: green is far away, then yellow, then red.

Connection is trivial: you need to tie ground wires. DO NOT CONNECT POSITIVE. The M2Stack Core2 has a battery. Tying grounds puts the two systems at the same potential for signaling. Connect ZED transmit to a UART in on the ESP32. Then the ESP software decodes the GGA messages, and does the calculations using the same WGS84 distances. For closest point to edge I simply treated the lat and long as 2D coords and did nearest point on line calculation in lat/long space. Again, this is slightly incorrect, but good enough for this use. And it was simple to get running using Arduino instead of a full blown ESP-IDF production.

It worked like a charm, and I discovered some interesting aspect of my property where it’s hard to know where the boundary is.

Land analysis

My property deed is pretty straightforward - there are 2 ground pegs at two corners, with a precise distance (hundredths of an inch) between them. It took some time to find the old pegs, but I did, and they measured exactly as claimed.

The other two corners were more vague - one was called a “section corner” but there was no mark. And oddly, from my measurements, in one direction the plot was perfect, in the other direction, both edges were 10’-15’ short. I figured it had something to do with some aspect of surveying I didn’t get from the document. My guess was somehow the road out front ate part of my yard. So I called a surveyor, and asked, and sure enough, the front did eat my yard. There was also three markers, which I knew were not the section corner, and I figured the actual corner was some function of the known markers.


What I took for document references on the markers turned out to be distances to the actual section corner, and using the three and finding the intersection of the circles gave a perfect spot - middle of an intersection, under the road, but it measured perfectly to the other two known pegs.

The fourth peg I computed from the three known ones and the deed; it too was under the road. I learned that metes and bounds properties are this way - they split the road.

A few more laps and measurements, and my RTK GPS works as awesomely as I could have hoped. It gets the same coords, to the last digit, repeatedly time and time again.

Note this is only when I have a RTK lock. FloatRTK drifts a little. Usually waiting clears it up. I’ve also noticed very overcast weather makes the signal less likely to lock - probably more noise or uncertainty between the various satellites.

Custom software for analysis

To check my data, I needed to analyze the log files. My first files were the NTRIP GPS files, which are ASCII files consisting of lines that look like


These are NMEA messages. A good place to look up these is https://gpsd.gitlab.io/gpsd/NMEA.html

Each starts with a $ and end with a \r\n. Fields are comma separated. The first field is a two letter satellite type (more or less). GP means GPS. The next three letters are the message type - GGA for the example above. Each ends with a *, followed by a two digit hex checksum. The checksum is the XOR of all bytes between the $ and the *, not including the end symbols.

The GGA message is a very nice message to understand - it encodes the position, height, fix type, and some other data. Here’s the fields

Field Meaning
__GGA Message type
hhmmss.ss Time of day
ddmm.mmmmm Latitude (degrees, then minutes and decimal minutes)
N or S Hemisphere. Latitude is negative for South
dddmm.mmmmm Longitude (degrees, then minutes and decimal minutes)
E or W Hemisphere. West is negative
a single digit for quality of fix (see link above) Of interest: 4 = RTK
Number of satellites in use
Horizontal Dilution of precision (meters) HDOP, describes error in location
Antenna Altitude above/below mean-sea-level (geoid) (in meters)
M units antenna meters
Geoidal separation
M - separation units meters meters
Age of differential GPS data, time in seconds since last SC104 type 1 or 9 update, null field when DGPS is not used unused for RTK locked messages
Differential reference station ID, 0000-1023

A quick note: the coordinates are NOT decimal degrees, which I find most useful. They are 2 (lat) or 3 (long) digits of degrees, then 2 digits for minutes 0-59, then decimal minutes. Convert them to decimal degrees, then negate for South or West.

So my parser read all files, scanned all messages, and I added decoders for those of interest. This is the breakdown I found for message types (counts per message type):

RMC -> 720
VTG -> 720
GGA -> 720
GSA -> 2880
GSV -> 13221
GLL -> 720
TXT -> 1

Next up was RCTM messages. This is a binary format, with the byte 0xD3 as a separator (but it can appear in data, so you need a careful filter).

The message packet starts with 0xD3, then 6 bits of zeros, then 10 bits of message length in bytes, then a 24 bit checksum. https://www.use-snip.com/kb/knowledge-base/rtcm-3-message-list/ has some good details.

Each message has a 4 digit number. Here are the breakdown of those I found in my testing

1014 -> 25631
1006 -> 5150
1004 -> 25640
1012 -> 25640
1013 -> 5122
1015 -> 25518
1016 -> 25512
1037 -> 25512
1038 -> 25512
1008 -> 5127
1033 -> 5127
1230 -> 5127
1029 -> 14
4092 -> 14

To compute distances, I used the WGS84 geoid, since that is the default for GPS systems. The ZED-F9P allows you to use a bewildering array of output formats. Read toe docs to use a different one.

A geoid takes into account the earth is not a sphere and is more accurate. WGS84 doesn’t account for other irregularities of the earth, however. But it’s good enough for my uses.

Given two lat/long/height triples, here is code to compute the distance between them:

// Center of coord system supposed to be earth's center of mass, uncertainty about 2cm
// Zero meridian is the IERS Reference Meridian, 5.3 arc seconds (102m) east of the Greenwich meridian
// oblate spheroid, 
// equitorial radius a = a = 6378137 
// flattening flattening f = 1/298.257223563
// WGS gravitational constant  S 84 gravitational constant (mass of Earth’s atmosphere included) is GM = 3986004.418×108 m³/s².
//  angular velocity of the Earth is defined to be ω = 72.92115×10−6 rad/s
// polar semi-minor axis b which equals a × (1 − f) = 6356752.3142 m
// first eccentricity squared, e² = 6.69437999014×10−3
// WGS 84 uses the Earth Gravitational Model 2008 This geoid defines the nominal sea level surface by means of a spherical harmonics series of degree 360
// WGS 84 currently uses the World Magnetic Model 2020

const double a = 6378137;
const double f = 1 / 298.257223563;
const double b = a * (1 - f);
const double e2 = 6.69437999014e-3; // eccentricity e squared, e = 1-b^2/a^2;

/// <summary>
/// Convert Lat/Long/ht to X,Y,Z
/// </summary>
/// <returns></returns>
public static Vec3 GeodeticToEcef(LatLong position)
    var p = DegreesToRadians(position.latitude);
    var L = DegreesToRadians(position.longitude);
    var h = position.Height;
    var s = Math.Sin(p);
    var Np = a / Math.Sqrt(1 - e2 * s*s);
    var x = (Np + h) * Math.Cos(p) * Math.Cos(L);
    var y = (Np + h) * Math.Cos(p) * Math.Sin(L);
    var z = (b * b * Np / (a * a) + h) * Math.Sin(p);
    return new Vec3(x,y,z);
public static double DegreesToRadians(double degrees)
    return degrees * Math.PI / 180.0;

/// <summary>
/// Distance in meters between two lat/long/height positions
/// </summary>
/// <returns></returns>
public static double Distance(LatLong position1, LatLong position2)
    var c1 = GeodeticToEcef(position1);
    var c2 = GeodeticToEcef(position2);
    return (c1 - c2).Length;

To compute more advanced things such as angles, and where a geodesic ends up, the theory is in Algorithms for geodesics, Karney, 2013. It’s not terribly hard to implement.

To determine the difference between GNS east, and actual magnetic east (and other directions), note that the magnetic pole moves, and has changed over time. For example, where I live, there is a 5.73 degree difference between magnetic North and GNS North. In 1900, this was 0.7 degrees, so drift is important, especially when reading directions of old deeds. The term to search is magnetic declination, and you do not want to try and implement this. Here is a calculator https://www.ngdc.noaa.gov/geomag/calculators/magcalc.shtml, and here is code and data tables to integrate into apps if you need it https://www.ngdc.noaa.gov/geomag/WMM/soft.shtml.

To compute the various intersections of things I needed, I did a simple process: create a small lat/long aligned grid containing the point of interest, find the grid cell with best fitting center, subdivide that, and repeat, until the error was small enough. There are better ways, but much harder to code. This worked fine for my uses over essentially planar questions about extensions and intersections and other constraint solving problems.

Final directions

The last part I plan to do is to use two ESP32s and a LoRa network over my property. LoRa has a range of many kilometers, but a slow data rate, but it’s high enough for this. That way I can remove the phone and the Bluetooth module from the solution, and stream RCTM from my network, to an ESP32, over LoRa, and into the other ESP32, then into the ZED-F9P board.



Comment is disabled to avoid unwanted discussions from 'localhost:1313' on your Disqus account...