Click here to Skip to main content
15,880,956 members
Articles / Web Development / HTML

A Handy GPS Class

Rate me:
Please Sign up or sign in to vote.
4.91/5 (32 votes)
11 Oct 2016CPOL6 min read 33.3K   895   55   13
A GPS class with a coordinate parser, distance calculation, and bearing calculation.

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.

C#
public enum CompassPoint { N,W,E,S }

Next come the properties.

C#
/// <summary>
/// Get/set the precision to be applied to calculated values (mostly dealing withe the Value 
/// property)
/// </summary>
public int          MathPrecision  { get; set; }
/// <summary>
/// Get/set the actual value of this coordintae part. This value is used to determine 
/// degrees/minutes/seconds when they're needed.
/// </summary>
public double       Value          { get; set; }
/// <summary>
/// The compass point represented by this coordinate part
/// </summary>
public CompassPoint Direction      { get; set; }
/// <summary>
/// Gets the radians represented by the Value
/// </summary>
public double Radians 
{ 
    get { return Math.Round(GPSMath.DegreeToRadian(this.Value), this.MathPrecision); } 
}
/// <summary>
/// Gets the degrees rpresented by the Value.
/// </summary>
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.

C#
/// <summary>
/// Creates an instance of this object using the specified degrees, minutes, and seconds
/// </summary>
/// <param name="degs">The degrees (can be a negative number, representing a west or south 
/// coordinate). The min/max value is determined by whether this is a longitude or latitude 
/// value.</param>
/// <param name="mins">The minutes. Value must be 0-60.</param>
/// <param name="secs">TYhe seconds. Value must be 0d-60d.</param>
/// <param name="mathPrecision">Precision used for math calculations.</param>
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();
}
/// <summary>
/// Creates an instance of this object using the specified degrees and minutes
/// </summary>
/// <param name="degs">The degrees (can be a negative number, representing a west or south 
/// coordinate). The min/max value is determined by whether this is a longitude or latitude 
/// value.</param>
/// <param name="mins">The minutes. Value must be 0d-60d.</param>
/// <param name="mathPrecision">Precision used for math calculations.</param>
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();
}
/// <summary>
/// Creates and instance of this object with the specified value
/// </summary>
/// <param name="value">The value (can be a negative number, representing a west or south 
/// coordinate). The min/max value is determined by whether this is a longitude or latitude 
/// value.</param>
/// <param name="mathPrecision">Precision used for math calculations.</param>
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?

