一个方便的 GPS 类
一个 GPS 类,包含坐标解析器、距离计算和方位角计算。
引言
你是否曾经想过为什么 Microsoft 没有在 DotNet 中包含某种 GPS 类?你知道,能解析/操作 GPS 坐标,或许还能提供计算两点之间距离和/或方位角的方法?我最近正好需要这个功能,于是就有了下面的代码。这是我所需功能的一个简单实现。我不需要在地图上绘制数据,我只需要能够确定两点之间的方位角和距离。整个过程中最重要的一部分是如何解析可能的坐标。
代码
代码包含四个文件,可以轻松地编译成一个程序集,或者直接放入你自己的项目中的现有程序集中。
LatLongBase 类
这个类负责 GPS 坐标的所有解析工作。有两个类派生自这个类——Latitude(纬度)和 Longitude(经度),它们仅用于标识坐标的哪个部分已被表示(接下来的部分将进行简要描述)。由于我们不希望这个类独立实例化,所以它是抽象的,只有纬度/经度特有的方法才包含在继承类中。
首先,我定义了一个枚举来表示指南针的四个基点。
public enum CompassPoint { N,W,E,S }
接下来是属性。
/// <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); }
}
接下来,我实现了几个构造函数重载,这些重载接受各种合理的坐标格式。基本思路是,每个构造函数在调用自身的 SanityCheck
方法以捕获潜在问题后,负责确定参数的有效性。前三个重载非常简单,因为我们处理的只是数值。
/// <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();
}
然而,最后一个接受字符串格式坐标的重载则变得更有趣了。正如你可能预料到的,由于允许的格式多种多样,需要大量的工作。真是个难题,对吧?
/// <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();
}
}
SanityCheck
方法用于验证构造函数参数,并在必要时抛出异常。有许多 SanityCheck
重载来处理所需的验证。这里发生的事情应该很清楚,所以没有代码注释。你可能会注意到的一个地方是,有几个重载支持基于字符串的类构造函数。
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");
}
}
根据类的要求,我们需要能够在 Value
和单独的度、分、秒之间进行转换。
/// <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);
}
最后,我们必须支持创建坐标的字符串表示。为此,我们有 ToString
方法以及一些相关的辅助方法。为了保持某种形式的顺序,我将 ToString
方法的结果限制在有限的可用格式选项中。它们在这些方法的注释中有描述。
/// <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;
}
抽象方法将在下一节中讨论。
Latitude 和 Longitude 类
由于继承的类是抽象的,我们需要重写几个方法。这些方法提供特定于继承类的功能,这意味着基类实际上不需要知道它是一个纬度还是经度对象。
/// <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;
}
}
GlobalPosition 类
为了创建一个完整的纬度/经度坐标,我实现了 GlobalPosition
类。由于 LatLongBase
类做了很多繁重的工作,GlobalPosition
类在功能方面相当轻量级。
首先,我定义了一个枚举,该枚举能够将距离返回为英里或公里。
public enum DistanceType { Miles, Kilometers }
public enum CalcType { Haversine, Rhumb }
然后,我为纬度和经度各添加了一个属性。
/// <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; }
接下来,我实现了三个构造函数重载,以允许各种合理的实例化技术。
/// <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]);
}
我还提供了一个 ToString
方法,该方法以指定格式返回位置。
/// <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));
}
我们之所以在这里,完全是因为我需要确定两个 GPS 坐标之间的距离。该代码的原始版本仅支持使用半正矢公式计算距离,但有人评论说由于项目限制,他们使用了更准确的方法。这让我想起了我找到的计算恒向线距离的代码。我最初选择不包含那段代码,因为半正矢公式计算出的距离较短,出于某种奇怪的原因(我可能处于高烟熏状态),我以为我的大量用户最想要的就是这种方法。所以,我修改了代码,以便程序员可以使用任一方法。距离计算代码现在由下面的三个方法组成。计算方法的说明在下一节中。我还包含了一个静态方法,用于计算两个或多个点之间的总距离(也进行了修改,允许程序员选择计算方式)。
/// <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;
}
既然我在考虑 GPS,我还包含了一个计算两点之间方位角的方法。你可能会注意到方法末尾的编译器指令。这是我用来验证方位角是否正确计算的代码。如果你计算从 thatPos
到 this
pos 的方位角,它应该相差 180 度。我也为距离计算包含了类似的有效性检查。为了思想上的完整性,我还包含了一个 BearingFrom 方法。
/// <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);
}
计算距离
距离是作为大圆航线(最短飞行距离,而不是驾驶距离)计算的。这种计算的实际名称是“半正矢公式”,它假设地球是球体(实际上是一个扁球体,而不是完美的球体),但这个公式已经足够接近实际情况了。有一个公式可以考虑到地球的实际形状,它会产生更准确的值,但它也更耗时,并且对我们来说并没有真正的实际益处。
计算方位角
什么是“恒向线”?来自 Maritime Professional[^] 网站——恒向线是航向上保持不变的航线或方位线,在麦卡托投影图上显示为直线。除了在特殊情况下,例如向正北或正南航行,或(在赤道上)向正东或正西航行时,沿着恒向线航行并不是地球表面两点之间的最短距离。恒向线的更技术性的定义是地球表面上与所有子午线成相同斜角的线。
为什么我使用“恒向线”?因为它提供了恒定的值。使用大圆航线只能给出弧线的起始方位角,对我来说这有点没用。
用法
代码包含一个示例控制台应用程序,用于测试 GlobalPosition
类。其中有几个使用示例,包括使用 ToString
方法。
// 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);
历史
-
2016 年 10 月 11 日 - 添加了对恒向线距离计算的支持(它计算出的值应该总是略大于(现有)半正矢计算结果。使用部分已更新以说明新功能。
-
2016 年 10 月 10 日 - 删除了
LatLongBase
字符串构造函数重载中一些无意义的for
循环,并上传了新代码。此更改不影响类的功能,只是鉴于我们已经在switch
语句中,而 case 由拆分字符串产生的数组长度确定,所以使用for
循环没有意义。
-
2016 年 10 月 7 日 - 修复了代码块中的一些格式问题。
-
2016 年 10 月 6 日 - 首次发布。