Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ body:
id: framework-version-used
attributes:
label: Targeted .NET Platform
placeholder: .NET6.0, .NET5.0 .NET Core 3.1, .NET Framework 4.7, etc.
placeholder: .NET 9.0, .NET 8.0, .NET 7.0, .NET 6.0, etc.
validations:
required: true

Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ env:
SDK_VERSION_8: '8.0.404'
SDK_VERSION_7: '7.0.410'
SDK_VERSION_6: '6.0.428'
SDK_VERSION_5: '5.0.408'
SDK_VERSION_3: '3.1.426'
COVERAGE_REPORT_DIRECTORY: 'CodeCoverageReports'

# Set up the .NET environment to improve test performance and reliability
Expand Down Expand Up @@ -49,8 +47,6 @@ jobs:
${{ env.SDK_VERSION_8 }}
${{ env.SDK_VERSION_7 }}
${{ env.SDK_VERSION_6 }}
${{ env.SDK_VERSION_5 }}
${{ env.SDK_VERSION_3 }}

- name: "Restore dependencies"
run: dotnet restore
Expand Down
11 changes: 0 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@


- [Support to ](#support-to-)
- [Dependencies ](#dependencies-)
- [How to use ](#how-to-use-)
- [Install NuGet package](#install-nuget-package)
- [Exceptions ](#exceptions-)
Expand Down Expand Up @@ -65,16 +64,6 @@
- .NET 8.0
- .NET 7.0
- .NET 6.0
- .NET 5.0
- .NET 3.1
- .NET Standard 2.1
- .NET Framework 4.6.2 or more



## Dependencies <a name="dependencies"></a>

- Newtonsoft.Json [NuGet](https://www.nuget.org/packages/Newtonsoft.Json/) *(.NET Framework 4.6.2 | .NET Framework 4.8 | .NET Standard 2.1)*



Expand Down
108 changes: 108 additions & 0 deletions docs/GeoDDCoordinate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# GeoDDCoordinate

## Floating-Point Reliability Fix

### Issue Description
The `GeoDDCoordinate` class had a critical floating-point equality reliability issue in its `==` operator implementation. The original code used direct double equality comparison (`==`), which is unreliable due to floating-point precision limitations.

#### Problems Identified:
1. **Direct Equality Comparison**: Using `left.Latitude == right.Latitude` fails for coordinates that should be considered equal but have tiny floating-point precision differences
2. **Calculation Errors**: Coordinates created through mathematical operations could be considered unequal to their mathematically equivalent counterparts
3. **Hash Code Inconsistency**: Hash codes were based on exact floating-point values, leading to inconsistent behavior in collections
4. **Collection Reliability**: `HashSet<GeoDDCoordinate>` and dictionary operations could behave unpredictably

### Solution Implemented

#### 1. Tolerance-Based Equality Comparison
```csharp
/// <summary>
/// Tolerance for floating-point equality comparisons in geographical coordinates.
/// This tolerance of 1e-9 degrees provides approximately 0.1mm precision at the equator,
/// which is ideal for mathematical simulations and high precision GPS applications.
///
/// Tolerance comparison at equator (~111km per degree):
/// - 1e-5 degrees ≈ 1m (Consumer GPS: cars, mobile phones)
/// - 1e-7 degrees ≈ 1cm (High precision: drones, basic surveying)
/// - 1e-9 degrees ≈ 0.1mm (Mathematical precision, high-end GPS/RTK) ✅
/// </summary>
private const double TOLERANCE = 1e-9;

public static bool operator ==(GeoDDCoordinate left, GeoDDCoordinate right)
{
// Handle null comparisons
if (left is null && right is null) return true;
if (left is null || right is null) return false;

// Use tolerance-based comparison for floating-point reliability
return Math.Abs(left.Latitude - right.Latitude) < TOLERANCE
&& Math.Abs(left.Longitude - right.Longitude) < TOLERANCE;
}
```

#### 2. Consistent Hash Code Implementation
```csharp
public override int GetHashCode()
{
// To maintain hash code consistency with tolerance-based equality,
// we use the same tolerance (1e-9) to ensure objects equal within tolerance
// produce the same hash code. This guarantees the equality contract:
// if x.Equals(y) then x.GetHashCode() == y.GetHashCode()
var quantizedLat = Math.Round(Latitude / TOLERANCE) * TOLERANCE;
var quantizedLon = Math.Round(Longitude / TOLERANCE) * TOLERANCE;

// Compatible hash code combination for older frameworks
unchecked
{
int hash = 17;
hash = hash * 23 + quantizedLat.GetHashCode();
hash = hash * 23 + quantizedLon.GetHashCode();
return hash;
}
}
```

### Tolerance Selection
- **Chosen**: `1e-9` degrees (≈ 0.1mm precision at equator)
- **Rationale**:
- Provides sub-millimeter precision suitable for mathematical applications and high-end GPS/RTK systems
- Maintains reliability while offering maximum practical precision for geographical coordinates
- Ideal for scientific simulations, precise surveying, and high-accuracy positioning systems
- Single tolerance value ensures perfect consistency between `Equals()` and `GetHashCode()`

### Tolerance Comparison Table

| Tolerance | Distance at Equator | Use Case |
|-----------|-------------------|----------|
| 1e-5 | ~1m | Consumer GPS (cars, mobile phones) |
| 1e-7 | ~1cm | High precision (drones, basic surveying) |
| 1e-9 | ~0.1mm | **Mathematical precision, high-end GPS/RTK** ✅ |

The selected `1e-9` tolerance provides the highest practical precision for geographical applications, suitable for scientific calculations and professional surveying equipment.

### Testing Strategy

#### Comprehensive Test Suite: `GeoDDCoordinateFloatingPointTests`
- **Calculation Precision Tests**: Verify coordinates from mathematical operations are considered equal
- **Near-Zero Handling**: Test behavior with signed zero and extremely small values
- **Boundary Cases**: Ensure correct behavior at coordinate limits
- **Collection Behavior**: Verify consistent behavior in `HashSet` and other collections
- **Tolerance Validation**: Confirm appropriate tolerance boundaries

#### Test Categories:
1. **Floating-Point Precision Issues**: Tests for tiny calculation differences
2. **Distance Calculation Reliability**: Ensure zero distance for equivalent coordinates
3. **Hash Code Consistency**: Verify hash codes follow equality contract
4. **Collection Behavior**: Test `HashSet` and dictionary operations
5. **Tolerance-Based Equality**: Demonstrate reliability improvements
6. **Edge Cases**: Handle signed zero, boundary values, and extreme cases

### Benefits

#### Reliability Improvements:
- ✅ **Calculation Stability**: Coordinates from calculations now compare correctly
- ✅ **Collection Reliability**: Consistent behavior in `HashSet`, `Dictionary`, etc.
- ✅ **Hash Code Consistency**: Equal objects have equal hash codes (perfect consistency)
- ✅ **Precision Appropriate**: 0.1mm precision suitable for mathematical and high-precision GPS applications
- ✅ **Single Tolerance**: Same tolerance for equality and hashing eliminates contract violations

This fix ensures the `GeoDDCoordinate` class is production-ready for geographical applications requiring reliable coordinate comparison and collection operations, with 0.1mm precision suitable for mathematical simulations and professional high-precision surveying while maintaining perfect consistency between equality and hashing operations.
3 changes: 0 additions & 3 deletions src/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@
###############################


# Code-block preferences
csharp_style_namespace_declarations = block_scoped:suggestion # https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/formatting-rules#csharp_style_namespace_declarations

# Use switch expression (IDE0066)
csharp_style_prefer_switch_expression = false # https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0066

Expand Down
25 changes: 12 additions & 13 deletions src/Constants.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
namespace PowerUtils.Geolocation
namespace PowerUtils.Geolocation;

public static class Constants
{
public static class Constants
{
public const double MAX_LATITUDE = 90;
public const double MIN_LATITUDE = MAX_LATITUDE * -1;
public const double MAX_LATITUDE = 90;
public const double MIN_LATITUDE = MAX_LATITUDE * -1;

public const double MAX_LONGITUDE = 180;
public const double MIN_LONGITUDE = MAX_LONGITUDE * -1;
public const double MAX_LONGITUDE = 180;
public const double MIN_LONGITUDE = MAX_LONGITUDE * -1;


// https://cloud.google.com/blog/products/maps-platform/how-calculate-distances-map-maps-javascript-api
// https://en.wikipedia.org/wiki/Earth_radius
// It is the radius of a spherical Earth
public const double EARTH_RADIUS_KILOMETER = 6371.071;
public const double EARTH_RADIUS_METER = 6371071;
}
// https://cloud.google.com/blog/products/maps-platform/how-calculate-distances-map-maps-javascript-api
// https://en.wikipedia.org/wiki/Earth_radius
// It is the radius of a spherical Earth
public const double EARTH_RADIUS_KILOMETER = 6371.071;
public const double EARTH_RADIUS_METER = 6371071;
}
117 changes: 57 additions & 60 deletions src/ConversionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,84 +3,81 @@
using PowerUtils.Geolocation.Exceptions;
using PowerUtils.Geolocation.Types;

namespace PowerUtils.Geolocation
namespace PowerUtils.Geolocation;

public static class ConversionExtensions
{
public static class ConversionExtensions
/// <summary>
/// Get the geographical orientation from a specific cardinal direction
/// </summary>
/// <param name="cardinalDirection">Cardinal direction</param>
/// <returns>Geographical orientation</returns>
public static GeographicalOrientation GetGeographicalOrientation(this CardinalDirection cardinalDirection)
{
/// <summary>
/// Get the geographical orientation from a specific cardinal direction
/// </summary>
/// <param name="cardinalDirection">Cardinal direction</param>
/// <returns>Geographical orientation</returns>
public static GeographicalOrientation GetGeographicalOrientation(this CardinalDirection cardinalDirection)
if(cardinalDirection == CardinalDirection.North || cardinalDirection == CardinalDirection.South)
{
if(cardinalDirection == CardinalDirection.North || cardinalDirection == CardinalDirection.South)
{
return GeographicalOrientation.Longitude;
}

return GeographicalOrientation.Latitude;
return GeographicalOrientation.Longitude;
}

return GeographicalOrientation.Latitude;
}


/// <summary>
/// Convert degree to radian (PI / 180)
/// </summary>
/// <param name="degree">Degrees</param>
/// <returns>Radian</returns>
public static double ToRadian(this double degree)
=> degree * (Math.PI / 180);

/// <summary>
/// Convert radian to degree (180 / PI)
/// </summary>
/// <param name="radian"></param>
/// <returns>Degree</returns>
public static double ToDegree(this double radian)
=> radian * (180 / Math.PI);
/// <summary>
/// Convert degree to radian (PI / 180)
/// </summary>
/// <param name="degree">Degrees</param>
/// <returns>Radian</returns>
public static double ToRadian(this double degree)
=> degree * (Math.PI / 180);

/// <summary>
/// Convert radian to degree (180 / PI)
/// </summary>
/// <param name="radian"></param>
/// <returns>Degree</returns>
public static double ToDegree(this double radian)
=> radian * (180 / Math.PI);


/// <summary>
/// Convert decimal degree point (string) to decimal degree point (double)
/// </summary>
/// <param name="ddPoint">Decimal degree point (string)</param>
/// <returns>Decimal degree point (double)</returns>
/// <exception cref="ArgumentNullException">The <paramref name="ddPoint">ddPoint</paramref> parameter is null.</exception>
/// <exception cref="InvalidCoordinateException">The <paramref name="ddPoint">ddPoint</paramref> is not formatted correctly.</exception>
public static double ToDDPoint(this string ddPoint)

private static readonly char[] _splitChars = new char[] { '.', ',' };
/// <summary>
/// Convert decimal degree point (string) to decimal degree point (double)
/// </summary>
/// <param name="ddPoint">Decimal degree point (string)</param>
/// <returns>Decimal degree point (double)</returns>
/// <exception cref="ArgumentNullException">The <paramref name="ddPoint">ddPoint</paramref> parameter is null.</exception>
/// <exception cref="InvalidCoordinateException">The <paramref name="ddPoint">ddPoint</paramref> is not formatted correctly.</exception>
public static double ToDDPoint(this string ddPoint)
{
if(ddPoint == null)
{
if(ddPoint == null)
{
throw new ArgumentNullException(nameof(ddPoint), "The value cannot be null");
}
throw new ArgumentNullException(nameof(ddPoint), "The value cannot be null");
}

var aux = ddPoint.Split(new char[] { '.', ',' });
var aux = ddPoint.Split(_splitChars);

try
try
{
if(aux.Length == 1)
{
if(aux.Length == 1)
{
return double.Parse(aux[0].Replace(" ", ""));
}

if(aux.Length == 2)
{
var sb = new StringBuilder();
sb.Append(aux[0].Replace(" ", ""));
sb.Append(System.Threading.Thread.CurrentThread.CurrentCulture.NumberFormat.NumberDecimalSeparator);
sb.Append(aux[1]);

return double.Parse(sb.ToString());
}

throw new InvalidCoordinateException(ddPoint);
return double.Parse(aux[0]);
}
catch

if(aux.Length == 2)
{
throw new InvalidCoordinateException(ddPoint);
var sb = new StringBuilder();
sb.Append(aux[0]);
sb.Append(System.Threading.Thread.CurrentThread.CurrentCulture.NumberFormat.NumberDecimalSeparator);
sb.Append(aux[1]);

return double.Parse(sb.ToString());
}
}
catch { }

Check warning on line 79 in src/ConversionExtensions.cs

View workflow job for this annotation

GitHub Actions / Sonar Scanner and Mutation Tests

Either remove or fill this block of code. (https://rules.sonarsource.com/csharp/RSPEC-108)

Check warning on line 79 in src/ConversionExtensions.cs

View workflow job for this annotation

GitHub Actions / Sonar Scanner and Mutation Tests

Handle the exception or explain in a comment why it can be ignored. (https://rules.sonarsource.com/csharp/RSPEC-2486)

throw new InvalidCoordinateException(ddPoint);
}
}
34 changes: 10 additions & 24 deletions src/Exceptions/CoordinateException.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,14 @@
using System;
using System.Runtime.Serialization;

namespace PowerUtils.Geolocation.Exceptions
{
[Serializable]
public abstract class CoordinateException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="CoordinateException"></see> class with a specified error message.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
protected CoordinateException(string message)
: base(message)
{ }
namespace PowerUtils.Geolocation.Exceptions;

/// <summary>
/// Initializes a new instance of the exception class with serialized data.
/// </summary>
/// <param name="info">The <see cref="SerializationInfo"></see> that holds the serialized object data about the exception being thrown.</param>
/// <param name="context">The <see cref="StreamingContext"></see> that contains contextual information about the source or destination.</param>
/// <exception cref="ArgumentNullException">The <paramref name="info">info</paramref> parameter is null.</exception>
/// <exception cref="SerializationException">The class name is null or <see cref="P:System.Exception.HResult"></see> is zero (0).</exception>
protected CoordinateException(SerializationInfo info, StreamingContext context)
: base(info, context)
{ }
}
public abstract class CoordinateException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="CoordinateException"></see> class with a specified error message.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
protected CoordinateException(string message)
: base(message)
{ }
}
Loading
Loading