C#
/// <summary>
/// Convert the specified string to a coordinate component. 
/// </summary>
/// <param name="coord">The coordinate as a string. Can be in one of the following formats 
/// (where X is the appropriate compass point and the minus is used to indicate W or S 
/// compass points when appropriate):
///     - [X][-]dd mm ss.s 
///     - [X][-]dd* mm' ss.s" 
///     - [X][-]dd mm.m (degrees minutes.percentage of minute)
///     - [X][-]dd.d (degrees)
/// </param>
/// <param name="mathPrecision">Precision used for math calculations.</param>
public LatLongBase(string coord, int mathPrecision = 6)
{
    // 1st sanity check - make sure the string isn't empty
    this.SanityCheck(coord, mathPrecision);
    this.MathPrecision = mathPrecision;
    // Convert compass points to their appropriate sign - for easier manipulation, we remove 
    // the compass points and if necessary, replace with a minus sign to indicate the 
    // appropriate direction.
    coord = coord.ToUpper();
    coord = this.AdjustCoordDirection(coord);
    // Get rid of the expected segment markers (degree, minute, and second symbols) and 
    // trim off any whitespace.
    coord = coord.Replace("\"", "").Replace("'", "").Replace(GPSMath.DEGREE_SYMBOL, "").Trim();
    // 2nd sanity check - Now that we've stripped all the unwanted stuff from the string, 
    // let's make sure we still have a string with content.
    this.SanityCheckString(coord);
    // split the string at space characters
    string[] parts = coord.Split(' ');
    bool     valid = false;
    int      degs  = 0;
    int      mins  = 0;
    double   secs  = 0d;
    // depending on how many "parts" there are in the string, we try to parse the value(s).
    switch (parts.Length)
    {
        case 1 :
            {
                // Assume that the part is a double value. that can merely be parsed and 
                // assigned to the Value property.
                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 :
            {
                // Assume that the parts are "degrees minutes". 
                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
                {
                    // if the values parsed as expected, we need to separate the minutes from the seconds.
                    mins = (int)(Math.Floor(minsTemp));
                    secs = Math.Round(60d * (minsTemp - mins), 3);
                    this.SanityCheck(degs, mins, secs, 3);
                }
            }
            break;
        case 3 :
            {
                // Assume that the parts are "degrees minutes seconds". 
                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 everything is valid and we had more than one parameter, convert the parsed 
    // degrees, minutes, and seconds, and assign the result to the Value property, and 
    // finally, set the compass point.
    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.

C#
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)
{
    // You can have a maximum of 17 digits to the right of the decimal point in a double 
    // value, but when you do ANY math on the value, the 17th digit may or may not reflect 
    // the actual value (research math and equality on doubles for more info). For this 
    // reason, I recommend using a precision value of nothing higher than 16. The default 
    // value is 6.
    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.

C#
/// <summary>
/// Converts the current Value to degrees/minutes/seconds, and returns those calculated 
/// values via the "out" properties.
/// </summary>
/// <param name="degs">The calculated degrees.</param>
/// <param name="mins">The calculated minutes.</param>
/// <param name="secs">The calculated seconds.</param>
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);
}
/// <summary>
/// Converts the specified degrees/minutes/seconds to a single value, and sets the Value 
/// property.
/// </summary>
/// <param name="degs">The degrees</param>
/// <param name="mins">The minutes</param>
/// <param name="secs">The seconds</param>
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.

C#
/// <summary>
/// Returns the Value of this object as a GPS coordinate part.
/// </summary>
/// <param name="format"></param>
/// <returns></returns>
/// <remarks>
/// Valid format string values (anything else will generate an exception). If a null/empty 
/// string is specified, the "DA" format will be used.
///     - DA = "N0* 0' 0"", where N indicates the appropriate direction at the BEGINNING of the string
///     - da = "-0* 0' 0"", where "-" is prepended if the coordinate part is either west or south
///     - AD = "0* 0' 0"N", where N indicates the appropriate direction at the END of the string
///     - DV = "N0.00000", where N indicates the appropriate direction at the BEGINNING of the string
///     - dv = "-0.00000", where "-" is prepended if the coordinate part is either west or south
///     - VD = "0.00000N", where N indicates the appropriate direction at the END of the string
/// </remarks>
public string ToString(string format)
{
    if (string.IsNullOrEmpty(format))
    {
        format = "DA";
    }
    string result = string.Empty;
    switch (format)
    {
        case "DA" : // "N0* 0' 0"" where N indicates the appropriate direction at the BEGINNING of the string
        case "da" : // "-0* 0' 0"", where "-" is prepended if the coordinate part is either west or south
            {
                result = this.AppendDirection(this.FormatAsDMS(), format);
            }
            break;
        case "AD" : // "0* 0' 0"N", where N indicates the appropriate direction at the END of the string
            {
                result = this.AppendDirection(this.FormatAsDMS(), format);
            }
            break;
        case "DV" : // "N0.00000", where N indicates the appropriate direction at the BEGINNING of the string
        case "dv" : // "-0.00000", where "-" is prepended if the coordinate part is either west or south
            {
                result = this.AppendDirection(string.Format("{0:0.00000}",this.Value), format);
            }
            break;
        case "VD" : // "0.00000N", where N indicates the appropriate direction at the END of the string
            {
                result = this.AppendDirection(string.Format("{0:0.00000}",this.Value), format);
            }
            break;
        default :
            throw new ArgumentException("Invalid GPS coordinate string format");
    }
    return result;
}
/// <summary>
/// Formats a string using the degrees, minutes and seconds of the coordinate.
/// </summary>
/// <returns>A string formatted as "0* 0' 0"".</returns>
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;
}
/// <summary>
/// Appends either the compass point, or a minus symbol (if appropriate, and indicated by the specified format).
/// </summary>
/// <param name="coord">The coordinate string</param>
/// <param name="format">The format indicator</param>
/// <returns>The adjusted coordinate string</returns>
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.

C#
/// <summary>
/// Represents a latitude position
/// </summary>
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)
    {
    }
    /// <summary>
    /// Sets the directionType for this object based on whether this object is a latitude  
    /// or a longitude, and the Value of the coordinate.
    /// </summary>
    protected override void SetDirection()
    {
        this.Direction = (this.Value < 0d) ? CompassPoint.S : CompassPoint.N;
    }
    /// <summary>
    /// Adjusts the direction based on whether this is a latitude or longitude
    /// </summary>
    /// <param name="coord">The string coordinate</param>
    /// <returns>The adjusted coordinate string</returns>
    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;
    }
    /// <summary>
    /// Gets the maximum value of the degrees based on whether or not this is a latitude  
    /// or longitude.
    /// </summary>
    /// <returns>The maximum allowed degrees.</returns>
    protected override int GetMaxDegrees()
    {
        return 90;
    }
}
/// <summary>
/// Represents a longitude position.
/// </summary>
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)
    {
    }
    /// <summary>
    /// Sets the directionType for this object based on whether this object is a latitude  
    /// or a longitude, and the Value of the coordinate.
    /// </summary>
    protected override void SetDirection()
    {
        this.Direction = (this.Value < 0d) ? CompassPoint.W : CompassPoint.E;
    }
    /// <summary>
    /// Adjusts the direction based on whether this is a latitude or longitude
    /// </summary>
    /// <param name="coord">The string coordinate</param>
    /// <returns>The adjusted coordinate string</returns>
    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;
    }
    /// <summary>
    /// Gets the maximum value of the degrees based on whether or not this is a latitude  
    /// or longitude.
    /// </summary>
    /// <returns>The maximum allowed degrees.</returns>
    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.

