Introduction
Have you ever wondered why Microsoft hasn't included a some sort of GPS class in DotNet? You know, something that can parse/manipulate a GPS coordinate, and maybe provide methods for calculating a distance and/or bearing between two points? I recently needed this very functionality, so I came up with the following code. It's a simple implementation of functionality I need. I didn't need to plot the data on a map, I merely wanted to be able to determine the bearing and distance between two points. The most important part of the whole thing was how to parse potential coordinates.
The Code
The code is comprised of four files that can easily be compiled into an assembly, or dropped into an existing assembly in your own project.
The LatLongBase Class
This class is where all of the work is performed regarding the parsing of a GPS coordinate. There are two classes derived from this class - Latititude and Longitude - which serve merely to identify which part of the entire coordinate is represented (a brief description is presented in the following section). Because we don't want this class to be instantiated on its own, it's abstract, with only latitude/longitude-specific methods being contained in the inheriting classes.
First, I define an enumerator to represent the four cardinal points on the compass.
public enum CompassPoint { N,W,E,S }
Next come the properties.
public int MathPrecision { get; set; }
public double Value { get; set; }
public CompassPoint Direction { get; set; }
public double Radians
{
get { return Math.Round(GPSMath.DegreeToRadian(this.Value), this.MathPrecision); }
}
public double Degrees
{
get { return Math.Round(GPSMath.RadianToDegree(this.Value), this.MathPrecision); }
}
Next, I implemented several constructor overloads that accept a reasonable variety of coordinate formats. The general idea is that each constructor is responsible for determining the validity of the parameters after calling its own SanityCheck
method to catch potential problems. The first three overloads are fairly simple, because we're dealing with nothing more traumatic than numeric values.
public LatLongBase(int degs, int mins, double secs, int mathPrecision = 6)
{
this.SanityCheck(degs, mins, secs, mathPrecision);
this.MathPrecision = mathPrecision;
this.DMSToValue(degs, mins, secs);
this.SetDirection();
}
public LatLongBase(int degs, double mins, int mathPrecision = 6)
{
this.SanityCheck(degs, mins, mathPrecision);
this.MathPrecision = mathPrecision;
int tempMins = (int)(Math.Floor(mins));
double secs = 60d * (mins - tempMins);
this.DMSToValue(degs, tempMins, secs);
this.SetDirection();
}
public LatLongBase(double value, int mathPrecision = 6)
{
this.SanityCheck(value, mathPrecision);
this.MathPrecision = mathPrecision;
this.Value = value;
this.SetDirection();
}
However, things get a bit more interesting with the final overload, which accepts the coordinate as a string. As you might expect, there's a considerable amount of work to do due to the varying formats that are allowed. What a nut-roll, right?
public LatLongBase(string coord, int mathPrecision = 6)
{
this.SanityCheck(coord, mathPrecision);
this.MathPrecision = mathPrecision;
coord = coord.ToUpper();
coord = this.AdjustCoordDirection(coord);
coord = coord.Replace("\"", "").Replace("'", "").Replace(GPSMath.DEGREE_SYMBOL, "").Trim();
this.SanityCheckString(coord);
string[] parts = coord.Split(' ');
bool valid = false;
int degs = 0;
int mins = 0;
double secs = 0d;
switch (parts.Length)
{
case 1 :
{
double value;
if (double.TryParse(coord, out value))
{
this.SanityCheck(value, mathPrecision);
this.Value = value;
}
else
{
throw new ArgumentException("Could not parse coordinate value. Expected degreees (decimal).");
}
}
break;
case 2 :
{
double minsTemp = 0d;
valid = ((int.TryParse(parts[0], out degs)) &&
(double.TryParse(parts[1], out minsTemp)));
if (!valid)
{
throw new ArgumentException("Could not parse coordinate value. Expected degrees (int), and minutes (double), i.e. 12 34.56.");
}
else
{
mins = (int)(Math.Floor(minsTemp));
secs = Math.Round(60d * (minsTemp - mins), 3);
this.SanityCheck(degs, mins, secs, 3);
}
}
break;
case 3 :
{
valid = ((int.TryParse(parts[0], out degs)) &&
(int.TryParse(parts[1], out mins)) &&
(double.TryParse(parts[2], out secs)));
if (!valid)
{
throw new ArgumentException("Could not parse coordinate value. Expected degrees (int), and minutes (int), and seconds (double), i.e. 12 34 56.789.");
}
else
{
this.SanityCheck(degs, mins, secs, mathPrecision);
}
}
break;
}
if (valid && parts.Length > 1)
{
this.DMSToValue(degs, mins, secs);
this.SetDirection();
}
}
The SanityCheck
methods are used to validate the constructor parameters, and throw exceptions when necessary. There are a number of SanityCheck
overloads to handle the required validation. It should be pretty obvious what's going on here, so there aren't any code comments. The one thing you might notice is that there are a couple of overloads that support the string-based class constructer.
private void SanityCheck(int degs, int mins, double secs, int mathPrecision)
{
int maxDegrees = this.GetMaxDegrees();
int minDegrees = maxDegrees * -1;
if (degs < minDegrees || degs > maxDegrees)
{
throw new ArgumentException(string.Format("Degrees MUST be {0} - {1}", minDegrees, maxDegrees));
}
if (mins < 0 || mins > 60)
{
throw new ArgumentException("Minutes MUST be 0 - 60");
}
if (secs < 0 || secs > 60)
{
throw new ArgumentException("Seconds MUST be 0 - 60");
}
this.SanityCheckPrecision(mathPrecision);
}
private void SanityCheck(int degs, double mins, int mathPrecision)
{
int maxDegrees = this.GetMaxDegrees();
int minDegrees = maxDegrees * -1;
if (degs < minDegrees || degs > maxDegrees)
{
throw new ArgumentException(string.Format("Degrees MUST be {0} - {1}", minDegrees, maxDegrees));
}
if (mins < 0d || mins > 60d)
{
throw new ArgumentException("Minutes MUST be 0.0 - 60.0");
}
this.SanityCheckPrecision(mathPrecision);
}
private void SanityCheck(double value, int mathPrecision)
{
double maxValue = (double)this.GetMaxDegrees();
double minValue = maxValue * -1;
if (value < minValue || value > maxValue)
{
throw new ArgumentException(string.Format("Degrees MUST be {0} - {1}", minValue, maxValue));
}
this.SanityCheckPrecision(mathPrecision);
}
private void SanityCheck(string coord, int mathPrecision)
{
this.SanityCheckString(coord);
this.SanityCheckPrecision(mathPrecision);
}
private void SanityCheckString(string coord)
{
if (string.IsNullOrEmpty(coord))
{
throw new ArgumentException("The coordinate string cannot be null/empty.");
}
}
private void SanityCheckPrecision(int mathPrecision)
{
if (mathPrecision < 0 || mathPrecision > 17)
{
throw new ArgumentException("Math precision MUST be 0 - 17");
}
}
Due to the requirements of the class, we need to be able to convert between the Value
, and separate degrees, minutes, and seconds.
private void ValueToDMS(out int degs, out int mins, out double secs)
{
degs = (int)this.Value;
secs = (Math.Abs(this.Value) * 3600) % 3600;
mins = (int)(Math.Abs(secs / 60d));
secs = Math.Round(secs % 60d, 3);
}
private void DMSToValue(int degs, int mins, double secs)
{
double adjuster = (degs < 0) ? -1d : 1d;
this.Value = Math.Round((Math.Abs(degs) + (mins/60d) + (secs/3600d)) * adjuster, this.MathPrecision);
}
Finally, we have to support the creating of a string representation of the coordinate. To that end, we have the ToString
method, as well as some associated helper methods. In order to maintain some form of order, I restricted the results of the ToString
method to a limited number of available format options. They are described in the comments for these methods.
public string ToString(string format)
{
if (string.IsNullOrEmpty(format))
{
format = "DA";
}
string result = string.Empty;
switch (format)
{
case "DA" : case "da" : {
result = this.AppendDirection(this.FormatAsDMS(), format);
}
break;
case "AD" : {
result = this.AppendDirection(this.FormatAsDMS(), format);
}
break;
case "DV" : case "dv" : {
result = this.AppendDirection(string.Format("{0:0.00000}",this.Value), format);
}
break;
case "VD" : {
result = this.AppendDirection(string.Format("{0:0.00000}",this.Value), format);
}
break;
default :
throw new ArgumentException("Invalid GPS coordinate string format");
}
return result;
}
private string FormatAsDMS()
{
string result = string.Empty;
int degs;
int mins;
double secs;
this.ValueToDMS(out degs, out mins, out secs);
result = string.Format("{0}{1} {2}' {3}\"", Math.Abs(degs), GPSMath.DEGREE_SYMBOL, mins, secs);
return result;
}
private string AppendDirection(string coord, string format)
{
string result = string.Empty;
switch (format)
{
case "da" :
case "dv" :
result = string.Concat("-",coord);
break;
case "DA" :
case "DV" :
result = string.Concat(this.Direction.ToString(), coord.Replace("-", ""));
break;
case "AD" :
case "VD" :
result = string.Concat(coord, this.Direction.ToString());
break;
}
return result;
}
The abstract methods are discussed in the following section.
The Latitude and Longitude Classes
Since the inherited class is abstract, we have a few methods we need to override. These methods provide functionality specific to the inheriting class, which means the base class doesn't really need to know anything about whether or not it's a latitude or longitude object.
public class Latitude : LatLongBase
{
public Latitude(int degs, int mins, double secs):base(degs, mins, secs)
{
}
public Latitude(int degs, double mins):base(degs, mins)
{
}
public Latitude(double coord):base(coord)
{
}
public Latitude(string coord):base(coord)
{
}
protected override void SetDirection()
{
this.Direction = (this.Value < 0d) ? CompassPoint.S : CompassPoint.N;
}
protected override string AdjustCoordDirection(string coord)
{
if (coord.StartsWith("S") || coord.EndsWith("S"))
{
coord = string.Concat("-",coord.Replace("S", ""));
}
else
{
coord = coord.Replace("N", "");
}
return coord;
}
protected override int GetMaxDegrees()
{
return 90;
}
}
public class Longitude : LatLongBase
{
public Longitude(int degs, int mins, double secs):base(degs, mins, secs)
{
}
public Longitude(int degs, double mins):base(degs, mins)
{
}
public Longitude(double coord):base (coord)
{
}
public Longitude(string coord):base(coord)
{
}
protected override void SetDirection()
{
this.Direction = (this.Value < 0d) ? CompassPoint.W : CompassPoint.E;
}
protected override string AdjustCoordDirection(string coord)
{
if (coord.StartsWith("W") || coord.EndsWith("W"))
{
coord = string.Concat("-",coord.Replace("W", ""));
}
else
{
coord = coord.Replace("E", "");
}
return coord;
}
protected override int GetMaxDegrees()
{
return 180;
}
}
The GlobalPosition Class
To create a complete lat/long coordinate, I implemented the GlobalPosition
class. Since the LatLongBase
class does a lot of the heavy lifting, the GlobalPosition
class is fairly light-weight in regards to functionality.
First, I defined an enumerator that establishes the ability to have distances returned as either miles of kilometers.
public enum DistanceType { Miles, Kilometers }
public enum CalcType { Haversine, Rhumb }
And then I add a property for both the latitude and the longitude.
public Latitude Latitude { get; set; }
public Longitude Longitude { get; set; }
Next, I implemented three constructor overloads to allow for a reasonable variety of instantiation techniques.
public GlobalPosition(Latitude latitude, Longitude longitude)
{
this.SanityCheck(latitude, longitude);
this.Latitude = latitude;
this.Longitude = longitude;
}
public GlobalPosition(double latitude, double longitude)
{
this.SanityCheck(latitude, longitude);
this.Latitude = new Latitude(latitude);
this.Longitude = new Longitude(longitude);
}
public GlobalPosition(string latlong, char delimiter=',')
{
this.SanityCheck(latlong, delimiter);
string[] parts = latlong.Split(delimiter);
if (parts.Length != 2)
{
throw new ArgumentException("Expecting two fields - a latitude and logitude separated by the specified delimiter.");
}
this.Latitude = new Latitude(parts[0]);
this.Longitude = new Longitude(parts[1]);
}
And I provided a ToString
method that returns the position in the specified format.
public string ToString(string format)
{
return string.Concat(this.Latitude.ToString(format),",",this.Longitude.ToString(format));
}
The entire reason we're here is because I needed to determine the distance between two GPS coordinates. The original version of this code only supported the haversine method for calculating distance, but someone commented that they used a much more accurate method due to project constraints. That started me thinking about code I had found to calculate the rhumb line distance. I originally chose not to include that code because the haversine method resulted in a shorter distance, and for some strange reason (I was probably on a bacon high), I thought that would be the most desireable method by my legions of user. So, I modified the code so that the programmer could use either method. The distance calculation code is now comprised of the three methods shown below. The available methods for calculation is explained in the next section. I also included a static method for calculating the total distance between two or more points (also modified to allow the programer to chose which way to go).
public double DistanceFrom(GlobalPosition thatPos, GlobalPosition.DistanceType distanceType = GlobalPosition.DistanceType.Miles, CalcType calcType = CalcType.Haversine, bool validate=false)
{
return ((calcType == CalcType.Haversine) ? this.HaversineDistanceFrom(thatPos, distanceType, validate) : this.RhumbDistanceFrom(thatPos, distanceType, validate));
}
public double HaversineDistanceFrom(GlobalPosition thatPos, GlobalPosition.DistanceType distanceType = GlobalPosition.DistanceType.Miles, bool validate=false)
{
double thisX;
double thisY;
double thisZ;
this.GetXYZForDistance(out thisX, out thisY, out thisZ);
double thatX;
double thatY;
double thatZ;
thatPos.GetXYZForDistance(out thatX, out thatY, out thatZ);
double diffX = thisX - thatX;
double diffY = thisY - thatY;
double diffZ = thisZ - thatZ;
double arc = Math.Sqrt((diffX * diffX) + (diffY * diffY) + (diffZ * diffZ));
double radius = ((distanceType == DistanceType.Miles) ? GPSMath.AVG_EARTH_RADIUS_MI:GPSMath.AVG_EARTH_RADIUS_KM);
double distance = Math.Round(radius * Math.Asin(arc), 1);
#if DEBUG
if (validate)
{
double reverseDistance = thatPos.HaversineDistanceFrom(this, distanceType, false);
if (distance != reverseDistance)
{
throw new InvalidOperationException("Distance value did not validate.");
}
}
#endif
return distance;
}
public double RhumbDistanceFrom(GlobalPosition thatPos, DistanceType distanceType = GlobalPosition.DistanceType.Miles, bool validate=false)
{
var lat1 = this.Latitude.Radians;
var lat2 = thatPos.Latitude.Radians;
var dLat = GPSMath.DegreeToRadian(Math.Abs(thatPos.Latitude.Value - this.Latitude.Value));
var dLon = GPSMath.DegreeToRadian(Math.Abs(thatPos.Longitude.Value - this.Longitude.Value));
var dPhi = Math.Log(Math.Tan(lat2 / 2 + Math.PI / 4) / Math.Tan(lat1 / 2 + Math.PI / 4));
var q = Math.Cos(lat1);
if (dPhi != 0) q = dLat / dPhi; if (dLon > Math.PI)
{
dLon = 2 * Math.PI - dLon;
}
double radius = ((distanceType == DistanceType.Miles) ? GPSMath.AVG_EARTH_RADIUS_MI:GPSMath.AVG_EARTH_RADIUS_KM);
double distance = Math.Round(Math.Sqrt(dLat * dLat + q * q * dLon * dLon) * radius * 0.5, 1);
#if DEBUG
if (validate)
{
double reverseDistance = thatPos.RhumbDistanceFrom(this, distanceType, false);
if (distance != reverseDistance)
{
throw new InvalidOperationException("Distance value did not validate.");
}
}
#endif
return distance;
}
private void GetXYZForDistance(out double x, out double y, out double z)
{
x = 0.5 * Math.Cos(this.Latitude.Radians) * Math.Sin(this.Longitude.Radians);
y = 0.5 * Math.Cos(this.Latitude.Radians) * Math.Cos(this.Longitude.Radians);
z = 0.5 * Math.Sin(this.Latitude.Radians);
}
public static double TotalDistanceBetweenManyPoints(IEnumerable<globalposition> points, GlobalPosition.DistanceType distanceType = GlobalPosition.DistanceType.Miles, CalcType calcType = CalcType.Haversine)
{
double result = 0d;
if (points.Count() > 1)
{
GlobalPosition pt1 = null;
GlobalPosition pt2 = null;
for (int i = 1; i < points.Count(); i++)
{
pt1 = points.ElementAt(i-1);
pt2 = points.ElementAt(i);
result += pt1.DistanceFrom(pt2, distanceType, calcType);
}
}
return result;
}
Since I was in a GPS frame of mind, I also included a method to calculate the bearing between two points. You may notice the compiler directive at the end of the method. This is the code I used to verify that the bearing is at properly calculated. If you calculate the bearing from thatPos
to this
pos, it should be different by 180 degrees. I included a similar validity check for distance calculations. In the interest of completeness of thought, I also included a BearingFrom method.
public double BearingTo(GlobalPosition thatPos, bool validate=false)
{
double heading = 0d;
double lat1 = GPSMath.DegreeToRadian(this.Latitude.Value);
double lat2 = GPSMath.DegreeToRadian(thatPos.Latitude.Value);
double diffLong = GPSMath.DegreeToRadian((double)((decimal)thatPos.Longitude.Value - (decimal)this.Longitude.Value));
double dPhi = Math.Log(Math.Tan(lat2 * 0.5 + Math.PI / 4) / Math.Tan(lat1 * 0.5 + Math.PI / 4));
if (Math.Abs(diffLong) > Math.PI)
{
diffLong = (diffLong > 0) ? -(2 * Math.PI - diffLong) : (2 * Math.PI + diffLong);
}
double bearing = Math.Atan2(diffLong, dPhi);
heading = Math.Round((GPSMath.RadianToDegree(bearing) + 360) % 360, 0);
#if DEBUG
if (validate)
{
double reverseHeading = thatPos.HeadingTo(this, false);
if (Math.Round(Math.Abs(heading - reverseHeading), 0) != 180d)
{
throw new InvalidOperationException("Heading value did not validate");
}
}
#endif
return heading;
}
public double BearingFrom(GlobalPosition thatPos, bool validate=false)
{
return thatPos.BearingTo(this, validate);
}
Calculating Distance
The distance is calculated as a great-circle route (shortest flying distance, as opposed to driving distance). The actual name of this type of calculation is "haversine formula", and it assumes that the earth is a sphere (it's actually an oblate spheroid, versus a perfect sphere), but this formula gets us close enough for government work. There is a formula available that takes the earth's actual shape into account, which produces a more accurate value, but it's also a more time-consuming approach, and doesn't really benefit us in any real way.
Calculating Bearing
What is a "rhumb line"? From the Maritime Professional[^] web site - A rhumb line is a steady course or line of bearing that appears as a straight line on a Mercator projection chart. Except in special situations, such as when traveling due north or due south or when (at the Equator) traveling due east or due west, sailing a rhumb line is not the shortest distance between two points on the surface of the earth. A more technical definition of the rhumb line is a line on the surface of the earth making the same oblique angle with all meridians.
Why do I use a "rhumb line"? Because it provides a constant value. Using a great circle bearing would only give you the starting bearing for an arc, and that's kinda useless to me.
Usage
The code includes a sample console application that exercises the GlobalPosition
class. There are several examples of usage, including using the ToString
method.
GlobalPosition pos1 = new GlobalPosition(new Latitude(38.950225), new Longitude(-76.947877));
GlobalPosition pos2 = new GlobalPosition(new Latitude(32.834356), new Longitude(-116.997632));
string stringCoord = string.Concat("-116", GPSMath.DEGREE_SYMBOL, " 59' 51.475\"");
GlobalPosition pos3 = new GlobalPosition(string.Concat("38.950225,", stringCoord));
double distance = pos1.DistanceFrom(pos2, GlobalPosition.DistanceType.Miles, GlobalPosition.CalcType.Haversine);
double distance2 = pos1.DistanceFrom(pos2, GlobalPosition.DistanceType.Miles, GlobalPosition.CalcType.Rhumb);
double heading = pos1.HeadingTo(pos2);
double heading2 = pos2.HeadingTo(pos1);
double diff = Math.Round(Math.Abs(heading-heading2),0);
double diff2 = Math.Round(Math.Abs(heading2-heading),0);
string pos1Str = pos1.ToString("DA");
pos1Str = pos1.ToString("AD");
string pos2Str = pos2.ToString("DA");
pos1Str = pos1.ToString("da");
pos1Str = pos1.ToString("DV");
pos1Str = pos1.ToString("VD");
List<GlobalPosition> list = new List<GlobalPosition>();
list.Add(pos1);
list.Add(pos2);
list.Add(pos1);
double bigDistance = GlobalPosition.TotalDistanceBetweenManyPoints(list, GlobalPosition.DistanceType.Miles, GlobalPosition.CalcType.Rhumb);
double bigDistance2 = GlobalPosition.TotalDistanceBetweenManyPoints(list, GlobalPosition.DistanceType.Miles, GlobalPosition.CalcType.Rhumb);
History
-
11 Oct 2016 - Included suport for rhumb line distance calculation (it should ALWAYS be a little larger value than the result from the (existing) haversine calculation. The usage section was updated to illustrate the new feature.
-
10 Oct 2016 - Got rid of some pointless
for
loops in LatLongBase
string constructor overload, and uploaded new code. The change does not affect the way the class works, it simply didn't make sense to use a for
loop, given that we were already inside a switch
statement where the cases are determined by the length of the array resulting from the split string.
-
07 Oct 2016 - Fixed some formatting issues in the code blocks.
-
06 Oct 2016 - Initial publication.