C#
public enum DistanceType { Miles, Kilometers }
public enum CalcType { Haversine, Rhumb }

And then I add a property for both the latitude and the longitude.

C#
/// <summary>
/// Get/set the latitude for this position
/// </summary>
public Latitude     Latitude        { get; set; }
/// <summary>
/// Get/set the longitude for this position
/// </summary>
public Longitude    Longitude       { get; set; }

Next, I implemented three constructor overloads to allow for a reasonable variety of instantiation techniques.

C#
/// <summary>
/// Create an instance of this object, using a Latitude object parameter, and a 
/// Longitude object parameter.
/// </summary>
/// <param name="latitude">An instantiated Latitude object.</param>
/// <param name="longitude">An instantiated Longitude object.</param>
public GlobalPosition(Latitude latitude, Longitude longitude)
{
    this.SanityCheck(latitude, longitude);
    this.Latitude = latitude;
    this.Longitude = longitude;
}
/// <summary>
/// Create an instance of this object, using a latitude value parameter, and a longitude 
/// value parameter.
/// </summary>
/// <param name="latitude">A valud indicating the latitude of the coordinate.</param>
/// <param name="longitude">A value indicating the longitude of the coordinate.</param>
public GlobalPosition(double latitude, double longitude)
{
    this.SanityCheck(latitude, longitude);
    this.Latitude = new Latitude(latitude);
    this.Longitude = new Longitude(longitude);
}
/// <summary>
/// Create an instance of this object with a string that represents some form of latitude 
/// AND longitude, using the specified delimiter to parse the string.
/// </summary>
/// <param name="latlong">The lat/long string. Each part must be a valid coordinate part. 
/// See the LatLongBase class for more information.</param>
/// <param name="delimiter">The delimiter used to separate the coordinate parts. Default 
/// value is a comma.</param>
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.");
    }
    // The LatLongBase class takes care of sanity checks for the specified part elements, 
    // so all we need to do is try to creat instances of them.
    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.

C#
/// <summary>
/// Formats the coordinate parts using the specified format string.
/// </summary>
/// <param name="format">The format string</param>
/// <returns>The combined coordinate parts.</returns>
/// <remarks>
/// Valid format string values (anything else will generate an exception). If a null/empty 
/// string is specified, the "DA" format will be used.
///     - DA = "N0* 0' 0", where N indicates the appropriate direction at the BEGINNING of the string
///     - da = "-0* 0' 0", where - is prepended if the coordinate part is either west or south
///     - AD = "0* 0' 0"N, where N indicates the appropriate direction at the END of the string
///     - DV = "N0.00000", where N indicates the appropriate direction at the BEGINNING of the string
///     - dv = "-0.00000", where - is prepended if the coordinate part is either west or south
///     - VD = "0.00000N", where N indicates the appropriate direction at the END of the string
/// </remarks>
public string ToString(string format)
{
    // Format string validation is performed in the Latitude and Longitude objects. An 
    // exception will be thrown if the specified format is not valid.
    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).

C#
/// <summary>
/// Calculates the distance from this GPS position to the specified position.
/// </summary>
/// <param name="thatPos">The position to which we are calculating the bearing.</param>
/// <param name="distanceType">The type of measurement (miles or kilometers)</param>
/// <param name="calcType">The type of distance calculation to use (default is haversine). 
/// Rhumb will result in a higher value, while haversine (great circle) is shortest distance)
/// </param>
/// <param name="validate">Determines if the math is sound by calculating the distance in 
/// both directions. Default value is false, and the value is only used when the application 
/// is compiled in debug mode.</param>
/// <returns>The distance between this position and the specified position, of the specified 
/// distance type (miles or kilometers)</returns>
/// <remarks>Validate is only active if the solution is compiled in debug mode, and consists 
/// of ensuring that the recipricol bearing varies by 180 degrees. If it does not, an 
/// InvalidOperationException is thrown.</remarks>
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));
}

/// <summary>
/// Calculates the great circle (shortest) distance from this GPS position to the specified position.
/// </summary>
/// <param name="thatPos">The position to which we are calculating the bearing.</param>
/// <param name="distanceType">The type of measurement (miles or kilometers)</param>
/// <param name="validate">Determines if the math is sound by calculating the distance in 
/// both directions. Default value is false, and the value is only used when the application 
/// is compiled in debug mode.</param>
/// <returns>The distance between this position and the specified position, of the specified 
/// distance type (miles or kilometers)</returns>
/// <remarks>Validate is only active if the solution is compiled in debug mode, and consists 
/// of ensuring that the recipricol bearing varies by 180 degrees. If it does not, an 
/// InvalidOperationException is thrown.</remarks>
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;
}

/// <summary>
/// Calculate the distance of a rhumb line between the two points. This will generally be a 
/// longer distance than the haversize distance calculated in the other DistanceFrom method.
/// </summary>
/// <param name="thatPos">The position to which we are calculating the bearing.</param>
/// <param name="distanceType">The type of measurement (miles or kilometers)</param>
/// <returns>The distance between this position and the specified position, of the specified 
/// distance type (miles or kilometers)</returns>
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;  // E-W line gives dPhi=0
    // if dLon over 180° take shorter rhumb across 180° meridian:
    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;
}

/// <summary>
/// Math function for distance calculation.
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <param name="z"></param>
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);
}

/// <summary>
/// A static method to calcculate the distance between two or more points.
/// </summary>
/// <param name="points">The collection of points to calculate with (there must be at 
/// least two points</param>
/// <param name="distanceType">Miles or kilometers</param>
/// <param name="calcType">Rhumb or haversine. Rhumb will result in a higher value, 
/// while haversine (great circle) is shortest distance)</param>
/// <returns>Zero if there are fewer that two points, or the total distance between all 
/// points.</returns>
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.

C#
/// <summary>
/// Creates a Rhumb bearing from this GPS position to the specified position.
/// </summary>
/// <param name="thatPos">The position to which we are calculating the bearing.</param>
/// <param name="validate">Determines if the math is sound by calculating the bearing in 
/// both directions and verifying that the difference is 180 degrees. Default value is 
/// false, and the value is only used when the application is compiled in debug mode.</param>
/// <returns>The bearing value in degrees, rounded to the nearest whole number.</returns>
/// <remarks>Validate is only active if the solution is compiled in debug mode, and consists 
/// of ensuring that the recipricol bearing varies by 180 degrees. If it does not, an 
/// InvalidOperationException is thrown.</remarks>
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;
}
/// <summary>
/// Creates a Rhumb bearing from the specified position to this GPS position.
/// </summary>
/// <param name="thatPos">The position from which we are calculating the bearing.</param>
/// <param name="validate">Determines if the math is sound by calculating the bearing in 
/// both directions and verifying that the difference is 180 degrees. Default value is 
/// false, and the value is only used when the application is compiled in debug mode.</param>
/// <returns>The bearing value in degrees, rounded to the nearest whole number.</returns>
/// <remarks>Validate is only active if the solution is compiled in debug mode, and consists 
/// of ensuring that the recipricol bearing varies by 180 degrees. If it does not, an 
/// InvalidOperationException is thrown.</remarks>
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.

C#
// instantiate some sample objects
GlobalPosition pos1 = new GlobalPosition(new Latitude(38.950225), new Longitude(-76.947877));
GlobalPosition pos2 = new GlobalPosition(new Latitude(32.834356), new Longitude(-116.997632));
// test the string constructor
string stringCoord  = string.Concat("-116", GPSMath.DEGREE_SYMBOL, " 59' 51.475\"");
GlobalPosition pos3 = new GlobalPosition(string.Concat("38.950225,", stringCoord));
// set a breakpoint on the next line, and you can inspect the values as they are set.
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.
     

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior) Paddedwall Software
United States United States
I've been paid as a programmer since 1982 with experience in Pascal, and C++ (both self-taught), and began writing Windows programs in 1991 using Visual C++ and MFC. In the 2nd half of 2007, I started writing C# Windows Forms and ASP.Net applications, and have since done WPF, Silverlight, WCF, web services, and Windows services.

My weakest point is that my moments of clarity are too brief to hold a meaningful conversation that requires more than 30 seconds to complete. Thankfully, grunts of agreement are all that is required to conduct most discussions without committing to any particular belief system.

Comments and Discussions

 
QuestionDistance calcs Pin
#realJSOP27-Nov-17 3:09
mve#realJSOP27-Nov-17 3:09 
QuestionDiscovery Pin
#realJSOP26-Nov-17 4:29
mve#realJSOP26-Nov-17 4:29 
GeneralMy vote of 5 Pin
User 991608017-Feb-17 4:12
professionalUser 991608017-Feb-17 4:12 
SuggestionCultureInfo Pin
sc2p13-Oct-16 3:19
sc2p13-Oct-16 3:19 
GeneralRe: CultureInfo Pin
#realJSOP18-Oct-16 1:58
mve#realJSOP18-Oct-16 1:58 
GeneralRe: CultureInfo Pin
sc2p19-Oct-16 7:56
sc2p19-Oct-16 7:56 
GeneralRe: CultureInfo Pin
#realJSOP21-Nov-21 3:25
mve#realJSOP21-Nov-21 3:25 
PraiseMy vote of 5 Pin
Dennis Dykstra11-Oct-16 9:52
Dennis Dykstra11-Oct-16 9:52 
AnswerRe: My vote of 5 Pin
#realJSOP12-Oct-16 5:44
mve#realJSOP12-Oct-16 5:44 
PraiseDistance methods Pin
Graham Cottle11-Oct-16 3:18
professionalGraham Cottle11-Oct-16 3:18 
AnswerRe: Distance methods Pin
#realJSOP11-Oct-16 23:54
mve#realJSOP11-Oct-16 23:54 
QuestionHarrumph Pin
#realJSOP7-Oct-16 4:03
mve#realJSOP7-Oct-16 4:03 
AnswerRe: Harrumph Pin
#realJSOP10-Oct-16 4:04
mve#realJSOP10-Oct-16 4:04 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.