diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 67d6a47..ea24b72 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,31 +16,31 @@ jobs: strategy: matrix: - dotnet: [ '8.0.x' ] + dotnet: [ '10.0.x' ] name: .NET ${{ matrix.dotnet }} steps: - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v6 with: - java-version: 17 + java-version: 25 distribution: 'zulu' # Alternative distribution options are available. - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ matrix.dotnet }} - name: Cache SonarQube Cloud packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~\sonar\cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Cache SonarQube Cloud scanner id: cache-sonar-scanner - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .\.sonar\scanner key: ${{ runner.os }}-sonar-scanner @@ -57,11 +57,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: .\.sonar\scanner\dotnet-sonarscanner begin /k:"TensionDev_CoordinateSystems" /o:"tensiondev" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.scanner.scanAll=false /d:sonar.cs.opencover.reportsPaths=**/coverage.opencover.xml + run: .\.sonar\scanner\dotnet-sonarscanner begin /k:"TensionDev_CoordinateSystems" /o:"tensiondev" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.scanner.scanAll=false /d:sonar.cs.opencover.reportsPaths=**/coverage.opencover.*.xml - name: Build run: dotnet build --no-restore - name: Test - run: dotnet test --no-build --verbosity normal --collect "XPlat Code Coverage;Format=opencover" + run: dotnet run --project TensionDev.CoordinateSystems.Tests/TensionDev.CoordinateSystems.Tests.csproj --no-build -- --coverlet --coverlet-output-format opencover - name: SonarCloudAnalyze env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any @@ -72,7 +72,7 @@ jobs: strategy: matrix: - dotnet: [ '8.0.x' ] + dotnet: [ '10.0.x' ] os: [macos-latest, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} @@ -80,11 +80,11 @@ jobs: name: .NET ${{ matrix.dotnet }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ matrix.dotnet }} - name: Restore dependencies @@ -92,4 +92,4 @@ jobs: - name: Build run: dotnet build --no-restore - name: Test - run: dotnet test --no-build --verbosity normal --collect "XPlat Code Coverage;Format=opencover" + run: dotnet run --project TensionDev.CoordinateSystems.Tests/TensionDev.CoordinateSystems.Tests.csproj --no-build -- --coverlet --coverlet-output-format opencover diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index fd2907c..1f4dd08 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -26,10 +26,10 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v4 - - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 + - name: Setup .NET 10 + uses: actions/setup-dotnet@v6 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x source-url: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json env: NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a624f3..e84e7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added `GeographicDd` as a decimal-degree coordinate representation. +- Added standard `Parse` and `TryParse` methods for `GeographicCoordinateSystem`, `GeographicDd`, `GeocentricCoordinateSystem`, `GeographicDdm`, and `GeographicDms`, implementing ADR-0008's representation-specific formats and validation. +- Added parsing for optional altitude suffixes, invariant-culture numeric values, and explicitly marked radians; bare geographic numeric pairs are decimal degrees. +- Added conversions in both directions between every distinct pair of `GeographicCoordinateSystem`, `GeographicDd`, `GeographicDdm`, and `GeographicDms`. +- Added metre-based altitude properties and optional altitude parsing to decimal-degree, DDM, and DMS representations. + +### Changed +- Made radians the canonical angular unit inside `GeographicCoordinateSystem`; its degree properties remain available as explicit conversions. +- Defined bare numeric latitude/longitude pairs as decimal degrees. Radian strings now require the `rad` marker. +- Geographic parsing now rejects invalid and out-of-range coordinates instead of silently changing their values. + ## [v0.4.0] - 2026-09-20 [v0.4.0](https://github.com/TensionDev/CoordinateSystems/releases/tag/v0.4.0) diff --git a/README.md b/README.md index 55c6e01..b7ad712 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,23 @@ Implementation references identify sources used when implementing coordinate-sys The library currently provides coordinate representations including: -- `GeographicCoordinateSystem` -- `GeocentricCoordinateSystem` -- `Geohash` +- `GeographicCoordinateSystem` — the canonical geographic model. Latitude and longitude are stored in radians; altitude is stored in metres. Degree accessors are available for convenience. +- `GeographicDd` — latitude and longitude in decimal degrees, with altitude in metres. +- `GeographicDdm` and `GeographicDms` — degree-minute and degree-minute-second representations, with altitude in metres. +- `GeocentricCoordinateSystem` — Earth-centred Cartesian coordinates in metres (with feet accessors). +- `Geohash` — a compact textual representation of a geographic location. -Conversion support is added independently from the coordinate model types. +`GeographicConverter` converts between every distinct pair of the four geographic representations. Conversions preserve altitude in metres and preserve the represented position within floating-point precision. + +### Geographic Units and Text Parsing + +`GeographicCoordinateSystem.LatitudeDecimalRadians` and `LongitudeDecimalRadians` are the canonical angular properties. Use `LatitudeDecimalDegrees` and `LongitudeDecimalDegrees` when working with decimal degrees, or use `GeographicDd` to make that representation explicit. All geographic models store altitude in metres; feet properties are convenience conversions. + +`GeographicCoordinateSystem.Parse` accepts decimal degrees, DDM, DMS, and radians marked explicitly with `rad`. A bare numeric pair such as `"52.52, 13.405"` always means decimal degrees. Radians must include the unit marker, for example `"0.9163 rad, 0.2338 rad"`. `GeographicDd`, `GeographicDdm`, and `GeographicDms` each parse only their own representation through `Parse` and `TryParse`. + +Parsing uses invariant culture and accepts an optional altitude suffix, such as `"Altitude: 100 m"` or `"Altitude: 328.084 ft"`. Parsed feet are converted to metres internally. + +See [ADR-0008](docs/adr/ADR-0008-string-parsing-for-coordinate-systems.md) for parsing rules and [ADR-0009](docs/adr/ADR-0009-si-based-geographic-model-and-decimal-degree-representation.md) for model units and conversions. ## Documentation diff --git a/TensionDev.CoordinateSystems.Tests/CoordinateSystemToStringTests.cs b/TensionDev.CoordinateSystems.Tests/CoordinateSystemToStringTests.cs index 38d2696..856e1a8 100644 --- a/TensionDev.CoordinateSystems.Tests/CoordinateSystemToStringTests.cs +++ b/TensionDev.CoordinateSystems.Tests/CoordinateSystemToStringTests.cs @@ -18,8 +18,8 @@ public void TestGeographicCoordinateSystem_ToString_Default() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 52.5200, - LongitudeDecimalDegrees = 13.4050, + LatitudeDecimalRadians = 52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; @@ -32,8 +32,8 @@ public void TestGeographicCoordinateSystem_ToString_DecimalDegrees() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 52.5200, - LongitudeDecimalDegrees = 13.4050, + LatitudeDecimalRadians = 52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; @@ -46,8 +46,8 @@ public void TestGeographicCoordinateSystem_ToString_DegreesDecimalMinutes() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 52.5200, - LongitudeDecimalDegrees = 13.4050, + LatitudeDecimalRadians = 52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; @@ -60,8 +60,8 @@ public void TestGeographicCoordinateSystem_ToString_DegreesMinutesSeconds() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 52.5200, - LongitudeDecimalDegrees = 13.4050, + LatitudeDecimalRadians = 52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; @@ -74,13 +74,13 @@ public void TestGeographicCoordinateSystem_ToString_Radians() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 52.5200, - LongitudeDecimalDegrees = 13.4050, + LatitudeDecimalRadians = 52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; string result = geographicCoordinateSystem.ToString(AngularFormat.Radians, DistanceUnit.Metres); - Assert.Equal("0.916647, 0.233961 Altitude: 100.500 m", result); + Assert.Equal("0.916647 rad, 0.233961 rad Altitude: 100.500 m", result); } [Fact] @@ -88,8 +88,8 @@ public void TestGeographicCoordinateSystem_ToString_DistanceUnit_Feet() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 52.5200, - LongitudeDecimalDegrees = 13.4050, + LatitudeDecimalRadians = 52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; @@ -130,8 +130,8 @@ public void TestGeographicCoordinateSystem_ToString_NegativeValues() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = -52.5200, - LongitudeDecimalDegrees = -13.4050, + LatitudeDecimalRadians = -52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = -13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; @@ -144,8 +144,8 @@ public void TestGeographicCoordinateSystem_ToString_ZeroValues() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 0, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0 }; @@ -158,8 +158,8 @@ public void TestGeographicCoordinateSystem_ToString_CustomPrecision() { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 52.5200, - LongitudeDecimalDegrees = 13.4050, + LatitudeDecimalRadians = 52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; @@ -186,8 +186,8 @@ public void TestGeographicCoordinateSystem_ToString_AngularPrecisionCappedAtSix( { GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 52.5200, - LongitudeDecimalDegrees = 13.4050, + LatitudeDecimalRadians = 52.5200 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.4050 * Math.PI / 180.0, AltitudeMetres = 100.5 }; @@ -231,4 +231,4 @@ public void Dispose() GC.SuppressFinalize(this); } } -} \ No newline at end of file +} diff --git a/TensionDev.CoordinateSystems.Tests/GeocentricConverterTests.cs b/TensionDev.CoordinateSystems.Tests/GeocentricConverterTests.cs index c484d60..4766fef 100644 --- a/TensionDev.CoordinateSystems.Tests/GeocentricConverterTests.cs +++ b/TensionDev.CoordinateSystems.Tests/GeocentricConverterTests.cs @@ -1,11 +1,11 @@ -using System; +using System; using Xunit; namespace TensionDev.CoordinateSystems.Tests { public class GeocentricConverterTests { - private const Double PositionToleranceMetres = 10.0; + private const Double PositionToleranceMetres = 0.001; [Fact] public void FromThrowsForNullGeographicCoordinateSystem() @@ -20,8 +20,8 @@ public void FromConvertsEquatorPrimeMeridian() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 0.0, - LongitudeDecimalDegrees = 0.0, + LatitudeDecimalRadians = 0.0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0.0 * Math.PI / 180.0, AltitudeMetres = 0.0, }; @@ -37,8 +37,8 @@ public void FromConvertsNorthPole() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 90.0, - LongitudeDecimalDegrees = 0.0, + LatitudeDecimalRadians = 90.0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0.0 * Math.PI / 180.0, AltitudeMetres = 0.0, }; @@ -49,6 +49,24 @@ public void FromConvertsNorthPole() AssertWithin(6356752.31425, actual.Z, PositionToleranceMetres); } + [Fact] + public void FromMatchesIogpWgs84GeographicGeocentricExample() + { + // IOGP Report 373-07-02, section 4.1.1 (North Sea WGS 84 example). + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = (53.0 + 48.0 / 60.0 + 33.820 / 3600.0) * Math.PI / 180.0, + LongitudeDecimalRadians = (2.0 + 7.0 / 60.0 + 46.380 / 3600.0) * Math.PI / 180.0, + AltitudeMetres = 73.0, + }; + + GeocentricCoordinateSystem actual = GeocentricConverter.From(source); + + AssertWithin(3771793.968, actual.X, 0.01); + AssertWithin(140253.342, actual.Y, 0.01); + AssertWithin(5124304.349, actual.Z, 0.01); + } + private static void AssertWithin(Double expected, Double actual, Double tolerance) { Assert.InRange(actual, expected - tolerance, expected + tolerance); diff --git a/TensionDev.CoordinateSystems.Tests/GeocentricCoordinateSystemTests.cs b/TensionDev.CoordinateSystems.Tests/GeocentricCoordinateSystemTests.cs index 3b7e36f..5ea441d 100644 --- a/TensionDev.CoordinateSystems.Tests/GeocentricCoordinateSystemTests.cs +++ b/TensionDev.CoordinateSystems.Tests/GeocentricCoordinateSystemTests.cs @@ -23,6 +23,338 @@ public void TestDefaultConstructor() Assert.Equal(0, geocentricCoordinateSystem.Z, POSITIONAL_PRECISION); } + [Fact] + public void TestParse_Metres_ReturnsCorrectValues() + { + string input = "X: 1234.567 m, Y: 5678.901 m, Z: 9012.345 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(1234.567, result.X, POSITIONAL_PRECISION); + Assert.Equal(5678.901, result.Y, POSITIONAL_PRECISION); + Assert.Equal(9012.345, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Feet_ReturnsCorrectValues() + { + string input = "X: 4084.334 ft, Y: 18637.209 ft, Z: 29567.989 ft"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + // Per ADR-0008 Section 66: all geocentric coordinates must be in metres after parsing + // Feet values are converted to metres (multiply by 0.3048). + double xMetres = 4084.334 * 0.3048; + double yMetres = 18637.209 * 0.3048; + double zMetres = 29567.989 * 0.3048; + + Assert.Equal(xMetres, result.X, POSITIONAL_PRECISION); + Assert.Equal(yMetres, result.Y, POSITIONAL_PRECISION); + Assert.Equal(zMetres, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_SixDecimalPrecision() + { + string input = "X: 1000.123456 m, Y: 2000.789012 m, Z: 3000.345678 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(1000.123456, result.X, POSITIONAL_PRECISION); + Assert.Equal(2000.789012, result.Y, POSITIONAL_PRECISION); + Assert.Equal(3000.345678, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_NegativeValues() + { + string input = "X: -1000.500 m, Y: -2000.750 m, Z: -3000.250 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(-1000.500, result.X, POSITIONAL_PRECISION); + Assert.Equal(-2000.750, result.Y, POSITIONAL_PRECISION); + Assert.Equal(-3000.250, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_ZeroValues() + { + string input = "X: 0.000 m, Y: 0.000 m, Z: 0.000 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(0, result.X, POSITIONAL_PRECISION); + Assert.Equal(0, result.Y, POSITIONAL_PRECISION); + Assert.Equal(0, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_LargeValues() + { + string input = "X: 99999.999 m, Y: 88888.888 m, Z: 77777.777 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(99999.999, result.X, POSITIONAL_PRECISION); + Assert.Equal(88888.888, result.Y, POSITIONAL_PRECISION); + Assert.Equal(77777.777, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_FeetUnits_MixedCase() + { + string input = "X: 4000.000 FT, Y: 5000.000 Ft, Z: 6000.000 ft"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + // Per ADR-0008 Section 66: all geocentric coordinates must be in metres after parsing + double xMetres = 4000.000 * 0.3048; + double yMetres = 5000.000 * 0.3048; + double zMetres = 6000.000 * 0.3048; + + Assert.Equal(xMetres, result.X, POSITIONAL_PRECISION); + Assert.Equal(yMetres, result.Y, POSITIONAL_PRECISION); + Assert.Equal(zMetres, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestTryParse_Metres_ReturnsTrue() + { + string input = "X: 1000.000 m, Y: 2000.000 m, Z: 3000.000 m"; + bool result = GeocentricCoordinateSystem.TryParse(input, out var system); + + Assert.True(result); + Assert.Equal(1000.000, system.X, POSITIONAL_PRECISION); + Assert.Equal(2000.000, system.Y, POSITIONAL_PRECISION); + Assert.Equal(3000.000, system.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestTryParse_Feet_ReturnsTrue() + { + string input = "X: 3280.840 ft, Y: 6561.680 ft, Z: 9842.520 ft"; + bool result = GeocentricCoordinateSystem.TryParse(input, out var system); + + Assert.True(result); + // Per ADR-0008 Section 66: all geocentric coordinates must be in metres after parsing + double xMetres = 3280.840 * 0.3048; + double yMetres = 6561.680 * 0.3048; + double zMetres = 9842.520 * 0.3048; + + Assert.Equal(xMetres, system.X, POSITIONAL_PRECISION); + Assert.Equal(yMetres, system.Y, POSITIONAL_PRECISION); + Assert.Equal(zMetres, system.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestTryParse_Null_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse(null, out var system); + Assert.False(result); + Assert.Equal(default(double), system.X); + } + + [Fact] + public void TestTryParse_EmptyString_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_WhitespaceOnly_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse(" \t\n ", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_MissingX_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("Y: 1000.000 m, Z: 2000.000 m", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_MissingY_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("X: 1000.000 m, Z: 2000.000 m", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_MissingZ_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("X: 1000.000 m, Y: 2000.000 m", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_MissingCommas_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("X: 1000.000 m Y: 2000.000 m Z: 3000.000 m", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_InvalidX_Value_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("X: abc m, Y: 2000.000 m, Z: 3000.000 m", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_InvalidY_Value_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("X: 1000.000 m, Y: xyz m, Z: 3000.000 m", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_InvalidZ_Value_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("X: 1000.000 m, Y: 2000.000 m, Z: qwe m", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_MixedUnits_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("X: 1000.000 m, Y: 2000.000 ft, Z: 3000.000 m", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_MissingUnit_ReturnsFalse() + { + bool result = GeocentricCoordinateSystem.TryParse("X: 1000.000, Y: 2000.000 m, Z: 3000.000 m", out _); + Assert.False(result); + } + + [Fact] + public void TestParse_InvalidInput_ThrowsArgumentNullException() + { + Assert.Throws(() => GeocentricCoordinateSystem.Parse(null)); + } + + [Fact] + public void TestParse_EmptyString_ThrowsFormatException() + { + Assert.Throws(() => GeocentricCoordinateSystem.Parse("")); + } + + [Fact] + public void TestParse_WhitespaceString_ThrowsFormatException() + { + Assert.Throws(() => GeocentricCoordinateSystem.Parse(" ")); + } + + [Fact] + public void TestParse_MalformedInput_ThrowsFormatException() + { + Assert.Throws(() => GeocentricCoordinateSystem.Parse("X: 1000.000 m, Y: 2000.000 m")); + } + + [Fact] + public void TestParse_MalformedValues_ThrowsFormatException() + { + Assert.Throws(() => GeocentricCoordinateSystem.Parse("X: abc m, Y: 2000.000 m, Z: 3000.000 m")); + } + + [Fact] + public void TestParse_InvalidUnit_ReturnsDefault() + { + Assert.False(GeocentricCoordinateSystem.TryParse("X: 1000.000 xyz, Y: 2000.000 m, Z: 3000.000 m", out _)); + } + + [Fact] + public void TestParse_MissingComma_ReturnsDefault() + { + Assert.False(GeocentricCoordinateSystem.TryParse("X: 1000.000 m Y: 2000.000 m Z: 3000.000 m", out _)); + } + + [Fact] + public void TestParse_TinyValues() + { + string input = "X: 0.001 m, Y: 0.002 m, Z: 0.003 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(0.001, result.X, POSITIONAL_PRECISION); + Assert.Equal(0.002, result.Y, POSITIONAL_PRECISION); + Assert.Equal(0.003, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_HighPrecisionValues() + { + string input = "X: 1.5e+10 m, Y: 2.7e+10 m, Z: 3.9e+10 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(1.5e+10, result.X, POSITIONAL_PRECISION); + Assert.Equal(2.7e+10, result.Y, POSITIONAL_PRECISION); + Assert.Equal(3.9e+10, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Feet_WithScientificNotation() + { + string input = "X: 4.1e+3 ft, Y: 6.2e+3 ft, Z: 8.3e+3 ft"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + // Per ADR-0008 Section 66: all geocentric coordinates must be in metres after parsing + double xMetres = 4100.0 * 0.3048; + double yMetres = 6200.0 * 0.3048; + double zMetres = 8300.0 * 0.3048; + + Assert.Equal(xMetres, result.X, POSITIONAL_PRECISION); + Assert.Equal(yMetres, result.Y, POSITIONAL_PRECISION); + Assert.Equal(zMetres, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Metres_WithNegativeZero() + { + string input = "X: -0.000 m, Y: -0.000 m, Z: -0.000 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(0, result.X, POSITIONAL_PRECISION); + Assert.Equal(0, result.Y, POSITIONAL_PRECISION); + Assert.Equal(0, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_MixedPositiveNegative() + { + string input = "X: 1000.500 m, Y: -2000.250 m, Z: 3000.750 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(1000.500, result.X, POSITIONAL_PRECISION); + Assert.Equal(-2000.250, result.Y, POSITIONAL_PRECISION); + Assert.Equal(3000.750, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_UnitAtEnd() + { + string input = "X: 1234.567890 m, Y: 5678.901234 m, Z: 9012.345678 m"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + Assert.Equal(1234.567890, result.X, POSITIONAL_PRECISION); + Assert.Equal(5678.901234, result.Y, POSITIONAL_PRECISION); + Assert.Equal(9012.345678, result.Z, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Feet_ConversionFromMetres() + { + // Parse feet value that is exactly 1000 metres converted (3280.84 ft ≈ 1000 m) + string input = "X: 3280.840 ft, Y: 6561.680 ft, Z: 9842.520 ft"; + GeocentricCoordinateSystem result = GeocentricCoordinateSystem.Parse(input); + + // Verify the values are approximately correct (feet -> metres conversion per ADR-0008 Section 66) + double xMetres = 3280.840 * 0.3048; + double yMetres = 6561.680 * 0.3048; + double zMetres = 9842.520 * 0.3048; + + Assert.Equal(xMetres, result.X, POSITIONAL_PRECISION); + Assert.Equal(yMetres, result.Y, POSITIONAL_PRECISION); + Assert.Equal(zMetres, result.Z, POSITIONAL_PRECISION); + } + protected virtual void Dispose(bool disposing) { if (!disposedValue) diff --git a/TensionDev.CoordinateSystems.Tests/GeographicConverterTests.cs b/TensionDev.CoordinateSystems.Tests/GeographicConverterTests.cs index 9aaf93b..28e6f25 100644 --- a/TensionDev.CoordinateSystems.Tests/GeographicConverterTests.cs +++ b/TensionDev.CoordinateSystems.Tests/GeographicConverterTests.cs @@ -5,9 +5,8 @@ namespace TensionDev.CoordinateSystems.Tests { public class GeographicConverterTests { - private const Double PositionToleranceMetres = 10.0; - private const Double Wgs84EquatorialRadiusMetres = 6378137.0; - private const Double PositionToleranceRadians = PositionToleranceMetres / Wgs84EquatorialRadiusMetres; + private const Double PositionToleranceMetres = 0.001; + private const Double PositionToleranceRadians = 1e-8; [Fact] public void FromThrowsForNullGeocentricCoordinateSystem() @@ -56,8 +55,8 @@ public void FromRoundTripsRepresentativeCoordinate() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 1.309432, - LongitudeDecimalDegrees = 103.780349, + LatitudeDecimalRadians = 1.309432 * Math.PI / 180.0, + LongitudeDecimalRadians = 103.780349 * Math.PI / 180.0, AltitudeMetres = 25.0, }; @@ -69,6 +68,118 @@ public void FromRoundTripsRepresentativeCoordinate() AssertWithin(source.AltitudeMetres, actual.AltitudeMetres, PositionToleranceMetres); } + [Fact] + public void GeographicCoordinateSystemDegreeAndRadianPropertiesRepresentTheSameAngle() + { + GeographicCoordinateSystem coordinate = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = Math.PI / 4.0, + LongitudeDecimalRadians = -Math.PI / 2.0, + }; + + AssertWithin(45.0, coordinate.LatitudeDecimalDegrees, 1e-12); + AssertWithin(-90.0, coordinate.LongitudeDecimalDegrees, 1e-12); + + coordinate.LatitudeDecimalDegrees = -30.0; + coordinate.LongitudeDecimalDegrees = 120.0; + + AssertWithin(-Math.PI / 6.0, coordinate.LatitudeDecimalRadians, 1e-12); + AssertWithin(2.0 * Math.PI / 3.0, coordinate.LongitudeDecimalRadians, 1e-12); + } + + [Fact] + public void FromMatchesIogpWgs84GeocentricGeographicExample() + { + // IOGP Report 373-07-02, section 4.1.1 (North Sea WGS 84 example). + GeocentricCoordinateSystem source = new GeocentricCoordinateSystem + { + X = 3771793.968, + Y = 140253.342, + Z = 5124304.349, + }; + + GeographicCoordinateSystem actual = GeographicConverter.From(source); + + AssertWithin(0.939151102, actual.LatitudeDecimalRadians, PositionToleranceRadians); + Double expectedLongitudeRadians = (2.0 + 7.0 / 60.0 + 46.380 / 3600.0) * Math.PI / 180.0; + AssertWithin(expectedLongitudeRadians, actual.LongitudeDecimalRadians, PositionToleranceRadians); + AssertWithin(73.0, actual.AltitudeMetres, 0.005); + } + + [Fact] + public void DmsAndDdmDirectConversionsPreserveCoordinateAndAltitude() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12, + LatitudeDirection = 'S', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18, + LongitudeDirection = 'W', + AltitudeMetres = 125.5, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(dms); + + Assert.Equal(52, ddm.LatitudeDegrees); + Assert.Equal(31.2, ddm.LatitudeMinutes, 12); + Assert.Equal('S', ddm.LatitudeDirection); + Assert.Equal(13, ddm.LongitudeDegrees); + Assert.Equal(24.3, ddm.LongitudeMinutes, 12); + Assert.Equal('W', ddm.LongitudeDirection); + Assert.Equal(125.5, ddm.AltitudeMetres); + + GeographicDms roundTripped = GeographicConverter.ToDms(ddm); + + Assert.Equal(52, roundTripped.LatitudeDegrees); + Assert.Equal(31, roundTripped.LatitudeMinutes); + Assert.Equal(12, roundTripped.LatitudeSeconds, 10); + Assert.Equal('S', roundTripped.LatitudeDirection); + Assert.Equal(13, roundTripped.LongitudeDegrees); + Assert.Equal(24, roundTripped.LongitudeMinutes); + Assert.Equal(18, roundTripped.LongitudeSeconds, 10); + Assert.Equal('W', roundTripped.LongitudeDirection); + Assert.Equal(125.5, roundTripped.AltitudeMetres); + } + + [Fact] + public void GeographicRepresentationsConvertInEveryDirectionAndPreserveAltitude() + { + GeographicDd dd = new GeographicDd + { + LatitudeDecimalDegrees = -33.86882, + LongitudeDecimalDegrees = 151.20929, + AltitudeMetres = 125.5, + }; + GeographicCoordinateSystem canonical = GeographicConverter.From(dd); + GeographicDdm ddm = GeographicConverter.ToDdm(dd); + GeographicDms dms = GeographicConverter.ToDms(dd); + + AssertGeographicDd(dd, GeographicConverter.ToDd(canonical)); + AssertGeographicDd(dd, GeographicConverter.ToDd(ddm)); + AssertGeographicDd(dd, GeographicConverter.ToDd(dms)); + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.From(dd))); + + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.FromDdm(ddm))); + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.FromDms(dms))); + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.ToDdm(canonical))); + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.ToDdm(dms))); + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.ToDms(canonical))); + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.ToDms(ddm))); + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.ToDdm(dd))); + AssertGeographicDd(dd, GeographicConverter.ToDd(GeographicConverter.ToDms(dd))); + } + + private static void AssertGeographicDd(GeographicDd expected, GeographicDd actual) + { + AssertWithin(expected.LatitudeDecimalDegrees, actual.LatitudeDecimalDegrees, 1e-6); + AssertWithin(expected.LongitudeDecimalDegrees, actual.LongitudeDecimalDegrees, 1e-6); + AssertWithin(expected.AltitudeMetres, actual.AltitudeMetres, PositionToleranceMetres); + } + private static void AssertWithin(Double expected, Double actual, Double tolerance) { Assert.InRange(actual, expected - tolerance, expected + tolerance); diff --git a/TensionDev.CoordinateSystems.Tests/GeographicCoordinateSystemTests.cs b/TensionDev.CoordinateSystems.Tests/GeographicCoordinateSystemTests.cs index e23aea3..4dcc495 100644 --- a/TensionDev.CoordinateSystems.Tests/GeographicCoordinateSystemTests.cs +++ b/TensionDev.CoordinateSystems.Tests/GeographicCoordinateSystemTests.cs @@ -1,106 +1,585 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 2021 - 2026 TensionDev +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System; using Xunit; namespace TensionDev.CoordinateSystems.Tests { - public class GeographicCoordinateSystemTests : IDisposable + public class GeographicCoordinateSystemParseTests : IDisposable { private bool disposedValue; private const Int32 POSITIONAL_PRECISION = 5; - public GeographicCoordinateSystemTests() + public GeographicCoordinateSystemParseTests() { } + #region Parse - Decimal Degrees Format + [Fact] - public void TestDefaultConstructor() + public void TestParse_DecimalDegrees_Metres() { - GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem(); + string input = "52.520000, 13.405000"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); - Assert.Equal(0, geographicCoordinateSystem.LatitudeDecimalDegrees, POSITIONAL_PRECISION); - Assert.Equal(0, geographicCoordinateSystem.LongitudeDecimalDegrees, POSITIONAL_PRECISION); - Assert.Equal(0, geographicCoordinateSystem.AltitudeMetres, POSITIONAL_PRECISION); - Assert.Equal(0, geographicCoordinateSystem.LatitudeDecimalRadians, POSITIONAL_PRECISION); - Assert.Equal(0, geographicCoordinateSystem.LongitudeDecimalRadians, POSITIONAL_PRECISION); - Assert.Equal(0, geographicCoordinateSystem.AltitudeFeet, POSITIONAL_PRECISION); + Assert.Equal(52.520000, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405000, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.AltitudeMetres, POSITIONAL_PRECISION); } [Fact] - public void TestConstructorWithValues1() + public void TestParse_DecimalDegrees_Metres_WithAltitude() { - double latitudeDeg = 30; - double longitudeDeg = 30; - double altitudeMetre = 1000; - double latitudeRad = latitudeDeg * Math.PI / 180.0; - double longitudeRad = longitudeDeg * Math.PI / 180.0; - double altitudeFeet = altitudeMetre / 0.3048; + string input = "52.520000, 13.405000 Altitude: 100.000 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); - GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() - { - LatitudeDecimalDegrees = latitudeDeg, - LongitudeDecimalDegrees = longitudeDeg, - AltitudeMetres = altitudeMetre, - }; + Assert.Equal(52.520000, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405000, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } - Assert.Equal(latitudeDeg, geographicCoordinateSystem.LatitudeDecimalDegrees, POSITIONAL_PRECISION); - Assert.Equal(longitudeDeg, geographicCoordinateSystem.LongitudeDecimalDegrees, POSITIONAL_PRECISION); - Assert.Equal(altitudeMetre, geographicCoordinateSystem.AltitudeMetres, POSITIONAL_PRECISION); - Assert.Equal(latitudeRad, geographicCoordinateSystem.LatitudeDecimalRadians, POSITIONAL_PRECISION); - Assert.Equal(longitudeRad, geographicCoordinateSystem.LongitudeDecimalRadians, POSITIONAL_PRECISION); - Assert.Equal(altitudeFeet, geographicCoordinateSystem.AltitudeFeet, POSITIONAL_PRECISION); + [Fact] + public void TestParse_DecimalDegrees_Feet() + { + string input = "52.520000, 13.405000 Altitude: 328.084 ft"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.520000, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405000, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); } [Fact] - public void TestConstructorWithValues2() + public void TestParse_DecimalDegrees_Negative() { - double latitudeDeg = -60; - double longitudeDeg = -90; - double altitudeMetre = -5000; - double latitudeRad = latitudeDeg * Math.PI / 180.0; - double longitudeRad = longitudeDeg * Math.PI / 180.0; - double altitudeFeet = altitudeMetre / 0.3048; + string input = "-33.868820, 151.209290"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); - GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() - { - LatitudeDecimalDegrees = latitudeDeg, - LongitudeDecimalDegrees = longitudeDeg, - AltitudeMetres = altitudeMetre, - }; + Assert.Equal(-33.868820, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(151.209290, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_BareSmallNumericPair_IsDecimalDegrees() + { + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse("0.5, 0.25"); - Assert.Equal(latitudeDeg, geographicCoordinateSystem.LatitudeDecimalDegrees, POSITIONAL_PRECISION); - Assert.Equal(longitudeDeg, geographicCoordinateSystem.LongitudeDecimalDegrees, POSITIONAL_PRECISION); - Assert.Equal(altitudeMetre, geographicCoordinateSystem.AltitudeMetres, POSITIONAL_PRECISION); - Assert.Equal(latitudeRad, geographicCoordinateSystem.LatitudeDecimalRadians, POSITIONAL_PRECISION); - Assert.Equal(longitudeRad, geographicCoordinateSystem.LongitudeDecimalRadians, POSITIONAL_PRECISION); - Assert.Equal(altitudeFeet, geographicCoordinateSystem.AltitudeFeet, POSITIONAL_PRECISION); + Assert.Equal(0.5, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0.25, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); } [Fact] - public void TestConstructorLimits() + public void TestParse_DecimalDegrees_Sydney() { - double latitudeDeg = 90; - double longitudeDeg = -180; - double altitudeMetre = 40000; - double latitudeRad = latitudeDeg * Math.PI / 180.0; - double longitudeRad = longitudeDeg * Math.PI / 180.0; - double altitudeFeet = altitudeMetre / 0.3048; + string input = "-33.868820, 151.209290 Altitude: 500.500 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); - GeographicCoordinateSystem geographicCoordinateSystem = new GeographicCoordinateSystem() - { - LatitudeDecimalDegrees = double.MaxValue, - LongitudeDecimalDegrees = double.MinValue, - AltitudeMetres = altitudeMetre, - }; + Assert.Equal(-33.868820, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(151.209290, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(500.500, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + #endregion + + #region Parse - DDM Format with Symbols + + [Fact] + public void TestParse_DDM_SymbolDelimited_Metres() + { + string input = "52° 31.20' N, 13° 24.30' E"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + // Expected: 52 + 31.20/60 = 52.52, 13 + 24.30/60 = 13.405 + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DDM_SymbolDelimited_WithAltitude() + { + string input = "52° 31.20' N, 13° 24.30' E Altitude: 100.000 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DDM_SymbolDelimited_Feet() + { + string input = "52° 31.20' N, 13° 24.30' E Altitude: 328.084 ft"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DDM_SymbolDelimited_Negative() + { + string input = "33° 52' 08\" S, 151° 12' 33\" E"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + // Expected: -33.868889, 151.209167 (with direction applied) + Assert.Equal(-33.868889, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(151.209167, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + #endregion + + #region Parse - DMS Format with Symbols + + [Fact] + public void TestParse_DMS_SymbolDelimited_Metres() + { + string input = "52° 31' 12.00\" N, 13° 24' 18.00\" E"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + // Expected: 52 + 31/60 + 12/3600 = 52.52, 13 + 24/60 + 18/3600 = 13.405 + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DMS_SymbolDelimited_WithAltitude() + { + string input = "52° 31' 12.00\" N, 13° 24' 18.00\" E Altitude: 100.000 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DMS_SymbolDelimited_Feet() + { + string input = "52° 31' 12.00\" N, 13° 24' 18.00\" E Altitude: 328.084 ft"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + #endregion + + #region Parse - Space-Delimited DDM Format + + [Fact] + public void TestParse_DDM_SpaceDelimited_Metres() + { + string input = "52 31.20 N, 13 24.30 E"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + // Expected: 52 + 31.20/60 = 52.52, 13 + 24.30/60 = 13.405 + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DDM_SpaceDelimited_WithAltitude() + { + string input = "52 31.20 N, 13 24.30 E Altitude: 100.000 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DDM_SpaceDelimited_Feet() + { + string input = "52 31.20 N, 13 24.30 E Altitude: 328.084 ft"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DDM_SpaceDelimited_Negative() + { + string input = "33 52 08 S, 151 12 33 E"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + // Expected: -33.868889, 151.209167 (with direction applied) + Assert.Equal(-33.868889, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(151.209167, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + #endregion + + #region Parse - Radians Format + + [Fact] + public void TestParse_Radians_Metres() + { + // Berlin: 52.52° N, 13.405° E converted to radians + double latRad = 52.52 * Math.PI / 180.0; + double lonRad = 13.405 * Math.PI / 180.0; + + string input = $"{latRad} rad, {lonRad} rad"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(latRad, result.LatitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(lonRad, result.LongitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(0, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Radians_WithAltitude_Metres() + { + double latRad = 52.52 * Math.PI / 180.0; + double lonRad = 13.405 * Math.PI / 180.0; + + string input = $"{latRad} rad, {lonRad} rad Altitude: 100.000 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(latRad, result.LatitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(lonRad, result.LongitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Radians_WithAltitude_Feet() + { + double latRad = 52.52 * Math.PI / 180.0; + double lonRad = 13.405 * Math.PI / 180.0; + + string input = $"{latRad} rad, {lonRad} rad Altitude: 328.084 ft"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(latRad, result.LatitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(lonRad, result.LongitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + #endregion + + #region Parse - Mixed Case Direction Indicators + + [Fact] + public void TestParse_DDM_LowercaseDirection() + { + string input = "52° 31.20' n, 13° 24.30' e"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DDM_MixedCaseDirection() + { + string input = "52° 31.20' n, 13° 24.30' e"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + #endregion + + #region Round-Trip Testing + + [Fact] + public void TestParse_ToString_DecimalDegrees_RoundTrip() + { + string input = "52.520000, 13.405000"; + GeographicCoordinateSystem parsed = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.520000, parsed.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405000, parsed.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_ToString_DDM_RoundTrip() + { + string originalInput = "52° 31.20' N, 13° 24.30' E"; + GeographicCoordinateSystem parsed = GeographicCoordinateSystem.Parse(originalInput); + + Assert.Equal(52.52, parsed.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, parsed.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_ToString_DMS_RoundTrip() + { + string originalInput = "52° 31' 12.00\" N, 13° 24' 18.00\" E"; + GeographicCoordinateSystem parsed = GeographicCoordinateSystem.Parse(originalInput); + + Assert.Equal(52.52, parsed.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, parsed.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } - Assert.Equal(latitudeDeg, geographicCoordinateSystem.LatitudeDecimalDegrees, POSITIONAL_PRECISION); - Assert.Equal(longitudeDeg, geographicCoordinateSystem.LongitudeDecimalDegrees, POSITIONAL_PRECISION); - Assert.Equal(altitudeMetre, geographicCoordinateSystem.AltitudeMetres, POSITIONAL_PRECISION); - Assert.Equal(latitudeRad, geographicCoordinateSystem.LatitudeDecimalRadians, POSITIONAL_PRECISION); - Assert.Equal(longitudeRad, geographicCoordinateSystem.LongitudeDecimalRadians, POSITIONAL_PRECISION); - Assert.Equal(altitudeFeet, geographicCoordinateSystem.AltitudeFeet, POSITIONAL_PRECISION); + [Fact] + public void TestParse_ToString_Radians_RoundTrip() + { + double latRad = 52.52 * Math.PI / 180.0; + double lonRad = 13.405 * Math.PI / 180.0; + + string originalInput = $"{latRad} rad, {lonRad} rad"; + GeographicCoordinateSystem parsed = GeographicCoordinateSystem.Parse(originalInput); + string output = parsed.ToString(AngularFormat.Radians, DistanceUnit.Metres, 6, 3); + GeographicCoordinateSystem roundTripped = GeographicCoordinateSystem.Parse(output); + + Assert.Equal(latRad, parsed.LatitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(lonRad, parsed.LongitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(parsed.LatitudeDecimalRadians, roundTripped.LatitudeDecimalRadians, POSITIONAL_PRECISION); + Assert.Equal(parsed.LongitudeDecimalRadians, roundTripped.LongitudeDecimalRadians, POSITIONAL_PRECISION); + } + + #endregion + + #region Parse - Boundary Conditions + + [Fact] + public void TestParse_Latitude_90() + { + string input = "90.000000, 0"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(90, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Latitude_Minus90() + { + string input = "-90.000000, 0"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(-90, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Longitude_180() + { + string input = "0, 180"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(180, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Longitude_Minus180() + { + string input = "0, -180"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(-180, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_ZeroLatitudeZeroLongitude() + { + string input = "0.000000, 0.000000"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + #endregion + + #region Parse - Error Cases (TryParse) + + [Fact] + public void TestTryParse_Null_ReturnsFalse() + { + bool result = GeographicCoordinateSystem.TryParse(null, out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_EmptyString_ReturnsFalse() + { + bool result = GeographicCoordinateSystem.TryParse("", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_WhitespaceOnly_ReturnsFalse() + { + bool result = GeographicCoordinateSystem.TryParse(" \t\n ", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_MissingComma_ReturnsFalse() + { + bool result = GeographicCoordinateSystem.TryParse("52.520000 13.405000", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_InvalidLatitude_ReturnsFalse() + { + bool result = GeographicCoordinateSystem.TryParse("100.000000, 13.405000", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_InvalidLongitude_ReturnsFalse() + { + bool result = GeographicCoordinateSystem.TryParse("52.520000, 200.000000", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_InvalidLatitudeDDM_ReturnsFalse() + { + bool result = GeographicCoordinateSystem.TryParse("100° 30' N, 13° 24' E", out _); + Assert.False(result); + } + + [Fact] + public void TestTryParse_MissingDirectionInDDM_ReturnsFalse() + { + bool result = GeographicCoordinateSystem.TryParse("52° 31.20, 13° 24.30 E", out _); + Assert.False(result); + } + + #endregion + + #region Parse - Error Cases (Throws) + + [Fact] + public void TestParse_Null_ThrowsArgumentNullException() + { + Assert.Throws(() => GeographicCoordinateSystem.Parse(null)); + } + + [Fact] + public void TestParse_EmptyString_ThrowsFormatException() + { + Assert.Throws(() => GeographicCoordinateSystem.Parse("")); + } + + [Fact] + public void TestParse_Whitespace_ThrowsFormatException() + { + Assert.Throws(() => GeographicCoordinateSystem.Parse(" ")); + } + + [Fact] + public void TestParse_Malformed_ThrowsFormatException() + { + Assert.Throws(() => GeographicCoordinateSystem.Parse("52.520000 13.405000")); + } + + #endregion + + #region Parse - Altitude Edge Cases + + [Fact] + public void TestParse_AltitudeZero_Metres() + { + string input = "52.520000, 13.405000 Altitude: 0.000 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_AltitudeZero_Feet() + { + string input = "52.520000, 13.405000 Altitude: 0.000 ft"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Altitude_LargeValue() + { + string input = "52.520000, 13.405000 Altitude: 99999.999 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(99999.999, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_Altitude_SmallValue() + { + string input = "52.520000, 13.405000 Altitude: 0.001 m"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0.001, result.AltitudeMetres, POSITIONAL_PRECISION); } + [Fact] + public void TestParse_Altitude_IncFeet_WithCapital() + { + string input = "52.520000, 13.405000 Altitude: 328.084 FT"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(100.000, result.AltitudeMetres, POSITIONAL_PRECISION); + } + + #endregion + + #region Parse - Whitespace Tolerance + + [Fact] + public void TestParse_AdditionalWhitespace() + { + string input = " 52.520000 , 13.405000 "; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestParse_DDM_Whitespace_AroundSymbols() + { + string input = "52° 31.20' N , 13° 24.30' E"; + GeographicCoordinateSystem result = GeographicCoordinateSystem.Parse(input); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + #endregion + protected virtual void Dispose(bool disposing) { if (!disposedValue) @@ -110,22 +589,12 @@ protected virtual void Dispose(bool disposing) // TODO: dispose managed state (managed objects) } - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null disposedValue = true; } } - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources - // ~UnitTestGeographicCoordinateSystem() - // { - // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - // Dispose(disposing: false); - // } - public void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Dispose(disposing: true); GC.SuppressFinalize(this); } diff --git a/TensionDev.CoordinateSystems.Tests/GeographicDdTests.cs b/TensionDev.CoordinateSystems.Tests/GeographicDdTests.cs new file mode 100644 index 0000000..bdca5b4 --- /dev/null +++ b/TensionDev.CoordinateSystems.Tests/GeographicDdTests.cs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 2021 - 2026 TensionDev +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using Xunit; + +namespace TensionDev.CoordinateSystems.Tests +{ + public class GeographicDdTests + { + [Fact] + public void ParseDecimalDegreesAndAltitude() + { + GeographicDd actual = GeographicDd.Parse("-33.868820, 151.209290 Altitude: 125.500 m"); + + Assert.Equal(-33.868820, actual.LatitudeDecimalDegrees, 6); + Assert.Equal(151.209290, actual.LongitudeDecimalDegrees, 6); + Assert.Equal(125.500, actual.AltitudeMetres, 3); + } + + [Fact] + public void ParseFeetAltitudeIntoMetres() + { + GeographicDd actual = GeographicDd.Parse("52.520000, 13.405000 Altitude: 328.084 ft"); + + Assert.Equal(100.0, actual.AltitudeMetres, 3); + Assert.Equal(328.084, actual.AltitudeFeet, 3); + } + + [Fact] + public void ParseDefaultsAltitudeToZero() + { + GeographicDd actual = GeographicDd.Parse("52.520000, 13.405000"); + + Assert.Equal(0.0, actual.AltitudeMetres); + } + + [Theory] + [InlineData("90, 180")] + [InlineData("-90, -180")] + public void ParseAcceptsInclusiveGeographicBoundaries(string input) + { + Assert.True(GeographicDd.TryParse(input, out _)); + } + + [Theory] + [InlineData("90.000001, 0")] + [InlineData("0, -180.000001")] + [InlineData("NaN, 0")] + [InlineData("52° 31.2' N, 13° 24.3' E")] + [InlineData("0.9163 rad, 0.2338 rad")] + public void TryParseRejectsInvalidOrDifferentRepresentations(string input) + { + Assert.False(GeographicDd.TryParse(input, out GeographicDd result)); + Assert.Null(result); + } + + [Fact] + public void ParseThrowsExpectedExceptions() + { + Assert.Throws(() => GeographicDd.Parse(null)); + Assert.Throws(() => GeographicDd.Parse("91, 0")); + } + + [Fact] + public void ToStringCanBeParsedBack() + { + GeographicDd source = new GeographicDd + { + LatitudeDecimalDegrees = 1.309432, + LongitudeDecimalDegrees = 103.780349, + AltitudeMetres = 25.125, + }; + + GeographicDd actual = GeographicDd.Parse(source.ToString()); + + Assert.Equal(source.LatitudeDecimalDegrees, actual.LatitudeDecimalDegrees, 6); + Assert.Equal(source.LongitudeDecimalDegrees, actual.LongitudeDecimalDegrees, 6); + Assert.Equal(source.AltitudeMetres, actual.AltitudeMetres, 3); + } + } +} diff --git a/TensionDev.CoordinateSystems.Tests/GeographicDdmTests.cs b/TensionDev.CoordinateSystems.Tests/GeographicDdmTests.cs index 1f9e39c..2d3d790 100644 --- a/TensionDev.CoordinateSystems.Tests/GeographicDdmTests.cs +++ b/TensionDev.CoordinateSystems.Tests/GeographicDdmTests.cs @@ -96,8 +96,8 @@ public void TestToDdmFromGeographicConverter_ReturnsCorrectValues() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 52.52, - LongitudeDecimalDegrees = 13.405, + LatitudeDecimalRadians = 52.52 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.405 * Math.PI / 180.0, AltitudeMetres = 100.5, }; @@ -116,8 +116,8 @@ public void TestToDdmFromGeographicConverter_NegativeValues() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = -52.52, - LongitudeDecimalDegrees = -13.405, + LatitudeDecimalRadians = -52.52 * Math.PI / 180.0, + LongitudeDecimalRadians = -13.405 * Math.PI / 180.0, AltitudeMetres = 100.5, }; @@ -136,8 +136,8 @@ public void TestToDdmFromGeographicConverter_Equator() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 0, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -156,8 +156,8 @@ public void TestToDdmFromGeographicConverter_NorthPole() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 90, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = 90 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -173,8 +173,8 @@ public void TestToDdmFromGeographicConverter_SouthPole() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = -90, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = -90 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -190,8 +190,8 @@ public void TestToDdmFromGeographicConverter_PrimeMeridian() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 0, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -207,8 +207,8 @@ public void TestToDdmFromGeographicConverter_Antimeridian() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 0, - LongitudeDecimalDegrees = 180, + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 180 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -216,7 +216,7 @@ public void TestToDdmFromGeographicConverter_Antimeridian() Assert.Equal(180, result.LongitudeDegrees, POSITIONAL_PRECISION); Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); - Assert.Equal('W', result.LongitudeDirection); + Assert.Equal('E', result.LongitudeDirection); } [Fact] @@ -241,7 +241,7 @@ public void TestToString_NegativeValues() }; string result = ddm.ToString(); - Assert.Equal("52° 31.200000' S, 13° 24.300000' W", result); + Assert.Equal("52° 31.200000' S, 13° 24.300000' W Altitude: 0.000 m", result); } [Fact] @@ -258,7 +258,7 @@ public void TestToString_ZeroValues() }; string result = ddm.ToString(); - Assert.Equal("0° 0.000000' N, 0° 0.000000' E", result); + Assert.Equal("0° 0.000000' N, 0° 0.000000' E Altitude: 0.000 m", result); } [Fact] @@ -275,7 +275,7 @@ public void TestToString_NorthPole() }; string result = ddm.ToString(); - Assert.Equal("90° 0.000000' N, 0° 0.000000' E", result); + Assert.Equal("90° 0.000000' N, 0° 0.000000' E Altitude: 0.000 m", result); } [Fact] @@ -283,8 +283,8 @@ public void TestToDdmFromGeographicConverter_RoundTrip() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 1.309432, - LongitudeDecimalDegrees = 103.780349, + LatitudeDecimalRadians = 1.309432 * Math.PI / 180.0, + LongitudeDecimalRadians = 103.780349 * Math.PI / 180.0, AltitudeMetres = 25.0, }; @@ -320,7 +320,7 @@ public void TestToString_PrecisionZero() }; string result = ddm.ToString(0); - Assert.Equal("52° 31' N, 13° 24' E", result); + Assert.Equal("52° 31' N, 13° 24' E Altitude: 0.000 m", result); } [Fact] @@ -337,7 +337,7 @@ public void TestToString_PrecisionThree() }; string result = ddm.ToString(3); - Assert.Equal("52° 31.235' N, 13° 24.346' E", result); + Assert.Equal("52° 31.235' N, 13° 24.346' E Altitude: 0.000 m", result); } [Fact] @@ -354,7 +354,7 @@ public void TestToString_PrecisionSix() }; string result = ddm.ToString(6); - Assert.Equal("52° 31.234567' N, 13° 24.345678' E", result); + Assert.Equal("52° 31.234567' N, 13° 24.345678' E Altitude: 0.000 m", result); } [Fact] @@ -371,7 +371,7 @@ public void TestToString_PrecisionClampedToSix() }; string result = ddm.ToString(10); - Assert.Equal("52° 31.234568' N, 13° 24.345679' E", result); + Assert.Equal("52° 31.234568' N, 13° 24.345679' E Altitude: 0.000 m", result); } [Fact] @@ -388,7 +388,7 @@ public void TestToString_MinuteRoundingToSixty() }; string result = ddm.ToString(6); - Assert.Equal("53° 0.000000' N, 13° 24.000000' E", result); + Assert.Equal("53° 0.000000' N, 13° 24.000000' E Altitude: 0.000 m", result); } [Fact] @@ -405,7 +405,7 @@ public void TestToString_MinuteOverflowIntoDegrees() }; string result = ddm.ToString(6); - Assert.Equal("52° 59.500000' N, 14° 0.000000' E", result); + Assert.Equal("52° 59.500000' N, 14° 0.000000' E Altitude: 0.000 m", result); } [Fact] @@ -422,7 +422,7 @@ public void TestToString_MaximumPrecision() }; string result = ddm.ToString(6); - Assert.Equal("52° 32.000000' N, 13° 25.000000' E", result); + Assert.Equal("52° 32.000000' N, 13° 25.000000' E Altitude: 0.000 m", result); } [Fact] @@ -571,8 +571,8 @@ public void TestFromDdmFromGeographicConverter_RoundTrip() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 1.309432, - LongitudeDecimalDegrees = 103.780349, + LatitudeDecimalRadians = 1.309432 * Math.PI / 180.0, + LongitudeDecimalRadians = 103.780349 * Math.PI / 180.0, AltitudeMetres = 25.0, }; @@ -583,6 +583,766 @@ public void TestFromDdmFromGeographicConverter_RoundTrip() Assert.Equal(source.LongitudeDecimalDegrees, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); } + #region Parse / TryParse Tests + + [Fact] + public void Parse_ValidDdmString_ReturnsCorrectValues() + { + string input = "52° 31.20' N, 13° 24.30' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_DdmWithSouthernWesternDirections_ReturnsCorrectValues() + { + string input = "33° 51.67' S, 18° 25.27' W"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(33, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(51.67, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('S', result.LatitudeDirection); + Assert.Equal(18, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(25.27, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('W', result.LongitudeDirection); + } + + [Fact] + public void Parse_DdmCaseInsensitiveDirection_ReturnsCorrectValues() + { + string input = "52° 31.20' n, 13° 24.30' e"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_DdmMixedCaseDirection_ReturnsCorrectValues() + { + string input = "52° 31.20' N, 13° 24.30' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_ZeroValues_ReturnsCorrectValues() + { + string input = "0° 0.000000' N, 0° 0.000000' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(0, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(0, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_NorthPole_ReturnsCorrectValues() + { + string input = "90° 0.000000' N, 0° 0.000000' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(90, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + } + + [Fact] + public void Parse_SouthPole_ReturnsCorrectValues() + { + string input = "90° 0.000000' S, 0° 0.000000' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(90, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('S', result.LatitudeDirection); + } + + [Fact] + public void Parse_PrimeMeridian_ReturnsCorrectValues() + { + string input = "0° 0.000000' N, 0° 0.000000' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(0, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_Antimeridian_ReturnsCorrectValues() + { + string input = "0° 0.000000' N, 180° 0.000000' W"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(180, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('W', result.LongitudeDirection); + } + + [Fact] + public void Parse_DdmWithAltitudeMetres_ParsesAltitude() + { + string input = "52° 31.20' N, 13° 24.30' E Altitude: 100.500 m"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_DdmWithAltitudeFeet_ParsesAltitude() + { + string input = "52° 31.20' N, 13° 24.30' E Altitude: 329.724 ft"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_DdmWithoutAltitude_DefaultsToZero() + { + string input = "52° 31.20' N, 13° 24.30' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_SpaceDelimitedDdm_ReturnsCorrectValues() + { + string input = "52 31.20 N, 13 24.30 E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_SpaceDelimitedDdmWithTab_ReturnsCorrectValues() + { + string input = "52\t31.20\tN, 13\t24.30\tE"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void Parse_SpaceDelimitedDdmNonIntegerDegrees_ThrowsFormatException() + { + string input = "52.5 31.20 N, 13 24.30 E"; + + Assert.Throws(() => GeographicDdm.Parse(input)); + } + + [Fact] + public void Parse_EmptyString_ThrowsFormatException() + { + Assert.Throws(() => GeographicDdm.Parse("")); + } + + [Fact] + public void Parse_NullString_ThrowsArgumentNullException() + { + Assert.Throws(() => GeographicDdm.Parse(null!)); + } + + [Fact] + public void Parse_WhitespaceString_ThrowsFormatException() + { + Assert.Throws(() => GeographicDdm.Parse(" ")); + } + + [Fact] + public void Parse_MissingComma_ThrowsFormatException() + { + Assert.Throws(() => GeographicDdm.Parse("52° 31.20' N 13° 24.30' E")); + } + + [Fact] + public void Parse_MissingDirectionIndicator_ThrowsFormatException() + { + Assert.Throws(() => GeographicDdm.Parse("52° 31.20, 13° 24.30' E")); + } + + [Fact] + public void Parse_InvalidDirection_ThrowsFormatException() + { + Assert.Throws(() => GeographicDdm.Parse("52° 31.20' X, 13° 24.30' E")); + } + + [Fact] + public void Parse_NonNumericDegrees_ThrowsFormatException() + { + Assert.Throws(() => GeographicDdm.Parse("abc° 31.20' N, 13° 24.30' E")); + } + + [Fact] + public void Parse_NonNumericMinutes_ThrowsFormatException() + { + Assert.Throws(() => GeographicDdm.Parse("52° xyz' N, 13° 24.30' E")); + } + + [Fact] + public void Parse_InvalidAltitudeFormat_ThrowsFormatException() + { + Assert.Throws(() => GeographicDdm.Parse("52° 31.20' N, 13° 24.30' E Altitude: abc m")); + } + + [Fact] + public void TryParse_ValidDdmString_ReturnsTrue() + { + string input = "52° 31.20' N, 13° 24.30' E"; + + bool result = GeographicDdm.TryParse(input, out GeographicDdm ddm); + + Assert.True(result); + Assert.Equal(52, ddm.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, ddm.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', ddm.LatitudeDirection); + Assert.Equal(13, ddm.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, ddm.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', ddm.LongitudeDirection); + } + + [Fact] + public void TryParse_InvalidString_ReturnsFalse() + { + bool result = GeographicDdm.TryParse("not a valid DDM", out GeographicDdm ddm); + + Assert.False(result); + } + + [Fact] + public void TryParse_EmptyString_ReturnsFalse() + { + bool result = GeographicDdm.TryParse("", out GeographicDdm ddm); + + Assert.False(result); + } + + [Fact] + public void TryParse_NullString_ReturnsFalse() + { + bool result = GeographicDdm.TryParse(null!, out GeographicDdm ddm); + + Assert.False(result); + } + + [Fact] + public void TryParse_SpaceDelimitedDdm_ReturnsTrue() + { + string input = "52 31.20 N, 13 24.30 E"; + + bool result = GeographicDdm.TryParse(input, out GeographicDdm ddm); + + Assert.True(result); + Assert.Equal(52, ddm.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, ddm.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', ddm.LatitudeDirection); + Assert.Equal(13, ddm.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, ddm.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', ddm.LongitudeDirection); + } + + [Fact] + public void TryParse_SpaceDelimitedNonIntegerDegrees_ReturnsFalse() + { + bool result = GeographicDdm.TryParse("52.5 31.20 N, 13 24.30 E", out GeographicDdm ddm); + + Assert.False(result); + } + + #endregion + + #region Round-Trip Tests + + [Fact] + public void Parse_ToString_RoundTrip() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = 52.52 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.405 * Math.PI / 180.0, + AltitudeMetres = 100.5, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + string formatted = ddm.ToString(); + GeographicDdm parsed = GeographicDdm.Parse(formatted); + string reparsed = parsed.ToString(); + + Assert.Equal(formatted, reparsed); + } + + [Fact] + public void Parse_ToString_RoundTrip_NegativeValues() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = -52.52 * Math.PI / 180.0, + LongitudeDecimalRadians = -13.405 * Math.PI / 180.0, + AltitudeMetres = 100.5, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + string formatted = ddm.ToString(); + GeographicDdm parsed = GeographicDdm.Parse(formatted); + string reparsed = parsed.ToString(); + + Assert.Equal(formatted, reparsed); + } + + [Fact] + public void Parse_ToString_RoundTrip_ZeroValues() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, + AltitudeMetres = 0, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + string formatted = ddm.ToString(); + GeographicDdm parsed = GeographicDdm.Parse(formatted); + string reparsed = parsed.ToString(); + + Assert.Equal(formatted, reparsed); + } + + [Fact] + public void Parse_ToString_RoundTrip_NorthPole() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = 90 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, + AltitudeMetres = 0, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + string formatted = ddm.ToString(); + GeographicDdm parsed = GeographicDdm.Parse(formatted); + string reparsed = parsed.ToString(); + + Assert.Equal(formatted, reparsed); + } + + [Fact] + public void Parse_ToString_RoundTrip_SouthPole() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = -90 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, + AltitudeMetres = 0, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + string formatted = ddm.ToString(); + GeographicDdm parsed = GeographicDdm.Parse(formatted); + string reparsed = parsed.ToString(); + + Assert.Equal(formatted, reparsed); + } + + [Fact] + public void Parse_ToString_RoundTrip_Antimeridian() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 180 * Math.PI / 180.0, + AltitudeMetres = 0, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + string formatted = ddm.ToString(); + GeographicDdm parsed = GeographicDdm.Parse(formatted); + string reparsed = parsed.ToString(); + + Assert.Equal(formatted, reparsed); + } + + [Fact] + public void Parse_ToString_RoundTrip_Singapore() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = 1.309432 * Math.PI / 180.0, + LongitudeDecimalRadians = 103.780349 * Math.PI / 180.0, + AltitudeMetres = 25.0, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + string formatted = ddm.ToString(); + GeographicDdm parsed = GeographicDdm.Parse(formatted); + string reparsed = parsed.ToString(); + + Assert.Equal(formatted, reparsed); + } + + [Fact] + public void Parse_ToString_PrecisionAwareRoundTrip() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.234567, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24.345678, + LongitudeDirection = 'E', + }; + + string formatted = ddm.ToString(6); + GeographicDdm parsed = GeographicDdm.Parse(formatted); + string reparsed = parsed.ToString(6); + + Assert.Equal(formatted, reparsed); + } + + #endregion + + #region Clamping Tests + + [Fact] + public void Parse_LatitudeDegreesExceeds90_RejectsValue() + { + string input = "95° 10.00' N, 13° 24.30' E"; + + Assert.False(GeographicDdm.TryParse(input, out _)); + } + + [Fact] + public void Parse_LongitudeDegreesExceeds180_RejectsValue() + { + string input = "52° 31.20' N, 185° 10.00' E"; + + Assert.False(GeographicDdm.TryParse(input, out _)); + } + + [Fact] + public void Parse_LatitudeMinutesEquals60_ClawsToNextDegree() + { + string input = "52° 60.00' N, 13° 24.30' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(53, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + } + + [Fact] + public void Parse_LatitudeMinutesExceeds60_RejectsValue() + { + string input = "52° 61.50' N, 13° 24.30' E"; + + Assert.False(GeographicDdm.TryParse(input, out _)); + } + + [Fact] + public void Parse_LongitudeMinutesEquals60_ClawsToNextDegree() + { + string input = "52° 31.20' N, 13° 60.00' E"; + + GeographicDdm result = GeographicDdm.Parse(input); + + Assert.Equal(14, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); + } + + #endregion + + #region Internal Helper Tests — TryParseDdmCoordinate + + [Fact] + public void TryParseDdmCoordinate_SymbolDelimited_NorthEast_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52° 31.20' N", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(52, degrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, minutes, POSITIONAL_PRECISION); + Assert.Equal('N', direction); + } + + [Fact] + public void TryParseDdmCoordinate_SymbolDelimited_South_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("33° 51.67' S", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(33, degrees, POSITIONAL_PRECISION); + Assert.Equal(51.67, minutes, POSITIONAL_PRECISION); + Assert.Equal('S', direction); + } + + [Fact] + public void TryParseDdmCoordinate_SymbolDelimitedLowercaseDirection_ReturnsTrueWithUppercase() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52° 31.20' n", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(52, degrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, minutes, POSITIONAL_PRECISION); + Assert.Equal('N', direction); + } + + [Fact] + public void TryParseDdmCoordinate_SpaceDelimited_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52 31.20 N", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(52, degrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, minutes, POSITIONAL_PRECISION); + Assert.Equal('N', direction); + } + + [Fact] + public void TryParseDdmCoordinate_SpaceDelimitedTabDelimited_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52\t31.20\tN", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(52, degrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, minutes, POSITIONAL_PRECISION); + Assert.Equal('N', direction); + } + + [Fact] + public void TryParseDdmCoordinate_EmptyPart_ReturnsFalse() + { + bool result = GeographicDdm.TryParseDdmCoordinate("", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.False(result); + } + + [Fact] + public void TryParseDdmCoordinate_WhitespacePart_ReturnsFalse() + { + bool result = GeographicDdm.TryParseDdmCoordinate(" ", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.False(result); + } + + [Fact] + public void TryParseDdmCoordinate_InvalidDirection_ReturnsFalse() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52° 31.20' X", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.False(result); + } + + [Fact] + public void TryParseDdmCoordinate_NonNumericDegrees_ReturnsFalse() + { + bool result = GeographicDdm.TryParseDdmCoordinate("abc° 31.20' N", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.False(result); + } + + [Fact] + public void TryParseDdmCoordinate_NonNumericMinutes_ReturnsFalse() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52° xyz' N", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.False(result); + } + + [Fact] + public void TryParseDdmCoordinate_SymbolDelimitedSinglePart_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52° 31.20' N", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(52, degrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, minutes, POSITIONAL_PRECISION); + Assert.Equal('N', direction); + } + + [Fact] + public void TryParseDdmCoordinate_SpaceDelimitedNonIntegerDegrees_ReturnsFalse() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52.5 31.20 N", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.False(result); + } + + [Fact] + public void TryParseDdmCoordinate_SpaceDelimitedWrongArity_ReturnsFalse() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52 31", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.False(result); + } + + [Fact] + public void TryParseDdmCoordinate_SpaceDelimitedMissingDirection_ReturnsFalse() + { + bool result = GeographicDdm.TryParseDdmCoordinate("52 31.20", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.False(result); + } + + [Fact] + public void TryParseDdmCoordinate_ZeroValues_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("0° 0.000000' N", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(0, degrees, POSITIONAL_PRECISION); + Assert.Equal(0, minutes, POSITIONAL_PRECISION); + Assert.Equal('N', direction); + } + + [Fact] + public void TryParseDdmCoordinate_NorthPole_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("90° 0.000000' N", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(90, degrees, POSITIONAL_PRECISION); + Assert.Equal(0, minutes, POSITIONAL_PRECISION); + Assert.Equal('N', direction); + } + + [Fact] + public void TryParseDdmCoordinate_SouthPole_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("90° 0.000000' S", isLatitude: true, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(90, degrees, POSITIONAL_PRECISION); + Assert.Equal(0, minutes, POSITIONAL_PRECISION); + Assert.Equal('S', direction); + } + + [Fact] + public void TryParseDdmCoordinate_Antimeridian_ReturnsTrue() + { + bool result = GeographicDdm.TryParseDdmCoordinate("180° 0.000000' W", isLatitude: false, out double degrees, out double minutes, out char direction); + + Assert.True(result); + Assert.Equal(180, degrees, POSITIONAL_PRECISION); + Assert.Equal(0, minutes, POSITIONAL_PRECISION); + Assert.Equal('W', direction); + } + + [Fact] + public void TryParseDdmCoordinate_SpaceDelimitedAllDirections_ReturnsTrue() + { + Assert.True(GeographicDdm.TryParseDdmCoordinate("52 31.20 N", isLatitude: true, out _, out _, out var d1)); + Assert.Equal('N', d1); + + Assert.True(GeographicDdm.TryParseDdmCoordinate("52 31.20 S", isLatitude: true, out _, out _, out var d2)); + Assert.Equal('S', d2); + + Assert.True(GeographicDdm.TryParseDdmCoordinate("52 31.20 E", isLatitude: false, out _, out _, out var d3)); + Assert.Equal('E', d3); + + Assert.True(GeographicDdm.TryParseDdmCoordinate("52 31.20 W", isLatitude: false, out _, out _, out var d4)); + Assert.Equal('W', d4); + } + + [Fact] + public void TryParseDdmCoordinate_SymbolDelimitedAllDirections_ReturnsTrue() + { + Assert.True(GeographicDdm.TryParseDdmCoordinate("52° 31.20' N", isLatitude: true, out _, out _, out var d1)); + Assert.Equal('N', d1); + + Assert.True(GeographicDdm.TryParseDdmCoordinate("52° 31.20' S", isLatitude: true, out _, out _, out var d2)); + Assert.Equal('S', d2); + + Assert.True(GeographicDdm.TryParseDdmCoordinate("52° 31.20' E", isLatitude: false, out _, out _, out var d3)); + Assert.Equal('E', d3); + + Assert.True(GeographicDdm.TryParseDdmCoordinate("52° 31.20' W", isLatitude: false, out _, out _, out var d4)); + Assert.Equal('W', d4); + } + + #endregion + + #region GeographicCoordinateSystem Direction Validation Tests + + [Theory] + [InlineData('N', true)] + [InlineData('n', true)] + [InlineData('S', true)] + [InlineData('s', true)] + [InlineData('E', true)] + [InlineData('e', true)] + [InlineData('W', true)] + [InlineData('w', true)] + public void IsValidDirection_ValidDirection_ReturnsTrue(char c, bool expected) + { + bool result = GeographicCoordinateSystem.IsValidDirection(c); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData('X')] + [InlineData('A')] + [InlineData('0')] + [InlineData(' ')] + [InlineData('\0')] + public void IsValidDirection_InvalidDirection_ReturnsFalse(char c) + { + Assert.False(GeographicCoordinateSystem.IsValidDirection(c)); + } + + #endregion + protected virtual void Dispose(bool disposing) { if (!disposedValue) diff --git a/TensionDev.CoordinateSystems.Tests/GeographicDmsParseTests.cs b/TensionDev.CoordinateSystems.Tests/GeographicDmsParseTests.cs new file mode 100644 index 0000000..edd6726 --- /dev/null +++ b/TensionDev.CoordinateSystems.Tests/GeographicDmsParseTests.cs @@ -0,0 +1,228 @@ +using System; +using System.Globalization; +using System.Threading; +using Xunit; + +namespace TensionDev.CoordinateSystems.Tests +{ + public class GeographicDmsParseTests + { + [Fact] + public void Parse_ValidDmsWithMetres_ReturnsCoordinate() + { + GeographicDms actual = GeographicDms.Parse("52° 31' 12.125\" S, 13° 24' 18.25\" W Altitude: 100.500 m"); + + Assert.Equal(52, actual.LatitudeDegrees); + Assert.Equal(31, actual.LatitudeMinutes); + Assert.Equal(12.125, actual.LatitudeSeconds, 6); + Assert.Equal('S', actual.LatitudeDirection); + Assert.Equal(13, actual.LongitudeDegrees); + Assert.Equal(24, actual.LongitudeMinutes); + Assert.Equal(18.25, actual.LongitudeSeconds, 6); + Assert.Equal('W', actual.LongitudeDirection); + Assert.Equal(100.5, actual.AltitudeMetres, 3); + } + + [Fact] + public void Parse_FeetAltitude_ConvertsToMetres() + { + GeographicDms actual = GeographicDms.Parse("52° 31' 12\" N, 13° 24' 18\" E Altitude: 328.084 ft"); + + Assert.Equal(100.0, actual.AltitudeMetres, 3); + Assert.Equal(328.084, actual.AltitudeFeet, 3); + } + + [Fact] + public void AltitudeFeet_SetAndGet_UsesMetresAsBackingValue() + { + GeographicDms actual = new GeographicDms { AltitudeFeet = 328.084 }; + + Assert.Equal(100.0, actual.AltitudeMetres, 3); + Assert.Equal(328.084, actual.AltitudeFeet, 3); + } + + [Fact] + public void TryParse_AltitudeOmitted_DefaultsToZero() + { + Assert.True(GeographicDms.TryParse("52° 31' 12\" N, 13° 24' 18\" E", out GeographicDms actual)); + + Assert.Equal(0.0, actual.AltitudeMetres); + } + + [Fact] + public void TryParse_LowercaseDirectionsAndFlexibleWhitespace_NormalizesDirections() + { + Assert.True(GeographicDms.TryParse(" 52°31'12.5\"s , 13°24'18.25\"w ", out GeographicDms actual)); + + Assert.Equal('S', actual.LatitudeDirection); + Assert.Equal('W', actual.LongitudeDirection); + Assert.Equal(12.5, actual.LatitudeSeconds, 6); + Assert.Equal(18.25, actual.LongitudeSeconds, 6); + } + + [Theory] + [InlineData("90° 0' 0\" N, 180° 0' 0\" E")] + [InlineData("90° 0' 0\" S, 180° 0' 0\" W")] + public void TryParse_InclusiveGeographicBoundaries_ReturnsTrue(string input) + { + Assert.True(GeographicDms.TryParse(input, out _)); + } + + [Fact] + public void Parse_MinutesAndSecondsAtSixty_NormalizesCarry() + { + GeographicDms actual = GeographicDms.Parse("52° 59' 60\" N, 13° 60' 0\" E"); + + Assert.Equal(53, actual.LatitudeDegrees); + Assert.Equal(0, actual.LatitudeMinutes); + Assert.Equal(0, actual.LatitudeSeconds); + Assert.Equal(14, actual.LongitudeDegrees); + Assert.Equal(0, actual.LongitudeMinutes); + Assert.Equal(0, actual.LongitudeSeconds); + } + + [Fact] + public void Parse_LongitudeSecondsAtSixty_NormalizesCarry() + { + GeographicDms actual = GeographicDms.Parse("52° 30' 0\" N, 13° 59' 60\" E"); + + Assert.Equal(14, actual.LongitudeDegrees); + Assert.Equal(0, actual.LongitudeMinutes); + Assert.Equal(0, actual.LongitudeSeconds); + } + + [Fact] + public void TryParse_SpaceDelimitedDmsAndTabs_ReturnsParsedCoordinates() + { + Assert.True(GeographicDms.TryParse("52 31 12.5 s, 13\t24\t18.25\tw", out GeographicDms actual)); + + Assert.Equal(52, actual.LatitudeDegrees); + Assert.Equal(31, actual.LatitudeMinutes); + Assert.Equal(12.5, actual.LatitudeSeconds, 6); + Assert.Equal('S', actual.LatitudeDirection); + Assert.Equal(13, actual.LongitudeDegrees); + Assert.Equal(24, actual.LongitudeMinutes); + Assert.Equal(18.25, actual.LongitudeSeconds, 6); + Assert.Equal('W', actual.LongitudeDirection); + } + + [Fact] + public void TryParseDmsCoordinate_EmptyPart_ReturnsFalseAndClearsOutputs() + { + bool result = GeographicDms.TryParseDmsCoordinate( + "", isLatitude: true, out double degrees, out double minutes, out double seconds, out char direction); + + Assert.False(result); + Assert.Equal(0, degrees); + Assert.Equal(0, minutes); + Assert.Equal(0, seconds); + Assert.Equal('\0', direction); + } + + [Theory] + [InlineData("90° 0' 0.001\" N, 0° 0' 0\" E")] + [InlineData("90° 0' 1\" N, 0° 0' 0\" E")] + [InlineData("0° 0' 0\" N, 180° 0' 0.001\" E")] + [InlineData("0° 0' 0\" N, 181° 0' 0\" E")] + [InlineData("52° 61' 0\" N, 13° 0' 0\" E")] + [InlineData("52° 0' 61\" N, 13° 0' 0\" E")] + [InlineData("abc° 31' 12\" N, 13° 0' 0\" E")] + [InlineData("91° 0' 0\" N, 13° 0' 0\" E")] + [InlineData("-1° 0' 0\" N, 13° 0' 0\" E")] + [InlineData("52° NaN' 0\" N, 13° 0' 0\" E")] + [InlineData("52° 0' NaN\" N, 13° 0' 0\" E")] + [InlineData("52° 31.2' N, 13° 24.3' E")] + [InlineData("52.52, 13.405")] + [InlineData("52° 31' 12\" N, 181° 0' 0\" E")] + [InlineData("52° 31' 12\" N, 13° nope' 0\" E")] + [InlineData("52° 31' 12\" N, 13° 24' nope\" E")] + [InlineData("52° 31' 12\" N, 13° 24.3' E")] + [InlineData("52° 31' 12\" X, 13° 24' 18\" E")] + [InlineData("52° 31' 12 E, 13° 24' 18\" E")] + [InlineData("52° 31' 12\" N, 13° 24' 18\" N")] + [InlineData("malformed input without coordinate separator")] + [InlineData("52 31 12.5 N, 13.5 24 18 E")] + [InlineData("52.5 31 12.5 N, 13 24 18 E")] + [InlineData("52 31.5 12.5 N, 13 24 18 E")] + [InlineData("52 31 nope N, 13 24 18 E")] + [InlineData("52 31 12 X, 13 24 18 E")] + [InlineData("52 31 12 NN, 13 24 18 E")] + [InlineData("52 31 12 N, 13 24 18 N")] + [InlineData("91 0 0 N, 13 24 18 E")] + [InlineData("52 31 12 N, 181 0 0 E")] + [InlineData("52° 31' 12\" N, 13° 24' 18\" E Altitude: invalid m")] + public void TryParse_InvalidOrDifferentRepresentation_ReturnsFalse(string input) + { + Assert.False(GeographicDms.TryParse(input, out GeographicDms actual)); + Assert.Null(actual); + } + + [Fact] + public void Parse_NullAndInvalidInput_ThrowsExpectedExceptions() + { + Assert.Throws(() => GeographicDms.Parse(null)); + Assert.Throws(() => GeographicDms.Parse(" ")); + Assert.Throws(() => GeographicDms.Parse("52° 31' 12\" N, 13° 24' 18\"")); + } + + [Fact] + public void TryParse_NullAndWhitespace_ReturnsFalseWithNullResult() + { + Assert.False(GeographicDms.TryParse(null, out GeographicDms nullResult)); + Assert.Null(nullResult); + Assert.False(GeographicDms.TryParse(" ", out GeographicDms whitespaceResult)); + Assert.Null(whitespaceResult); + } + + [Fact] + public void TryParse_UsesInvariantCulture() + { + CultureInfo originalCulture = Thread.CurrentThread.CurrentCulture; + CultureInfo originalUiCulture = Thread.CurrentThread.CurrentUICulture; + try + { + Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo("fr-FR"); + + Assert.True(GeographicDms.TryParse("52° 31' 12.5\" N, 13° 24' 18.25\" E Altitude: 100.5 m", out GeographicDms actual)); + Assert.Equal(12.5, actual.LatitudeSeconds, 6); + Assert.Equal(18.25, actual.LongitudeSeconds, 6); + Assert.Equal(100.5, actual.AltitudeMetres, 3); + } + finally + { + Thread.CurrentThread.CurrentCulture = originalCulture; + Thread.CurrentThread.CurrentUICulture = originalUiCulture; + } + } + + [Fact] + public void Parse_ToStringRoundTrip_PreservesSemanticValues() + { + GeographicDms source = new GeographicDms + { + LatitudeDegrees = 1, + LatitudeMinutes = 18, + LatitudeSeconds = 33.123456, + LatitudeDirection = 'S', + LongitudeDegrees = 103, + LongitudeMinutes = 46, + LongitudeSeconds = 49.654321, + LongitudeDirection = 'E', + AltitudeMetres = 25.125, + }; + + GeographicDms actual = GeographicDms.Parse(source.ToString()); + + Assert.Equal(source.LatitudeDegrees, actual.LatitudeDegrees); + Assert.Equal(source.LatitudeMinutes, actual.LatitudeMinutes); + Assert.Equal(source.LatitudeSeconds, actual.LatitudeSeconds, 6); + Assert.Equal(source.LatitudeDirection, actual.LatitudeDirection); + Assert.Equal(source.LongitudeDegrees, actual.LongitudeDegrees); + Assert.Equal(source.LongitudeMinutes, actual.LongitudeMinutes); + Assert.Equal(source.LongitudeSeconds, actual.LongitudeSeconds, 6); + Assert.Equal(source.LongitudeDirection, actual.LongitudeDirection); + Assert.Equal(source.AltitudeMetres, actual.AltitudeMetres, 3); + } + } +} diff --git a/TensionDev.CoordinateSystems.Tests/GeographicDmsTests.cs b/TensionDev.CoordinateSystems.Tests/GeographicDmsTests.cs index 0512ce8..8311479 100644 --- a/TensionDev.CoordinateSystems.Tests/GeographicDmsTests.cs +++ b/TensionDev.CoordinateSystems.Tests/GeographicDmsTests.cs @@ -102,8 +102,8 @@ public void TestToDmsFromGeographicConverter_ReturnsCorrectValues() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 52.52, - LongitudeDecimalDegrees = 13.405, + LatitudeDecimalRadians = 52.52 * Math.PI / 180.0, + LongitudeDecimalRadians = 13.405 * Math.PI / 180.0, AltitudeMetres = 100.5, }; @@ -124,8 +124,8 @@ public void TestToDmsFromGeographicConverter_NegativeValues() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = -52.52, - LongitudeDecimalDegrees = -13.405, + LatitudeDecimalRadians = -52.52 * Math.PI / 180.0, + LongitudeDecimalRadians = -13.405 * Math.PI / 180.0, AltitudeMetres = 100.5, }; @@ -146,8 +146,8 @@ public void TestToDmsFromGeographicConverter_Equator() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 0, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -168,8 +168,8 @@ public void TestToDmsFromGeographicConverter_NorthPole() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 90, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = 90 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -186,8 +186,8 @@ public void TestToDmsFromGeographicConverter_SouthPole() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = -90, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = -90 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -204,8 +204,8 @@ public void TestToDmsFromGeographicConverter_PrimeMeridian() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 0, - LongitudeDecimalDegrees = 0, + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -222,8 +222,8 @@ public void TestToDmsFromGeographicConverter_Antimeridian() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 0, - LongitudeDecimalDegrees = 180, + LatitudeDecimalRadians = 0 * Math.PI / 180.0, + LongitudeDecimalRadians = 180 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -232,7 +232,7 @@ public void TestToDmsFromGeographicConverter_Antimeridian() Assert.Equal(180, result.LongitudeDegrees, POSITIONAL_PRECISION); Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); Assert.Equal(0, result.LongitudeSeconds, POSITIONAL_PRECISION); - Assert.Equal('W', result.LongitudeDirection); + Assert.Equal('E', result.LongitudeDirection); } [Fact] @@ -259,7 +259,7 @@ public void TestToString_NegativeValues() }; string result = dms.ToString(); - Assert.Equal("52° 31' 12.000000\" S, 13° 24' 18.000000\" W", result); + Assert.Equal("52° 31' 12.000000\" S, 13° 24' 18.000000\" W Altitude: 0.000 m", result); } [Fact] @@ -278,7 +278,7 @@ public void TestToString_ZeroValues() }; string result = dms.ToString(); - Assert.Equal("0° 0' 0.000000\" N, 0° 0' 0.000000\" E", result); + Assert.Equal("0° 0' 0.000000\" N, 0° 0' 0.000000\" E Altitude: 0.000 m", result); } [Fact] @@ -297,7 +297,7 @@ public void TestToString_NorthPole() }; string result = dms.ToString(); - Assert.Equal("90° 0' 0.000000\" N, 0° 0' 0.000000\" E", result); + Assert.Equal("90° 0' 0.000000\" N, 0° 0' 0.000000\" E Altitude: 0.000 m", result); } [Fact] @@ -305,8 +305,8 @@ public void TestToDmsFromGeographicConverter_RoundTrip() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 1.309432, - LongitudeDecimalDegrees = 103.780349, + LatitudeDecimalRadians = 1.309432 * Math.PI / 180.0, + LongitudeDecimalRadians = 103.780349 * Math.PI / 180.0, AltitudeMetres = 25.0, }; @@ -344,7 +344,7 @@ public void TestToString_PrecisionZero() }; string result = dms.ToString(0); - Assert.Equal("52° 31' 13\" N, 13° 24' 19\" E", result); + Assert.Equal("52° 31' 13\" N, 13° 24' 19\" E Altitude: 0.000 m", result); } [Fact] @@ -363,7 +363,7 @@ public void TestToString_PrecisionThree() }; string result = dms.ToString(3); - Assert.Equal("52° 31' 12.679\" N, 13° 24' 18.789\" E", result); + Assert.Equal("52° 31' 12.679\" N, 13° 24' 18.789\" E Altitude: 0.000 m", result); } [Fact] @@ -382,7 +382,7 @@ public void TestToString_PrecisionSix() }; string result = dms.ToString(6); - Assert.Equal("52° 31' 12.678901\" N, 13° 24' 18.789012\" E", result); + Assert.Equal("52° 31' 12.678901\" N, 13° 24' 18.789012\" E Altitude: 0.000 m", result); } [Fact] @@ -401,7 +401,7 @@ public void TestToString_PrecisionClampedToSix() }; string result = dms.ToString(10); - Assert.Equal("52° 31' 12.678901\" N, 13° 24' 18.789012\" E", result); + Assert.Equal("52° 31' 12.678901\" N, 13° 24' 18.789012\" E Altitude: 0.000 m", result); } [Fact] @@ -420,7 +420,7 @@ public void TestToString_SecondsRoundingToSixty() }; string result = dms.ToString(6); - Assert.Equal("52° 32' 0.000000\" N, 13° 24' 18.000000\" E", result); + Assert.Equal("52° 32' 0.000000\" N, 13° 24' 18.000000\" E Altitude: 0.000 m", result); } [Fact] @@ -439,7 +439,7 @@ public void TestToString_SecondsOverflowIntoMinutes() }; string result = dms.ToString(6); - Assert.Equal("52° 32' 0.000000\" N, 13° 25' 0.000000\" E", result); + Assert.Equal("52° 32' 0.000000\" N, 13° 25' 0.000000\" E Altitude: 0.000 m", result); } [Fact] @@ -457,7 +457,7 @@ public void TestToString_MinuteRoundingToSixty() }; string result = dms.ToString(6); - Assert.Equal("53° 0' 0.000000\" N, 13° 24' 18.000000\" E", result); + Assert.Equal("53° 0' 0.000000\" N, 13° 24' 18.000000\" E Altitude: 0.000 m", result); } [Fact] @@ -476,7 +476,7 @@ public void TestToString_CascadingOverflow() }; string result = dms.ToString(6); - Assert.Equal("53° 0' 0.000000\" N, 14° 0' 0.000000\" E", result); + Assert.Equal("53° 0' 0.000000\" N, 14° 0' 0.000000\" E Altitude: 0.000 m", result); } [Fact] @@ -495,7 +495,7 @@ public void TestToString_MaximumPrecision() }; string result = dms.ToString(6); - Assert.Equal("52° 32' 0.000000\" N, 13° 25' 0.000000\" E", result); + Assert.Equal("52° 32' 0.000000\" N, 13° 25' 0.000000\" E Altitude: 0.000 m", result); } [Fact] @@ -658,8 +658,8 @@ public void TestFromDmsFromGeographicConverter_RoundTrip() { GeographicCoordinateSystem source = new GeographicCoordinateSystem { - LatitudeDecimalDegrees = 1.309432, - LongitudeDecimalDegrees = 103.780349, + LatitudeDecimalRadians = 1.309432 * Math.PI / 180.0, + LongitudeDecimalRadians = 103.780349 * Math.PI / 180.0, AltitudeMetres = 25.0, }; diff --git a/TensionDev.CoordinateSystems.Tests/GeohashBase32Tests.cs b/TensionDev.CoordinateSystems.Tests/GeohashBase32Tests.cs index 9d9f95b..4c7af60 100644 --- a/TensionDev.CoordinateSystems.Tests/GeohashBase32Tests.cs +++ b/TensionDev.CoordinateSystems.Tests/GeohashBase32Tests.cs @@ -15,8 +15,11 @@ public void IsValidAcceptsGeohashBase32(String value) [Theory] [InlineData("")] + [InlineData(" ")] + [InlineData(null)] [InlineData("ezs42a")] [InlineData("iezs42o")] + [InlineData("0123456789bcd")] public void IsValidRejectsNonGeohashBase32(String value) { Assert.False(GeohashBase32.IsValid(value)); @@ -29,5 +32,49 @@ public void EncodeReturnsGeohashBase32String() Assert.Equal("u", actual); } + + [Fact] + public void DecodeReturnsExpectedBits() + { + (System.Collections.BitArray bits, UInt32 length) = GeohashBase32.Decode("u"); + + Assert.Equal((UInt32)5, length); + Assert.Equal(5, bits.Count); + Assert.Equal(new[] { false, true, false, true, true }, ToArray(bits)); + } + + [Fact] + public void DecodeAcceptsMaximumHashLength() + { + (System.Collections.BitArray bits, UInt32 length) = GeohashBase32.Decode("0123456789bc"); + + Assert.Equal((UInt32)(GeohashBase32.MaximumHashLength * GeohashBase32.BitsPerCharacter), length); + Assert.Equal(GeohashBase32.MaximumHashLength * GeohashBase32.BitsPerCharacter, bits.Count); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("bad!")] + [InlineData("0123456789bcd")] + public void DecodeRejectsInvalidGeohashBase32(String value) + { + Assert.Throws(() => GeohashBase32.Decode(value)); + } + + [Theory] + [InlineData(0)] + [InlineData(13)] + public void EncodeRejectsUnsupportedLength(Int32 length) + { + Assert.Throws(() => GeohashBase32.Encode(0, length)); + } + + private static Boolean[] ToArray(System.Collections.BitArray bits) + { + Boolean[] values = new Boolean[bits.Count]; + bits.CopyTo(values, 0); + return values; + } } } diff --git a/TensionDev.CoordinateSystems.Tests/GeohashTests.cs b/TensionDev.CoordinateSystems.Tests/GeohashTests.cs index 6e44291..2a043e9 100644 --- a/TensionDev.CoordinateSystems.Tests/GeohashTests.cs +++ b/TensionDev.CoordinateSystems.Tests/GeohashTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using Xunit; namespace TensionDev.CoordinateSystems.Tests @@ -58,8 +58,8 @@ public void TestConstructorLimits() { GeographicCoordinateSystem expected = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 1.309432, - LongitudeDecimalDegrees = 103.780349, + LatitudeDecimalRadians = 1.309432 * Math.PI / 180.0, + LongitudeDecimalRadians = 103.780349 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -78,8 +78,8 @@ public void TestGeographicConverterFromGeohash() { GeographicCoordinateSystem expected = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 42.605, - LongitudeDecimalDegrees = -5.603, + LatitudeDecimalRadians = 42.605 * Math.PI / 180.0, + LongitudeDecimalRadians = -5.603 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -98,8 +98,8 @@ public void TestGeohashConverterFromGeographicCoordinateSystemUnderLimit() { GeographicCoordinateSystem value = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 57.64911, - LongitudeDecimalDegrees = 10.40744, + LatitudeDecimalRadians = 57.64911 * Math.PI / 180.0, + LongitudeDecimalRadians = 10.40744 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -114,8 +114,8 @@ public void TestGeohashConverterFromGeographicCoordinateSystemLengthOne() GeographicCoordinateSystem value = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = -22, - LongitudeDecimalDegrees = 68, + LatitudeDecimalRadians = -22 * Math.PI / 180.0, + LongitudeDecimalRadians = 68 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -132,8 +132,8 @@ public void TestGeohashConverterFromGeographicCoordinateSystem() GeographicCoordinateSystem value = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 57.64911, - LongitudeDecimalDegrees = 10.40744, + LatitudeDecimalRadians = 57.64911 * Math.PI / 180.0, + LongitudeDecimalRadians = 10.40744 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -142,6 +142,48 @@ public void TestGeohashConverterFromGeographicCoordinateSystem() Assert.Equal(expected.Hash, actual.Hash); } + [Theory] + [InlineData(0.0, 0.0, 9, "s00000000")] + [InlineData(48.856667, 2.352222, 9, "u09tvw0fd")] + [InlineData(-89.99, -179.99, 4, "0000")] + public void FromMatchesCta5009ATestVectors(Double latitude, Double longitude, Int32 length, String expectedHash) + { + // CTA-5009-A, Annex A (encoding test vectors). + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = latitude * Math.PI / 180.0, + LongitudeDecimalRadians = longitude * Math.PI / 180.0, + }; + + Geohash actual = GeohashConverter.From(source, length); + + Assert.Equal(expectedHash, actual.Hash); + } + + [Fact] + public void FromRejectsNorthPoleEndpoint() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = 90.0 * Math.PI / 180.0, + LongitudeDecimalRadians = 0.0 * Math.PI / 180.0, + }; + + Assert.Throws(() => GeohashConverter.From(source, 1)); + } + + [Fact] + public void FromRejectsAntimeridianPositiveEndpoint() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalRadians = 0.0 * Math.PI / 180.0, + LongitudeDecimalRadians = 180.0 * Math.PI / 180.0, + }; + + Assert.Throws(() => GeohashConverter.From(source, 1)); + } + [Fact] public void TestGeohashConverterFromGeographicCoordinateSystemLengthMax() { @@ -150,8 +192,8 @@ public void TestGeohashConverterFromGeographicCoordinateSystemLengthMax() GeographicCoordinateSystem value = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = -79.428710, - LongitudeDecimalDegrees = -157.262087, + LatitudeDecimalRadians = -79.428710 * Math.PI / 180.0, + LongitudeDecimalRadians = -157.262087 * Math.PI / 180.0, AltitudeMetres = 0, }; @@ -165,33 +207,14 @@ public void TestGeohashConverterFromGeographicCoordinateSystemOverLimit() { GeographicCoordinateSystem value = new GeographicCoordinateSystem() { - LatitudeDecimalDegrees = 57.64911, - LongitudeDecimalDegrees = 10.40744, + LatitudeDecimalRadians = 57.64911 * Math.PI / 180.0, + LongitudeDecimalRadians = 10.40744 * Math.PI / 180.0, AltitudeMetres = 0, }; Assert.Throws(() => GeohashConverter.From(value, 13)); } - [Fact] - public void TestGeographicConverterFromGeohashViaDestinationConverter() - { - GeographicCoordinateSystem expected = new GeographicCoordinateSystem() - { - LatitudeDecimalDegrees = 42.605, - LongitudeDecimalDegrees = -5.603, - AltitudeMetres = 0, - }; - - Geohash geohash = new Geohash("ezs42"); - - GeographicCoordinateSystem actual = GeographicConverter.From(geohash); - - Assert.Equal(expected.LatitudeDecimalDegrees, actual.LatitudeDecimalDegrees, LatitudeError5); - Assert.Equal(expected.LongitudeDecimalDegrees, actual.LongitudeDecimalDegrees, LongitudeError5); - Assert.Equal(expected.AltitudeMetres, actual.AltitudeMetres); - } - protected virtual void Dispose(bool disposing) { if (!disposedValue) diff --git a/TensionDev.CoordinateSystems.Tests/TensionDev.CoordinateSystems.Tests.csproj b/TensionDev.CoordinateSystems.Tests/TensionDev.CoordinateSystems.Tests.csproj index 9f655b4..8976633 100644 --- a/TensionDev.CoordinateSystems.Tests/TensionDev.CoordinateSystems.Tests.csproj +++ b/TensionDev.CoordinateSystems.Tests/TensionDev.CoordinateSystems.Tests.csproj @@ -1,17 +1,15 @@ - net8.0;net10.0 + net10.0 false true + true - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/TensionDev.CoordinateSystems/GeocentricCoordinateSystem.cs b/TensionDev.CoordinateSystems/GeocentricCoordinateSystem.cs index 9e74698..fea4ee5 100644 --- a/TensionDev.CoordinateSystems/GeocentricCoordinateSystem.cs +++ b/TensionDev.CoordinateSystems/GeocentricCoordinateSystem.cs @@ -72,5 +72,106 @@ internal string ToStringFeet(int distancePrecision) string format = "F" + distancePrecision; return $"X: {(X / 0.3048).ToString(format, CultureInfo.InvariantCulture)} ft, Y: {(Y / 0.3048).ToString(format, CultureInfo.InvariantCulture)} ft, Z: {(Z / 0.3048).ToString(format, CultureInfo.InvariantCulture)} ft"; } + + /// + /// Parses a geocentric coordinate string into a instance. + /// + /// A string in geocentric format (e.g., "X: 1000.000 m, Y: 2000.000 m, Z: 3000.000 m" or with feet units). + /// A parsed from the string. + /// Thrown when the string is null. + /// Thrown when the string is not in a valid geocentric format. + public static GeocentricCoordinateSystem Parse(string s) + { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + + if (string.IsNullOrWhiteSpace(s)) + { + throw new FormatException("Input string was null or whitespace."); + } + + if (!TryParse(s, out var result)) + { + throw new FormatException($"String '{s}' is not in a valid geocentric format."); + } + + return result; + } + + /// + /// Attempts to parse a geocentric coordinate string into a instance. + /// + /// A string in geocentric format. + /// When this method returns, contains the parsed or default on failure. + /// True if the string was parsed successfully; false otherwise. + public static bool TryParse(string s, out GeocentricCoordinateSystem result) + { + result = new GeocentricCoordinateSystem(); + + if (s == null || string.IsNullOrWhiteSpace(s)) + { + return false; + } + + // Split by comma first to extract X, Y, Z coordinate segments. + var segments = s.Split(','); + if (segments.Length != 3) + { + return false; + } + + double[] values = new double[3]; + string[] expectedAxes = { "X", "Y", "Z" }; + string commonUnit = null; + + for (int i = 0; i < 3; i++) + { + string segment = segments[i].Trim(); + int colonIdx = segment.IndexOf(':'); + if (colonIdx < 0 || !string.Equals(segment.Substring(0, colonIdx).Trim(), expectedAxes[i], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string[] valueAndUnit = segment.Substring(colonIdx + 1).Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (valueAndUnit.Length != 2) + { + return false; + } + + string unit = valueAndUnit[1]; + if (!string.Equals(unit, "m", StringComparison.OrdinalIgnoreCase) && + !string.Equals(unit, "ft", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (commonUnit != null && !string.Equals(unit, commonUnit, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + commonUnit = unit; + + if (!double.TryParse(valueAndUnit[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) + { + return false; + } + + // One international foot is exactly 0.3048 metres. + values[i] = string.Equals(unit, "ft", StringComparison.OrdinalIgnoreCase) ? value * 0.3048 : value; + } + + result = new GeocentricCoordinateSystem + { + X = values[0], + Y = values[1], + Z = values[2] + }; + + return true; + } } } diff --git a/TensionDev.CoordinateSystems/GeographicAltitudeParser.cs b/TensionDev.CoordinateSystems/GeographicAltitudeParser.cs new file mode 100644 index 0000000..18975d3 --- /dev/null +++ b/TensionDev.CoordinateSystems/GeographicAltitudeParser.cs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2021 - 2026 TensionDev + +using System; +using System.Globalization; + +namespace TensionDev.CoordinateSystems +{ + internal static class GeographicAltitudeParser + { + private const double FeetToMetres = 0.3048; + + internal static bool TryExtractAltitude(ref string coordinateText, out double altitudeMetres) + { + altitudeMetres = 0.0; + int index = coordinateText.LastIndexOf("Altitude:", StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + return true; + } + + string[] parts = coordinateText.Substring(index + "Altitude:".Length) + .Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2 || + !double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || + !GeographicCoordinateSystem.IsFinite(value)) + { + return false; + } + + if (string.Equals(parts[1], "m", StringComparison.OrdinalIgnoreCase)) + { + altitudeMetres = value; + } + else if (string.Equals(parts[1], "ft", StringComparison.OrdinalIgnoreCase)) + { + altitudeMetres = value * FeetToMetres; + } + else + { + return false; + } + + if (!GeographicCoordinateSystem.IsFinite(altitudeMetres)) + { + return false; + } + + coordinateText = coordinateText.Substring(0, index).TrimEnd(); + return true; + } + } +} diff --git a/TensionDev.CoordinateSystems/GeographicConverter.cs b/TensionDev.CoordinateSystems/GeographicConverter.cs index 0df04c5..008af36 100644 --- a/TensionDev.CoordinateSystems/GeographicConverter.cs +++ b/TensionDev.CoordinateSystems/GeographicConverter.cs @@ -86,131 +86,126 @@ public static GeographicCoordinateSystem From(Geohash source) return BitNotationDivisions(bitNotation, length); } - /// - /// Converts geographic coordinates to a degrees and decimal minutes (DDM) representation. - /// - /// The geographic coordinates to convert. - /// A representing the same position in DDM. - public static GeographicDdm ToDdm(GeographicCoordinateSystem source) + /// Converts the canonical geographic model to decimal degrees. + public static GeographicDd ToDd(GeographicCoordinateSystem source) { - if (source == null) + if (source == null) throw new ArgumentNullException(nameof(source)); + return new GeographicDd { - throw new ArgumentNullException(nameof(source)); - } - - (double latDegrees, double latMinutes, char latDirection) = Decompose(source.LatitudeDecimalDegrees, true); - (double lonDegrees, double lonMinutes, char lonDirection) = Decompose(source.LongitudeDecimalDegrees, false); + LatitudeDecimalDegrees = source.LatitudeDecimalDegrees, + LongitudeDecimalDegrees = source.LongitudeDecimalDegrees, + AltitudeMetres = source.AltitudeMetres, + }; + } - return new GeographicDdm + /// Converts decimal-degree coordinates to the canonical radians-based model. + public static GeographicCoordinateSystem From(GeographicDd source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + return new GeographicCoordinateSystem { - LatitudeDegrees = latDegrees, - LatitudeMinutes = latMinutes, - LatitudeDirection = latDirection, - LongitudeDegrees = lonDegrees, - LongitudeMinutes = lonMinutes, - LongitudeDirection = lonDirection, + LatitudeDecimalDegrees = source.LatitudeDecimalDegrees, + LongitudeDecimalDegrees = source.LongitudeDecimalDegrees, + AltitudeMetres = source.AltitudeMetres, }; } - /// - /// Converts geographic coordinates to a degrees, minutes, and seconds (DMS) representation. - /// - /// The geographic coordinates to convert. - /// A representing the same position in DMS. - public static GeographicDms ToDms(GeographicCoordinateSystem source) + /// Converts DDM coordinates to decimal degrees. + public static GeographicDd ToDd(GeographicDdm source) { - if (source == null) + if (source == null) throw new ArgumentNullException(nameof(source)); + return new GeographicDd { - throw new ArgumentNullException(nameof(source)); - } + LatitudeDecimalDegrees = Recompose(source.LatitudeDegrees, source.LatitudeMinutes, source.LatitudeDirection, true), + LongitudeDecimalDegrees = Recompose(source.LongitudeDegrees, source.LongitudeMinutes, source.LongitudeDirection, false), + AltitudeMetres = source.AltitudeMetres, + }; + } - (double latDegrees, double latMinutes, double latSeconds, char latDirection) = DecomposeWithSeconds(source.LatitudeDecimalDegrees, true); - (double lonDegrees, double lonMinutes, double lonSeconds, char lonDirection) = DecomposeWithSeconds(source.LongitudeDecimalDegrees, false); + /// Converts DMS coordinates to decimal degrees through DDM. + public static GeographicDd ToDd(GeographicDms source) => ToDd(ToDdm(source)); - return new GeographicDms + /// Converts a geographic coordinate to DDM. + public static GeographicDdm ToDdm(GeographicCoordinateSystem source) => ToDdm(ToDd(source)); + + /// Converts decimal-degree coordinates to DDM. + public static GeographicDdm ToDdm(GeographicDd source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + (double latDegrees, double latMinutes, char latDirection) = Decompose(source.LatitudeDecimalDegrees, true); + (double lonDegrees, double lonMinutes, char lonDirection) = Decompose(source.LongitudeDecimalDegrees, false); + return new GeographicDdm { LatitudeDegrees = latDegrees, LatitudeMinutes = latMinutes, - LatitudeSeconds = latSeconds, LatitudeDirection = latDirection, LongitudeDegrees = lonDegrees, LongitudeMinutes = lonMinutes, - LongitudeSeconds = lonSeconds, LongitudeDirection = lonDirection, + AltitudeMetres = source.AltitudeMetres, }; } - /// - /// Converts a degrees and decimal minutes (DDM) representation to geographic coordinates. - /// - /// The DDM representation to convert. - /// The converted geographic coordinates. - public static GeographicCoordinateSystem FromDdm(GeographicDdm source) + /// Converts DMS coordinates to DDM. + public static GeographicDdm ToDdm(GeographicDms source) { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - double latitudeDecimalDegrees = Recompose(source.LatitudeDegrees, source.LatitudeMinutes, source.LatitudeDirection, true); - double longitudeDecimalDegrees = Recompose(source.LongitudeDegrees, source.LongitudeMinutes, source.LongitudeDirection, false); - - return new GeographicCoordinateSystem + if (source == null) throw new ArgumentNullException(nameof(source)); + return new GeographicDdm { - LatitudeDecimalDegrees = latitudeDecimalDegrees, - LongitudeDecimalDegrees = longitudeDecimalDegrees, + LatitudeDegrees = source.LatitudeDegrees, + LatitudeMinutes = source.LatitudeMinutes + source.LatitudeSeconds / 60.0, + LatitudeDirection = source.LatitudeDirection, + LongitudeDegrees = source.LongitudeDegrees, + LongitudeMinutes = source.LongitudeMinutes + source.LongitudeSeconds / 60.0, + LongitudeDirection = source.LongitudeDirection, + AltitudeMetres = source.AltitudeMetres, }; } - /// - /// Converts a degrees, minutes, and seconds (DMS) representation to geographic coordinates. - /// - /// The DMS representation to convert. - /// The converted geographic coordinates. - public static GeographicCoordinateSystem FromDms(GeographicDms source) + /// Converts a geographic coordinate to DMS. + public static GeographicDms ToDms(GeographicCoordinateSystem source) => ToDms(ToDd(source)); + + /// Converts decimal-degree coordinates to DMS through DDM. + public static GeographicDms ToDms(GeographicDd source) => ToDms(ToDdm(source)); + + /// Converts DDM coordinates to DMS. + public static GeographicDms ToDms(GeographicDdm source) { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } + if (source == null) throw new ArgumentNullException(nameof(source)); - double latitudeDecimalDegrees = RecomposeWithSeconds(source.LatitudeDegrees, source.LatitudeMinutes, source.LatitudeSeconds, source.LatitudeDirection, true); - double longitudeDecimalDegrees = RecomposeWithSeconds(source.LongitudeDegrees, source.LongitudeMinutes, source.LongitudeSeconds, source.LongitudeDirection, false); + double latitudeWholeMinutes = Math.Truncate(source.LatitudeMinutes); + double longitudeWholeMinutes = Math.Truncate(source.LongitudeMinutes); - return new GeographicCoordinateSystem + return new GeographicDms { - LatitudeDecimalDegrees = latitudeDecimalDegrees, - LongitudeDecimalDegrees = longitudeDecimalDegrees, + LatitudeDegrees = source.LatitudeDegrees, + LatitudeMinutes = latitudeWholeMinutes, + LatitudeSeconds = (source.LatitudeMinutes - latitudeWholeMinutes) * 60.0, + LatitudeDirection = source.LatitudeDirection, + LongitudeDegrees = source.LongitudeDegrees, + LongitudeMinutes = longitudeWholeMinutes, + LongitudeSeconds = (source.LongitudeMinutes - longitudeWholeMinutes) * 60.0, + LongitudeDirection = source.LongitudeDirection, + AltitudeMetres = source.AltitudeMetres, }; } - private static double Recompose(double degrees, double minutes, char direction, bool isLatitude) - { - double absoluteDegrees = degrees + minutes / 60.0; - - if (isLatitude && direction == 'S') - { - return -absoluteDegrees; - } - - if (!isLatitude && direction == 'W') - { - return -absoluteDegrees; - } + /// Converts DDM coordinates to the canonical geographic model. + public static GeographicCoordinateSystem FromDdm(GeographicDdm source) => From(ToDd(source)); - return absoluteDegrees; - } + /// Converts DMS coordinates to the canonical geographic model. + public static GeographicCoordinateSystem FromDms(GeographicDms source) => From(ToDd(source)); - private static double RecomposeWithSeconds(double degrees, double minutes, double seconds, char direction, bool isLatitude) + private static double Recompose(double degrees, double minutes, char direction, bool isLatitude) { - double absoluteDegrees = degrees + minutes / 60.0 + seconds / 3600.0; + double absoluteDegrees = degrees + minutes / 60.0; - if (isLatitude && direction == 'S') + if (isLatitude && char.ToUpperInvariant(direction) == 'S') { return -absoluteDegrees; } - if (!isLatitude && direction == 'W') + if (!isLatitude && char.ToUpperInvariant(direction) == 'W') { return -absoluteDegrees; } @@ -228,28 +223,11 @@ private static (double degrees, double minutes, char direction) Decompose(double char direction = isLatitude ? (isNegative ? 'S' : 'N') : - (degreesInt == 180 ? 'W' : (isNegative ? 'W' : 'E')); + (isNegative ? 'W' : 'E'); return ((double)degreesInt, minutes, direction); } - private static (double degrees, double minutes, double seconds, char direction) DecomposeWithSeconds(double degrees, bool isLatitude) - { - bool isNegative = degrees < 0; - double absDegrees = Math.Abs(degrees); - - int degreesInt = (int)absDegrees; - double minutesDouble = (absDegrees - degreesInt) * 60.0; - int minutesInt = (int)minutesDouble; - double seconds = (minutesDouble - minutesInt) * 60.0; - - char direction = isLatitude ? - (isNegative ? 'S' : 'N') : - (degreesInt == 180 ? 'W' : (isNegative ? 'W' : 'E')); - - return ((double)degreesInt, (double)minutesInt, seconds, direction); - } - private static GeographicCoordinateSystem FromPolarAxis(double z, double longitude) { if (z == 0.0) diff --git a/TensionDev.CoordinateSystems/GeographicCoordinateSystem.cs b/TensionDev.CoordinateSystems/GeographicCoordinateSystem.cs index a50a027..2c225ee 100644 --- a/TensionDev.CoordinateSystems/GeographicCoordinateSystem.cs +++ b/TensionDev.CoordinateSystems/GeographicCoordinateSystem.cs @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 // // Copyright 2021 - 2026 TensionDev // @@ -15,7 +15,6 @@ // limitations under the License. using System; -using System.Collections.Generic; using System.Globalization; using System.Text; @@ -28,19 +27,27 @@ public class GeographicCoordinateSystem { private const double FEET_TO_METRES = 0.3048; - private double _latitude; - private double _longitude; + private double _latitudeRadians; + private double _longitudeRadians; private double _altitude; /// /// Latitude in Radians /// - public Double LatitudeDecimalRadians { get => _latitude * Math.PI / 180.0; set => LatitudeDecimalDegrees = value * 180.0 / Math.PI; } + public Double LatitudeDecimalRadians + { + get => _latitudeRadians; + set => _latitudeRadians = Math.Max(Math.Min(value, Math.PI / 2.0), -Math.PI / 2.0); + } /// /// Longitude in Radians /// - public Double LongitudeDecimalRadians { get => _longitude * Math.PI / 180.0; set => LongitudeDecimalDegrees = value * 180.0 / Math.PI; } + public Double LongitudeDecimalRadians + { + get => _longitudeRadians; + set => _longitudeRadians = Math.Max(Math.Min(value, Math.PI), -Math.PI); + } /// /// Altitude in Feet @@ -50,12 +57,20 @@ public class GeographicCoordinateSystem /// /// Latitude in Degrees /// - public Double LatitudeDecimalDegrees { get => _latitude; set => _latitude = Math.Max(Math.Min(value, 90), -90); } + public Double LatitudeDecimalDegrees + { + get => _latitudeRadians * 180.0 / Math.PI; + set => LatitudeDecimalRadians = value * Math.PI / 180.0; + } /// /// Longitude in Degrees /// - public Double LongitudeDecimalDegrees { get => _longitude; set => _longitude = Math.Max(Math.Min(value, 180), -180); } + public Double LongitudeDecimalDegrees + { + get => _longitudeRadians * 180.0 / Math.PI; + set => LongitudeDecimalRadians = value * Math.PI / 180.0; + } /// /// Altitude in Metres @@ -95,7 +110,7 @@ internal string ToStringDecimalDegrees(DistanceUnit distanceUnit, int angularPre StringBuilder sb = new StringBuilder(); string angularFormat = "F" + angularPrecision; sb.Append($"{LatitudeDecimalDegrees.ToString(angularFormat, CultureInfo.InvariantCulture)}, {LongitudeDecimalDegrees.ToString(angularFormat, CultureInfo.InvariantCulture)}"); - sb.Append(ToStringAltitude(distanceUnit, distancePrecision)); + sb.Append(FormatAltitude(_altitude, distanceUnit, distancePrecision)); return sb.ToString(); } @@ -103,7 +118,7 @@ internal string ToStringDegreesDecimalMinutes(DistanceUnit distanceUnit, int ang { StringBuilder sb = new StringBuilder(); sb.Append($"{FormatDegreesDecimalMinutes(LatitudeDecimalDegrees, true)}, {FormatDegreesDecimalMinutes(LongitudeDecimalDegrees, false)}"); - sb.Append(ToStringAltitude(distanceUnit, distancePrecision)); + sb.Append(FormatAltitude(_altitude, distanceUnit, distancePrecision)); return sb.ToString(); } @@ -111,7 +126,7 @@ internal string ToStringDegreesMinutesSeconds(DistanceUnit distanceUnit, int ang { StringBuilder sb = new StringBuilder(); sb.Append($"{FormatDegreesMinutesSeconds(LatitudeDecimalDegrees, true)}, {FormatDegreesMinutesSeconds(LongitudeDecimalDegrees, false)}"); - sb.Append(ToStringAltitude(distanceUnit, distancePrecision)); + sb.Append(FormatAltitude(_altitude, distanceUnit, distancePrecision)); return sb.ToString(); } @@ -119,12 +134,12 @@ internal string ToStringRadians(DistanceUnit distanceUnit, int angularPrecision, { StringBuilder sb = new StringBuilder(); string angularFormat = "F" + angularPrecision; - sb.Append($"{LatitudeDecimalRadians.ToString(angularFormat, CultureInfo.InvariantCulture)}, {LongitudeDecimalRadians.ToString(angularFormat, CultureInfo.InvariantCulture)}"); - sb.Append(ToStringAltitude(distanceUnit, distancePrecision)); + sb.Append($"{LatitudeDecimalRadians.ToString(angularFormat, CultureInfo.InvariantCulture)} rad, {LongitudeDecimalRadians.ToString(angularFormat, CultureInfo.InvariantCulture)} rad"); + sb.Append(FormatAltitude(_altitude, distanceUnit, distancePrecision)); return sb.ToString(); } - internal string ToStringAltitude(DistanceUnit distanceUnit, int distancePrecision) + internal static string FormatAltitude(double altitudeMetres, DistanceUnit distanceUnit, int distancePrecision) { string unitSymbol; double altitudeValue; @@ -133,11 +148,11 @@ internal string ToStringAltitude(DistanceUnit distanceUnit, int distancePrecisio { case DistanceUnit.Metres: unitSymbol = "m"; - altitudeValue = _altitude; + altitudeValue = altitudeMetres; break; case DistanceUnit.Feet: unitSymbol = "ft"; - altitudeValue = _altitude / FEET_TO_METRES; + altitudeValue = altitudeMetres / FEET_TO_METRES; break; default: throw new NotImplementedException(); @@ -179,5 +194,333 @@ private static string FormatDegreesMinutesSeconds(double degrees, bool isLatitud return $"{degreesInt}° {minutes}' {seconds:F2}\" {direction}"; } + + /// + /// Parses a geographic coordinate string into a instance. + /// Supports multiple angular formats: Decimal Degrees, DDM (Degrees-Decimal Minutes), DMS (Degrees-Minutes-Seconds), and Radians. + /// Altitude is optional with suffix "Altitude: X.XXX m" or "Altitude: X.XXX ft". + /// + /// A string in geographic format. + /// A parsed from the string. + /// Thrown when the string is null. + /// Thrown when the string is not in a valid geographic format. + public static GeographicCoordinateSystem Parse(string s) + { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + + if (string.IsNullOrWhiteSpace(s)) + { + throw new FormatException("Input string was null or whitespace."); + } + + if (!TryParse(s, out var result)) + { + throw new FormatException($"String '{s}' is not in a valid geographic format."); + } + + return result; + } + + /// + /// Attempts to parse a geographic coordinate string into a instance. + /// + /// A string in geographic format. + /// When this method returns, contains the parsed or default on failure. + /// True if the string was parsed successfully; false otherwise. + public static bool TryParse(string s, out GeographicCoordinateSystem result) + { + result = null; + + if (s == null || string.IsNullOrWhiteSpace(s)) + { + return false; + } + + string body = s.Trim(); + if (!GeographicAltitudeParser.TryExtractAltitude(ref body, out double altitudeMetres)) + { + return false; + } + var parsed = new GeographicCoordinateSystem { AltitudeMetres = altitudeMetres }; + + // Detect angular format by examining the string structure + if (body.IndexOf(',') < 0) + { + return false; + } + + int commaIdx = body.IndexOf(','); + string leftPart = body.Substring(0, commaIdx).Trim(); + string rightPart = body.Substring(commaIdx + 1).Trim(); + + // Check for degree symbol to determine DDM vs DMS format + bool hasDegreeSymbolLeft = HasSpecialChar(leftPart, '°'); + bool hasMinuteSymbolLeft = HasSpecialChar(leftPart, '\''); + bool hasSecondQuoteLeft = HasSpecialChar(leftPart, '"'); + + bool hasDegreeSymbolRight = HasSpecialChar(rightPart, '°'); + bool hasMinuteSymbolRight = HasSpecialChar(rightPart, '\''); + bool hasSecondQuoteRight = HasSpecialChar(rightPart, '"'); + + // Detect direction character index + int leftDirIdx = GetDirectionIndex(leftPart); + int rightDirIdx = GetDirectionIndex(rightPart); + + if (hasDegreeSymbolLeft || hasDegreeSymbolRight || hasMinuteSymbolLeft || hasMinuteSymbolRight || leftDirIdx >= 0 || rightDirIdx >= 0) + { + // Direction-delimited or symbol-delimited DDM/DMS formats. + double latResult = ParseCoordinatePart(leftPart, true, leftDirIdx); + double lonResult = ParseCoordinatePart(rightPart, false, rightDirIdx); + + // Check for sentinel values indicating parse failure (missing direction, invalid format) + if (!IsFinite(latResult) || !IsFinite(lonResult) || + latResult < -90.0 || latResult > 90.0 || lonResult < -180.0 || lonResult > 180.0) + return false; + + parsed.LatitudeDecimalDegrees = latResult; + parsed.LongitudeDecimalDegrees = lonResult; + result = parsed; + return true; + } + + // Radians must be explicit. Bare numeric pairs are decimal degrees. + bool leftHasRadians = leftPart.TrimEnd().EndsWith("rad", StringComparison.OrdinalIgnoreCase); + bool rightHasRadians = rightPart.TrimEnd().EndsWith("rad", StringComparison.OrdinalIgnoreCase); + if (leftHasRadians != rightHasRadians) + { + return false; + } + + string leftNumeric = leftHasRadians ? leftPart.Trim().Substring(0, leftPart.Trim().Length - 3).Trim() : leftPart; + string rightNumeric = rightHasRadians ? rightPart.Trim().Substring(0, rightPart.Trim().Length - 3).Trim() : rightPart; + if (double.TryParse(leftNumeric, NumberStyles.Float, CultureInfo.InvariantCulture, out double latNumeric) && + double.TryParse(rightNumeric, NumberStyles.Float, CultureInfo.InvariantCulture, out double lonNumeric) && + !double.IsNaN(latNumeric) && !double.IsInfinity(latNumeric) && + !double.IsNaN(lonNumeric) && !double.IsInfinity(lonNumeric)) + { + if (leftHasRadians) + { + if (latNumeric < -Math.PI / 2.0 || latNumeric > Math.PI / 2.0 || + lonNumeric < -Math.PI || lonNumeric > Math.PI) + { + return false; + } + + parsed.LatitudeDecimalRadians = latNumeric; + parsed.LongitudeDecimalRadians = lonNumeric; + result = parsed; + return true; + } + + if (latNumeric < -90.0 || latNumeric > 90.0 || + lonNumeric < -180.0 || lonNumeric > 180.0) + { + return false; + } + + parsed.LatitudeDecimalDegrees = latNumeric; + parsed.LongitudeDecimalDegrees = lonNumeric; + result = parsed; + return true; + } + + return false; + } + + private static bool HasSpecialChar(string part, char c) + { + if (part == null || string.IsNullOrEmpty(part)) + return false; + + int idx = part.IndexOf(c); + return idx >= 0; + } + + private static int GetDirectionIndex(string part) + { + if (part == null || string.IsNullOrEmpty(part)) + return -1; + + int index = part.TrimEnd().Length - 1; + return index >= 0 && IsValidDirection(part[index]) ? index : -1; + } + + private static double ParseCoordinatePart(string part, bool isLatitude, int dirIdx) + { + if (string.IsNullOrWhiteSpace(part)) + { + return double.MaxValue; + } + + string trimmed = part.Trim(); + string[] tokens = trimmed.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 2) + { + return double.MaxValue; + } + + char direction = IsValidDirectionChar(tokens[tokens.Length - 1][0]); + if (tokens[tokens.Length - 1].Length != 1 || !IsDirectionValidForAxis(direction, isLatitude)) + { + return double.MaxValue; + } + + double degrees; + double minutes = 0.0; + double seconds = 0.0; + bool isDdm = false; + bool isDms = false; + + if (trimmed.Contains("°")) + { + int degreeIndex = trimmed.IndexOf('°'); + int minuteIndex = trimmed.IndexOf('\''); + if (degreeIndex < 0 || minuteIndex <= degreeIndex) + { + return double.MaxValue; + } + + string degreeText = trimmed.Substring(0, degreeIndex).Trim(); + string minuteText = trimmed.Substring(degreeIndex + 1, minuteIndex - degreeIndex - 1).Trim(); + int secondIndex = trimmed.LastIndexOf('"'); + + if (!double.TryParse(degreeText, NumberStyles.Float, CultureInfo.InvariantCulture, out degrees)) + { + return double.MaxValue; + } + + if (secondIndex > minuteIndex) + { + string secondsText = trimmed.Substring(minuteIndex + 1, secondIndex - minuteIndex - 1).Trim(); + minuteText = minuteText.TrimEnd(); + if (!double.TryParse(minuteText, NumberStyles.Float, CultureInfo.InvariantCulture, out minutes) || + !double.TryParse(secondsText, NumberStyles.Float, CultureInfo.InvariantCulture, out seconds)) + { + return double.MaxValue; + } + isDms = true; + } + else + { + if (!double.TryParse(minuteText, NumberStyles.Float, CultureInfo.InvariantCulture, out minutes)) + { + return double.MaxValue; + } + isDdm = true; + } + } + else + { + // Drop the final direction token; the remaining tokens determine DD, DDM, or DMS. + int componentCount = tokens.Length - 1; + if (componentCount == 1) + { + if (!double.TryParse(tokens[0], NumberStyles.Float, CultureInfo.InvariantCulture, out degrees)) + { + return double.MaxValue; + } + } + else if (componentCount == 2) + { + if (!double.TryParse(tokens[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out degrees) || + !double.TryParse(tokens[1], NumberStyles.Float, CultureInfo.InvariantCulture, out minutes)) + { + return double.MaxValue; + } + isDdm = true; + } + else if (componentCount == 3) + { + if (!double.TryParse(tokens[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out degrees) || + !double.TryParse(tokens[1], NumberStyles.Float, CultureInfo.InvariantCulture, out minutes) || + !double.TryParse(tokens[2], NumberStyles.Float, CultureInfo.InvariantCulture, out seconds)) + { + return double.MaxValue; + } + isDms = true; + } + else + { + return double.MaxValue; + } + } + + double maximumDegrees = isLatitude ? 90.0 : 180.0; + if (degrees < 0.0 || degrees > maximumDegrees || + (isDdm && (minutes < 0.0 || minutes > 60.0)) || + (isDms && (minutes < 0.0 || minutes > 60.0 || seconds < 0.0 || seconds > 60.0)) || + (degrees == maximumDegrees && (minutes != 0.0 || seconds != 0.0))) + { + return double.MaxValue; + } + + double value = degrees; + if (isDdm) + { + value += minutes / 60.0; + } + else if (isDms) + { + value += minutes / 60.0 + seconds / 3600.0; + } + + return direction == 'S' || direction == 'W' ? -value : value; + } + + private static bool IsDirectionValidForAxis(char direction, bool isLatitude) + { + return isLatitude + ? IsValidLatitudeDirection(direction) + : IsValidLongitudeDirection(direction); + } + private static double ConvertDmsToDecimalDegrees(double degrees, double minutes, double seconds) + { + return degrees + minutes / 60.0 + seconds / 3600.0; + } + + private static double ConvertDdmToDecimalDegrees(double degrees, double minutes) + { + return degrees + minutes / 60.0; + } + + private static double ApplyDirection(double value, char direction) + { + // For latitude: negative values indicate south (S) + // For longitude: negative values indicate west (W) + if (direction == 's' || direction == 'S') + return -Math.Abs(value); + if (direction == 'w' || direction == 'W') + return -Math.Abs(value); + return value; + } + + internal static bool IsValidDirection(char c) + { + return IsValidLatitudeDirection(c) || IsValidLongitudeDirection(c); + } + + internal static bool IsValidLatitudeDirection(char c) + { + char direction = char.ToUpperInvariant(c); + return direction == 'N' || direction == 'S'; + } + + internal static bool IsValidLongitudeDirection(char c) + { + char direction = char.ToUpperInvariant(c); + return direction == 'E' || direction == 'W'; + } + + internal static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + + internal static char IsValidDirectionChar(char c) + { + if (IsValidDirection(c)) + return char.ToUpperInvariant(c); + return '\0'; + } } } diff --git a/TensionDev.CoordinateSystems/GeographicDd.cs b/TensionDev.CoordinateSystems/GeographicDd.cs new file mode 100644 index 0000000..12117f4 --- /dev/null +++ b/TensionDev.CoordinateSystems/GeographicDd.cs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 2021 - 2026 TensionDev +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Globalization; + +namespace TensionDev.CoordinateSystems +{ + /// + /// A geographic coordinate represented in decimal degrees (DD), with altitude in metres. + /// + public class GeographicDd + { + private const double FeetToMetres = 0.3048; + private double _latitudeDecimalDegrees; + private double _longitudeDecimalDegrees; + private double _altitudeMetres; + + /// Latitude in decimal degrees, clamped to [-90, 90]. + public double LatitudeDecimalDegrees + { + get => _latitudeDecimalDegrees; + set => _latitudeDecimalDegrees = Math.Max(-90.0, Math.Min(90.0, value)); + } + + /// Longitude in decimal degrees, clamped to [-180, 180]. + public double LongitudeDecimalDegrees + { + get => _longitudeDecimalDegrees; + set => _longitudeDecimalDegrees = Math.Max(-180.0, Math.Min(180.0, value)); + } + + /// Altitude in metres. + public double AltitudeMetres + { + get => _altitudeMetres; + set => _altitudeMetres = value; + } + + /// Altitude in feet. + public double AltitudeFeet + { + get => _altitudeMetres / FeetToMetres; + set => _altitudeMetres = value * FeetToMetres; + } + + /// Returns this coordinate in decimal degrees with altitude in metres. + public override string ToString() + { + return string.Format( + CultureInfo.InvariantCulture, + "{0:F6}, {1:F6}{2}", + LatitudeDecimalDegrees, + LongitudeDecimalDegrees, + GeographicCoordinateSystem.FormatAltitude(AltitudeMetres, DistanceUnit.Metres, 3)); + } + + /// Parses a decimal-degree coordinate string. + /// The input is null. + /// The input is malformed or outside the geographic range. + public static GeographicDd Parse(string s) + { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + + if (!TryParse(s, out GeographicDd result)) + { + throw new FormatException($"String '{s}' is not in a valid decimal-degree format."); + } + + return result; + } + + /// Attempts to parse a decimal-degree coordinate string. + public static bool TryParse(string s, out GeographicDd result) + { + result = null; + if (string.IsNullOrWhiteSpace(s)) + { + return false; + } + + string body = s.Trim(); + double altitudeMetres = 0.0; + int altitudeIndex = body.LastIndexOf("Altitude:", StringComparison.OrdinalIgnoreCase); + if (altitudeIndex >= 0) + { + if (!TryParseAltitude(body.Substring(altitudeIndex + "Altitude:".Length), out altitudeMetres)) + { + return false; + } + body = body.Substring(0, altitudeIndex).TrimEnd(); + } + + int commaIndex = body.IndexOf(','); + if (commaIndex < 0 || body.IndexOf(',', commaIndex + 1) >= 0) + { + return false; + } + + string latitudeText = body.Substring(0, commaIndex).Trim(); + string longitudeText = body.Substring(commaIndex + 1).Trim(); + if (!double.TryParse(latitudeText, NumberStyles.Float, CultureInfo.InvariantCulture, out double latitude) || + !double.TryParse(longitudeText, NumberStyles.Float, CultureInfo.InvariantCulture, out double longitude) || + !GeographicCoordinateSystem.IsFinite(latitude) || !GeographicCoordinateSystem.IsFinite(longitude) || + latitude < -90.0 || latitude > 90.0 || + longitude < -180.0 || longitude > 180.0) + { + return false; + } + + result = new GeographicDd + { + LatitudeDecimalDegrees = latitude, + LongitudeDecimalDegrees = longitude, + AltitudeMetres = altitudeMetres, + }; + return true; + } + + private static bool TryParseAltitude(string text, out double altitudeMetres) + { + altitudeMetres = 0.0; + string[] parts = text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 1 || parts.Length > 2 || + !double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || + !GeographicCoordinateSystem.IsFinite(value)) + { + return false; + } + + if (parts.Length == 1 || string.Equals(parts[1], "m", StringComparison.OrdinalIgnoreCase)) + { + altitudeMetres = value; + return true; + } + + if (string.Equals(parts[1], "ft", StringComparison.OrdinalIgnoreCase)) + { + altitudeMetres = value * FeetToMetres; + return GeographicCoordinateSystem.IsFinite(altitudeMetres); + } + + return false; + } + + } +} diff --git a/TensionDev.CoordinateSystems/GeographicDdm.cs b/TensionDev.CoordinateSystems/GeographicDdm.cs index 14cf42b..5d194ef 100644 --- a/TensionDev.CoordinateSystems/GeographicDdm.cs +++ b/TensionDev.CoordinateSystems/GeographicDdm.cs @@ -27,6 +27,16 @@ public class GeographicDdm private double _latitudeDegrees; private double _longitudeDegrees; + /// Altitude in metres. + public double AltitudeMetres { get; set; } + + /// Altitude in feet. + public double AltitudeFeet + { + get => AltitudeMetres / 0.3048; + set => AltitudeMetres = value * 0.3048; + } + /// /// Latitude degrees (0-90). Always non-negative; direction is indicated by . /// @@ -110,12 +120,243 @@ public string ToString(int precision) if (Math.Abs(lonMinutes) < precisionUnit) lonMinutes = 0.0; // avoid -0.000000 } - return FormatDdm(latDegrees, latMinutes, latDirection, precisionFormat) + ", " + FormatDdm(lonDegrees, lonMinutes, lonDirection, precisionFormat); + string text = FormatDdm(latDegrees, latMinutes, latDirection, precisionFormat) + ", " + FormatDdm(lonDegrees, lonMinutes, lonDirection, precisionFormat); + return text + GeographicCoordinateSystem.FormatAltitude(AltitudeMetres, DistanceUnit.Metres, 3); } private static string FormatDdm(double degrees, double minutes, char direction, string precisionFormat) { return degrees + "° " + minutes.ToString(precisionFormat, CultureInfo.InvariantCulture) + "' " + direction; } + + /// + /// Parses a DDM-formatted string into a instance. + /// + /// A string in DDM format (e.g., "52° 31.20' N, 13° 24.30' E" or with optional altitude suffix). + /// A parsed from the string. + /// Thrown when the string is null. + /// Thrown when the string is not in a valid DDM format or values are out of range. + public static GeographicDdm Parse(string s) + { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + + if (string.IsNullOrWhiteSpace(s)) + { + throw new FormatException("Input string was null or whitespace."); + } + + if (!TryParse(s, out var result)) + { + throw new FormatException($"String '{s}' is not in a valid DDM format."); + } + + return result; + } + + /// + /// Attempts to parse a DDM-formatted string into a instance. + /// + /// A string in DDM format. + /// When this method returns, contains the parsed or default on failure. + /// True if the string was parsed successfully; false otherwise. + public static bool TryParse(string s, out GeographicDdm result) + { + result = default; + + if (s == null || string.IsNullOrWhiteSpace(s)) + { + return false; + } + + string body = s.Trim(); + if (!GeographicAltitudeParser.TryExtractAltitude(ref body, out double altitudeMetres)) + { + return false; + } + var parsed = new GeographicDdm(); + + // Parse the DDM body: "lat deg min' dir, lon deg min' dir" + // Split by comma to separate latitude and longitude + int commaIdx = body.IndexOf(','); + if (commaIdx < 0) + { + return false; + } + + string latPart = body.Substring(0, commaIdx).Trim(); + string lonPart = body.Substring(commaIdx + 1).Trim(); + + if (!TryParseDdmCoordinate(latPart, isLatitude: true, out double latDegrees, out double latMinutes, out char latDir)) + { + return false; + } + + if (!TryParseDdmCoordinate(lonPart, isLatitude: false, out double lonDegrees, out double lonMinutes, out char lonDir)) + { + return false; + } + + // Normalize minutes >= 60 to carry over to degrees + if (latMinutes >= 60.0) + { + latDegrees += Math.Floor(latMinutes / 60.0); + latMinutes = latMinutes % 60.0; + } + + if (lonMinutes >= 60.0) + { + lonDegrees += Math.Floor(lonMinutes / 60.0); + lonMinutes = lonMinutes % 60.0; + } + + if (latDegrees < 0.0 || latDegrees > 90.0 || lonDegrees < 0.0 || lonDegrees > 180.0 || + (latDegrees == 90.0 && latMinutes != 0.0) || + (lonDegrees == 180.0 && lonMinutes != 0.0) || + !GeographicCoordinateSystem.IsFinite(latDegrees) || !GeographicCoordinateSystem.IsFinite(lonDegrees) || + !GeographicCoordinateSystem.IsFinite(latMinutes) || !GeographicCoordinateSystem.IsFinite(lonMinutes)) + { + return false; + } + + parsed.LatitudeDegrees = latDegrees; + parsed.LatitudeMinutes = latMinutes; + parsed.LatitudeDirection = latDir; + parsed.LongitudeDegrees = lonDegrees; + parsed.LongitudeMinutes = lonMinutes; + parsed.LongitudeDirection = lonDir; + parsed.AltitudeMetres = altitudeMetres; + result = parsed; + + return true; + } + + internal static bool TryParseDdmCoordinate(string part, bool isLatitude, out double degrees, out double minutes, out char direction) + { + degrees = 0; + minutes = 0; + direction = '\0'; + + if (string.IsNullOrWhiteSpace(part)) + { + return false; + } + + // Try symbol-delimited format first: "52° 31.20' N" + // Pattern: ° ' + int degSymbolIdx = part.IndexOf('°'); + int minSymbolIdx = part.IndexOf('\''); + int dirIdx = -1; + + if (degSymbolIdx >= 0 && minSymbolIdx > degSymbolIdx) + { + // Check for direction character after the minute symbol + var remainingAfterMinutes = part.Substring(minSymbolIdx + 1).Trim(); + dirIdx = remainingAfterMinutes.IndexOfAny(new[] { 'N', 'n', 'S', 's', 'E', 'e', 'W', 'w' }); + } + + if (dirIdx >= 0 && degSymbolIdx >= 0 && minSymbolIdx > degSymbolIdx) + { + string degStr = part.Substring(0, degSymbolIdx).Trim(); + string minStr = part.Substring(degSymbolIdx + 1, minSymbolIdx - degSymbolIdx - 1).Trim(); + char potentialDir; + + // Check which direction character is at the end + int lastNOrS = part.LastIndexOfAny(new[] { 'N', 'n', 'S', 's' }); + int lastEOrW = part.LastIndexOfAny(new[] { 'E', 'e', 'W', 'w' }); + + if (isLatitude) + { + // Latitude must end with N or S + if (lastNOrS <= minSymbolIdx) return false; // Missing direction + char foundDir = part[lastNOrS]; + // Validate the found direction is actually N or S (not E or W mistakenly picked up) + if (!GeographicCoordinateSystem.IsValidLatitudeDirection(foundDir)) return false; + potentialDir = foundDir; + direction = char.ToUpperInvariant(potentialDir); + } + else + { + // Longitude must end with E or W + if (lastEOrW <= minSymbolIdx) return false; // Missing direction + char foundDir = part[lastEOrW]; + // Validate the found direction is actually E or W (not N or S mistakenly picked up) + if (!GeographicCoordinateSystem.IsValidLongitudeDirection(foundDir)) return false; + potentialDir = foundDir; + direction = char.ToUpperInvariant(potentialDir); + } + + if (!double.TryParse(degStr, NumberStyles.Float, CultureInfo.InvariantCulture, out degrees)) + { + return false; + } + + if (!double.TryParse(minStr, NumberStyles.Float, CultureInfo.InvariantCulture, out minutes)) + { + return false; + } + + if (!GeographicCoordinateSystem.IsFinite(degrees) || !GeographicCoordinateSystem.IsFinite(minutes) || + degrees < 0.0 || minutes < 0.0 || minutes > 60.0 || + degrees > (isLatitude ? 90.0 : 180.0)) + return false; + + return true; + } + + // Try space-delimited format: "52 31.20 N" + // Pattern: + var parts = part.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 3) + { + string degStr = parts[0]; + string minStr = parts[1]; + string dirStr = parts[2]; + + char potentialDir; + + // Validate direction character based on coordinate type + if (isLatitude) + { + // Latitude must be N or S + if (!GeographicCoordinateSystem.IsValidLatitudeDirection(dirStr[0])) return false; + potentialDir = dirStr[0]; + direction = char.ToUpperInvariant(potentialDir); + } + else + { + // Longitude must be E or W + if (!GeographicCoordinateSystem.IsValidLongitudeDirection(dirStr[0])) return false; + potentialDir = dirStr[0]; + direction = char.ToUpperInvariant(potentialDir); + } + + // Degrees must be an integer in space-delimited format + if (!double.TryParse(degStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out double parsedDeg)) + { + return false; + } + degrees = parsedDeg; + + if (!double.TryParse(minStr, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedMin)) + { + return false; + } + minutes = parsedMin; + + // Validate minutes range >= 0 per ADR-0008 Section 93-94 (upper bound checked during normalization) + if (dirStr.Length != 1 || !GeographicCoordinateSystem.IsFinite(degrees) || + !GeographicCoordinateSystem.IsFinite(minutes) || degrees < 0.0 || minutes < 0.0 || minutes > 60.0 || + degrees > (isLatitude ? 90.0 : 180.0)) + return false; + + return true; + } + + return false; + } + } } diff --git a/TensionDev.CoordinateSystems/GeographicDms.cs b/TensionDev.CoordinateSystems/GeographicDms.cs index 0b8fdcd..443a2ee 100644 --- a/TensionDev.CoordinateSystems/GeographicDms.cs +++ b/TensionDev.CoordinateSystems/GeographicDms.cs @@ -27,6 +27,16 @@ public class GeographicDms private double _latitudeDegrees; private double _longitudeDegrees; + /// Altitude in metres. + public double AltitudeMetres { get; set; } + + /// Altitude in feet. + public double AltitudeFeet + { + get => AltitudeMetres / 0.3048; + set => AltitudeMetres = value * 0.3048; + } + /// /// Latitude degrees (0-90). Always non-negative; direction is indicated by . /// @@ -137,12 +147,280 @@ public string ToString(int precision) if (Math.Abs(lonMinutes) < precisionUnit) lonMinutes = 0.0; // avoid -0.000000 } - return FormatDms(latDegrees, latMinutes, latSeconds, latDirection, precisionFormat) + ", " + FormatDms(lonDegrees, lonMinutes, lonSeconds, lonDirection, precisionFormat); + string text = FormatDms(latDegrees, latMinutes, latSeconds, latDirection, precisionFormat) + ", " + FormatDms(lonDegrees, lonMinutes, lonSeconds, lonDirection, precisionFormat); + return text + GeographicCoordinateSystem.FormatAltitude(AltitudeMetres, DistanceUnit.Metres, 3); } private static string FormatDms(double degrees, double minutes, double seconds, char direction, string precisionFormat) { return degrees + "° " + minutes.ToString("F0", CultureInfo.InvariantCulture) + "' " + seconds.ToString(precisionFormat, CultureInfo.InvariantCulture) + "\" " + direction; } + + /// + /// Parses a DMS-formatted string into a instance. + /// + /// A string in DMS format (e.g., "52° 31' 12.00\" N, 13° 24' 18.00\" E" or with optional altitude suffix). + /// A parsed from the string. + /// Thrown when the string is null. + /// Thrown when the string is not in a valid DMS format or values are out of range. + public static GeographicDms Parse(string s) + { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + + if (string.IsNullOrWhiteSpace(s)) + { + throw new FormatException("Input string was null or whitespace."); + } + + if (!TryParse(s, out var result)) + { + throw new FormatException($"String '{s}' is not in a valid DMS format."); + } + + return result; + } + + /// + /// Attempts to parse a DMS-formatted string into a instance. + /// + /// A string in DMS format. + /// When this method returns, contains the parsed or default on failure. + /// True if the string was parsed successfully; false otherwise. + public static bool TryParse(string s, out GeographicDms result) + { + result = default; + + if (s == null || string.IsNullOrWhiteSpace(s)) + { + return false; + } + + string body = s.Trim(); + if (!GeographicAltitudeParser.TryExtractAltitude(ref body, out double altitudeMetres)) + { + return false; + } + var parsed = new GeographicDms(); + + // Parse the DMS body: "lat deg min sec" dir, lon deg min sec" dir" + // Split by comma to separate latitude and longitude + int commaIdx = body.IndexOf(','); + if (commaIdx < 0) + { + return false; + } + + string latPart = body.Substring(0, commaIdx).Trim(); + string lonPart = body.Substring(commaIdx + 1).Trim(); + + if (!TryParseDmsCoordinate(latPart, isLatitude: true, out double latDegrees, out double latMinutes, out double latSeconds, out char latDir)) + { + return false; + } + + if (!TryParseDmsCoordinate(lonPart, isLatitude: false, out double lonDegrees, out double lonMinutes, out double lonSeconds, out char lonDir)) + { + return false; + } + + // Normalize seconds >= 60 to carry over to minutes + if (latSeconds >= 60.0) + { + latMinutes += Math.Floor(latSeconds / 60.0); + latSeconds = latSeconds % 60.0; + } + + if (lonSeconds >= 60.0) + { + lonMinutes += Math.Floor(lonSeconds / 60.0); + lonSeconds = lonSeconds % 60.0; + } + + // Normalize minutes >= 60 to carry over to degrees + if (latMinutes >= 60.0) + { + latDegrees += Math.Floor(latMinutes / 60.0); + latMinutes = latMinutes % 60.0; + } + + if (lonMinutes >= 60.0) + { + lonDegrees += Math.Floor(lonMinutes / 60.0); + lonMinutes = lonMinutes % 60.0; + } + + if (latDegrees < 0.0 || latDegrees > 90.0 || lonDegrees < 0.0 || lonDegrees > 180.0 || + (latDegrees == 90.0 && (latMinutes != 0.0 || latSeconds != 0.0)) || + (lonDegrees == 180.0 && (lonMinutes != 0.0 || lonSeconds != 0.0)) || + !GeographicCoordinateSystem.IsFinite(latDegrees) || !GeographicCoordinateSystem.IsFinite(lonDegrees) || + !GeographicCoordinateSystem.IsFinite(latMinutes) || !GeographicCoordinateSystem.IsFinite(latSeconds) || + !GeographicCoordinateSystem.IsFinite(lonMinutes) || !GeographicCoordinateSystem.IsFinite(lonSeconds)) + { + return false; + } + + parsed.LatitudeDegrees = latDegrees; + parsed.LatitudeMinutes = latMinutes; + parsed.LatitudeSeconds = latSeconds; + parsed.LatitudeDirection = latDir; + parsed.LongitudeDegrees = lonDegrees; + parsed.LongitudeMinutes = lonMinutes; + parsed.LongitudeSeconds = lonSeconds; + parsed.LongitudeDirection = lonDir; + parsed.AltitudeMetres = altitudeMetres; + result = parsed; + + return true; + } + + internal static bool TryParseDmsCoordinate(string part, bool isLatitude, out double degrees, out double minutes, out double seconds, out char direction) + { + degrees = 0; + minutes = 0; + seconds = 0; + direction = '\0'; + + if (string.IsNullOrWhiteSpace(part)) + { + return false; + } + + // Try symbol-delimited format first: "52° 31' 12.00" N" + // Pattern: ° ' " + int degSymbolIdx = part.IndexOf('°'); + int minSymbolIdx = part.IndexOf('\''); + int secSymbolIdx = -1; + int dirIdx = -1; + + if (degSymbolIdx >= 0 && minSymbolIdx > degSymbolIdx) + { + secSymbolIdx = part.IndexOf('"', minSymbolIdx + 1); + if (secSymbolIdx >= 0) + { + dirIdx = part.IndexOfAny(new[] { 'N', 'n', 'S', 's', 'E', 'e', 'W', 'w' }, secSymbolIdx + 1); + } + } + + if (degSymbolIdx >= 0 && minSymbolIdx > degSymbolIdx && secSymbolIdx > minSymbolIdx && dirIdx > secSymbolIdx) + { + string degStr = part.Substring(0, degSymbolIdx).Trim(); + string minStr = part.Substring(degSymbolIdx + 1, minSymbolIdx - degSymbolIdx - 1).Trim(); + string secStr = part.Substring(minSymbolIdx + 1, secSymbolIdx - minSymbolIdx - 1).Trim(); + + if (!double.TryParse(degStr, NumberStyles.Float, CultureInfo.InvariantCulture, out degrees)) + { + return false; + } + + if (!double.TryParse(minStr, NumberStyles.Float, CultureInfo.InvariantCulture, out minutes)) + { + return false; + } + + if (!double.TryParse(secStr, NumberStyles.Float, CultureInfo.InvariantCulture, out seconds)) + { + return false; + } + + // Determine direction based on coordinate type + int lastNOrS = part.LastIndexOfAny(new[] { 'N', 'n', 'S', 's' }); + int lastEOrW = part.LastIndexOfAny(new[] { 'E', 'e', 'W', 'w' }); + + char potentialDir; + if (isLatitude) + { + // Latitude must end with N or S + if (lastNOrS <= secSymbolIdx) return false; // Missing direction + char foundDir = part[lastNOrS]; + // Validate the found direction is actually N or S (not E or W mistakenly picked up) + if (!GeographicCoordinateSystem.IsValidLatitudeDirection(foundDir)) return false; + potentialDir = foundDir; + direction = char.ToUpperInvariant(potentialDir); + } + else + { + // Longitude must end with E or W + if (lastEOrW <= secSymbolIdx) return false; // Missing direction + char foundDir = part[lastEOrW]; + // Validate the found direction is actually E or W (not N or S mistakenly picked up) + if (!GeographicCoordinateSystem.IsValidLongitudeDirection(foundDir)) return false; + potentialDir = foundDir; + direction = char.ToUpperInvariant(potentialDir); + } + + // Validate degrees range per ADR-0008 Section 91-92 + if (isLatitude && (degrees < -90 || degrees > 90)) + return false; + if (!isLatitude && (degrees < -180 || degrees > 180)) + return false; + + // Validate minutes and seconds range >= 0 per ADR-0008 Section 93-94 (upper bound checked during normalization) + if (!GeographicCoordinateSystem.IsFinite(degrees) || !GeographicCoordinateSystem.IsFinite(minutes) || + !GeographicCoordinateSystem.IsFinite(seconds) || degrees < 0.0 || minutes < 0.0 || seconds < 0.0 || + minutes > 60.0 || seconds > 60.0 || degrees > (isLatitude ? 90.0 : 180.0)) + return false; + + return true; + } + + // Try space-delimited format: "52 31 12 N" + // Pattern: + var parts = part.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 4) + { + string degStr = parts[0]; + string minStr = parts[1]; + string secStr = parts[2]; + string dirStr = parts[3]; + + // Degrees and minutes must be integers in space-delimited format + if (!double.TryParse(degStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out double parsedDeg)) + { + return false; + } + degrees = parsedDeg; + + if (!double.TryParse(minStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out double parsedMin)) + { + return false; + } + minutes = parsedMin; + + if (!double.TryParse(secStr, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSec)) + { + return false; + } + seconds = parsedSec; + + char potentialDir = dirStr[0]; + if (isLatitude && !GeographicCoordinateSystem.IsValidLatitudeDirection(potentialDir)) + return false; + if (!isLatitude && !GeographicCoordinateSystem.IsValidLongitudeDirection(potentialDir)) + return false; + + direction = char.ToUpperInvariant(potentialDir); + + // Validate degrees range per ADR-0008 Section 91-92 + if (isLatitude && (degrees < -90 || degrees > 90)) + return false; + if (!isLatitude && (degrees < -180 || degrees > 180)) + return false; + + // Validate minutes and seconds range >= 0 per ADR-0008 Section 93-94 (upper bound checked during normalization) + if (dirStr.Length != 1 || !GeographicCoordinateSystem.IsFinite(degrees) || + !GeographicCoordinateSystem.IsFinite(minutes) || !GeographicCoordinateSystem.IsFinite(seconds) || + degrees < 0.0 || minutes < 0.0 || seconds < 0.0 || minutes > 60.0 || seconds > 60.0 || + degrees > (isLatitude ? 90.0 : 180.0)) + return false; + + return true; + } + + return false; + } + } } diff --git a/TensionDev.CoordinateSystems/GeohashBase32.cs b/TensionDev.CoordinateSystems/GeohashBase32.cs index ba8ecfc..2a14d24 100644 --- a/TensionDev.CoordinateSystems/GeohashBase32.cs +++ b/TensionDev.CoordinateSystems/GeohashBase32.cs @@ -102,13 +102,9 @@ public static (BitArray bitNotation, UInt32 length) Decode(String hash) for (Int32 i = 0; i < hash.Length; ++i) { + // IsValid guarantees each character belongs to the Base32 alphabet. Int32 value = Alphabet.IndexOf(hash[i]); - if (value < 0) - { - throw new InvalidOperationException("GeoHash is not based on Geohash Base32."); - } - bitStream <<= BitsPerCharacter; bitStream += (UInt64)value; diff --git a/TensionDev.CoordinateSystems/GeohashConverter.cs b/TensionDev.CoordinateSystems/GeohashConverter.cs index 81202b4..a7aa5c0 100644 --- a/TensionDev.CoordinateSystems/GeohashConverter.cs +++ b/TensionDev.CoordinateSystems/GeohashConverter.cs @@ -41,6 +41,21 @@ public static Geohash From(GeographicCoordinateSystem source, Int32 length) throw new ArgumentOutOfRangeException(nameof(length), $"GeoHash of length {length} is not supported!"); } + double latitude = source.LatitudeDecimalDegrees; + double longitude = source.LongitudeDecimalDegrees; + + // CTA-5009-A defines Geohash input coordinates as latitude [-90, 90) + // and longitude [-180, 180). + if (!(latitude >= -90.0 && latitude < 90.0)) + { + throw new ArgumentOutOfRangeException(nameof(source), "Latitude must be in the range [-90, 90) degrees."); + } + + if (!(longitude >= -180.0 && longitude < 180.0)) + { + throw new ArgumentOutOfRangeException(nameof(source), "Longitude must be in the range [-180, 180) degrees."); + } + Int32 bitLength = length * GeohashBase32.BitsPerCharacter; UInt64 bitNotation = 0; Boolean bit; @@ -57,14 +72,14 @@ public static Geohash From(GeographicCoordinateSystem source, Int32 length) (longitudeMin, longitudeMax, bit) = BitNotationMultiplication( longitudeMin, longitudeMax, - source.LongitudeDecimalDegrees); + longitude); } else { (latitudeMin, latitudeMax, bit) = BitNotationMultiplication( latitudeMin, latitudeMax, - source.LatitudeDecimalDegrees); + latitude); } bitNotation <<= 1; diff --git a/TensionDev.CoordinateSystems/TensionDev.CoordinateSystems.csproj b/TensionDev.CoordinateSystems/TensionDev.CoordinateSystems.csproj index 4ca05b7..0f806fe 100644 --- a/TensionDev.CoordinateSystems/TensionDev.CoordinateSystems.csproj +++ b/TensionDev.CoordinateSystems/TensionDev.CoordinateSystems.csproj @@ -10,7 +10,7 @@ true true TensionDev.CoordinateSystems - 0.4.0 + 0.5.0 TensionDev amsga TensionDev TensionDev.CoordinateSystems diff --git a/docs/adr/ADR-0008-string-parsing-for-coordinate-systems.md b/docs/adr/ADR-0008-string-parsing-for-coordinate-systems.md new file mode 100644 index 0000000..5806eab --- /dev/null +++ b/docs/adr/ADR-0008-string-parsing-for-coordinate-systems.md @@ -0,0 +1,164 @@ +# ADR-0008: String Parsing for Coordinate Systems + +- **Status:** Accepted +- **Date:** 2026-09-23 + +## Context + +The library provides extensive string formatting for coordinate representations: + +- `GeographicCoordinateSystem.ToString(AngularFormat, DistanceUnit, int, int)` covers decimal degrees, DDM, DMS, and radians with configurable precision. +- `GeographicDd.ToString()` produces a decimal-degree representation. +- `GeographicDdm.ToString(int)` and `GeographicDms.ToString(int)` produce formatted DDM and DMS strings. +- `GeocentricCoordinateSystem.ToString(DistanceUnit, int)` formats X, Y, Z values. + +However, there is no corresponding way to parse strings back into coordinate objects. Consumers who receive coordinate data as text (e.g., from user input, configuration files, APIs, or log entries) have no library-supported path to convert it back into `GeographicCoordinateSystem`, `GeographicDd`, `GeocentricCoordinateSystem`, `GeographicDdm`, or `GeographicDms` instances. + +The existing `GeographicConverter` class provides `FromDdm(GeographicDdm)` and `FromDms(GeographicDms)` for converting between *typed* model objects, and `From(GeocentricCoordinateSystem)` for geocentric-to-geographic conversion. These bridge typed representations but do not address the string-to-object gap. + +## Decision + +`GeographicCoordinateSystem`, `GeographicDd`, and `GeocentricCoordinateSystem` will support string parsing through the standard .NET `Parse` and `TryParse` pattern. `GeographicDdm` and `GeographicDms` will support the same pattern for their own representations. + +### GeographicCoordinateSystem + +`GeographicCoordinateSystem` will provide: + +- `Parse(string)` — parses a coordinate string and returns a `GeographicCoordinateSystem`. Throws on invalid input. +- `TryParse(string, out GeographicCoordinateSystem)` — attempts to parse a coordinate string; returns `true` on success, `false` on failure. + +The `Parse` method will accept strings in all formats supported by `ToString`, plus space-delimited variants: + +- Decimal degrees: `"52.520000, 13.405000"` or with altitude `"52.520000, 13.405000 Altitude: 100.000 m"` +- DDM: `"52° 31.20' N, 13° 24.30' E"` or with altitude `"52° 31.20' N, 13° 24.30' E Altitude: 100.000 m"` (altitude is optional; if absent, defaults to 0) +- DMS: `"52° 31' 12.00\" N, 13° 24' 18.00\" E"` or with altitude `"52° 31' 12.00\" N, 13° 24' 18.00\" E Altitude: 100.000 m"` (altitude is optional; if absent, defaults to 0) +- Radians: values explicitly marked as radians (for example, `"0.916578 rad, 0.233904 rad"`), optionally with altitude. A bare numeric pair is always interpreted as decimal degrees, not radians. + +The `Parse` method also accepts space-delimited variants where the degree symbol, minute/second symbols, and direction characters are omitted. These formats are identified by the number of numeric components: + +- **Space-delimited DDM** (two numeric components): `"52 31.20 N, 13 24.30 E"` — latitude and longitude are separated by two numbers followed by a direction indicator. `10 0.000 N` is interpreted as DDM. +- **Space-delimited DMS** (three numeric components): `"52 31 12 N, 13 24 18 E"` — latitude and longitude are separated by three numbers followed by a direction indicator. `10 0 13.123 N` is interpreted as DMS. + +The direction indicator (N/S/E/W) is still required to disambiguate the final number. The latitude and longitude components must use the same format (both symbol-delimited or both space-delimited). + +For space-delimited formats, the integer constraint is enforced: + +- **Space-delimited DDM** (two numeric components): the first number (degrees) must be an integer. A non-integer value such as `"10.2 31.20 N"` will throw `FormatException`. +- **Space-delimited DMS** (three numeric components): the first two numbers (degrees and minutes) must be integers. A non-integer value such as `"10.2 10.3 10.4 N"` will throw `FormatException`. + +Altitude is appended with a leading space, the literal `"Altitude: "`, the value formatted with `distancePrecision` decimal places, a space, and the unit symbol (`"m"` or `"ft"`). If absent, altitude defaults to 0. + +Direction indicators (N, S, E, W) are required for DDM and DMS formats. Direction characters are case-insensitive (e.g., `"n"` and `"N"` are equivalent). Decimal degrees and radians do not use direction indicators. + +Parsing will use `CultureInfo.InvariantCulture` to ensure consistent behaviour regardless of the system's culture settings. Decimal separators are always `.` and thousands separators are never expected. + +`GeographicCoordinateSystem.Parse` converts the parsed angular values to the canonical radians used by `GeographicCoordinateSystem`, as defined by ADR-0009. Altitude is stored in metres. An unmarked decimal-degree string remains accepted and is converted to radians when constructing the result. + +### GeographicDd + +`GeographicDd` will provide the standard .NET parsing methods: + +- `Parse(string)` — parses a decimal-degree coordinate string and returns a `GeographicDd`. Throws on invalid input. +- `TryParse(string, out GeographicDd)` — attempts to parse the same format; returns `true` on success and `false` on failure. + +`GeographicDd.Parse` accepts only its decimal-degree format, such as `"52.520000, 13.405000"`, with the optional altitude suffix used by `GeographicCoordinateSystem`. Latitude and longitude values are in degrees; altitude is in metres. It does not parse DDM, DMS, radian, GeoJSON, ISO 6709, or geocentric input. Conversion to those representations is performed by the appropriate converter or interchange API. + +### GeocentricCoordinateSystem + +`GeocentricCoordinateSystem` will provide: + +- `Parse(string)` — parses a Cartesian coordinate string and returns a `GeocentricCoordinateSystem`. Throws on invalid input. +- `TryParse(string, out GeocentricCoordinateSystem)` — attempts to parse a coordinate string; returns `true` on success and `false` on failure. + +The `Parse` method will accept strings in the formats produced by `ToString`: + +- Metres: `"X: 1000.000 m, Y: 2000.000 m, Z: 3000.000 m"` +- Feet: `"X: 3280.840 ft, Y: 6561.680 ft, Z: 9842.520 ft"` + +The unit suffix (`m` or `ft`) is parsed to determine the output unit. If absent, metres is assumed. + +### GeographicDdm and GeographicDms + +`GeographicDdm` and `GeographicDms` will also provide `Parse` and `TryParse` methods: + +- `GeographicDdm.Parse(string)` — parses a DDM string into a `GeographicDdm` instance. +- `GeographicDdm.TryParse(string, out GeographicDdm)` — attempts to parse a DDM string. +- `GeographicDms.Parse(string)` — parses a DMS string into a `GeographicDms` instance. +- `GeographicDms.TryParse(string, out GeographicDms)` — attempts to parse a DMS string. + +Each type parses **only its own format** — the format produced by its own `ToString` method. `GeographicDdm.Parse` accepts only DDM strings (e.g., `"52° 31.20' N, 13° 24.30' E"`). `GeographicDms.Parse` accepts only DMS strings (e.g., `"52° 31' 12.00\" N, 13° 24' 18.00\" E"`). Neither type accepts decimal degree, radian, or geocentric input. + +Each type also parses the optional altitude suffix in the same format as `GeographicCoordinateSystem` (e.g., `"52° 31.20' N, 13° 24.30' E Altitude: 100.000 m"`). If absent, altitude defaults to 0. + +### Parsing and Format Boundaries + +Any string that does not conform to the expected format for the target type will throw `FormatException`. Parsing never performs format detection or cross-format conversion. For example, a geocentric coordinate string (`"X: 1000.000 m, Y: 2000.000 m, Z: 3000.000 m"`) passed to `GeographicCoordinateSystem.Parse` will throw `FormatException`. A DDM string passed to `GeographicDms.Parse` will throw `FormatException`. + +`GeographicCoordinateSystem` is the general parser: it can parse DD, DDM, DMS, explicit radians, and the supported space-delimited variants, then convert the result into its canonical radians. Bare numeric pairs mean decimal degrees. `GeographicDd`, `GeographicDdm`, and `GeographicDms` each parse only their own representation; conversion to a different representation is a converter responsibility. + +### Validation + +Parsing validates the input against the valid geographic range and converts angular units for the target model: + +- Latitude: -90 to 90 degrees (inclusive). +- Longitude: -180 to 180 degrees (inclusive). +- Minutes: 0 to 60. +- Seconds: 0 to 60. + +Out-of-range values will cause `Parse` to throw and `TryParse` to return `false`. + +`GeographicDd`, `GeographicDdm`, and `GeographicDms` values are validated in degrees. `GeographicCoordinateSystem` values are validated in radians. Parsing rejects out-of-range values rather than silently changing the parsed location. + +### Precision and Rounding + +Parsing preserves the full precision of the input `double` values. No precision clamping is applied during parsing — the six-decimal-place precision limit documented in ADR-0005 applies only to string formatting, not to the underlying numerical values. + +## Rationale + +- **Parse/TryParse convention:** The `Parse`/`TryParse` pattern is the standard .NET idiom for string-to-value conversion (used by `int`, `DateTime`, `Guid`, etc.). It gives consumers both a concise API for trusted input (`Parse`) and a safe path for untrusted input (`TryParse`) without requiring them to catch exceptions for validation. +- **Symmetry with formatting:** ADR-0004 and ADR-0005 established that the library formats coordinates in multiple angular representations and distance units. Parsing in the same set of formats provides round-trip fidelity — a parsed coordinate can be formatted and the result is semantically equivalent. +- **Invariant culture:** Coordinates are often exchanged between systems with different locale settings. Using `InvariantCulture` avoids culture-dependent parsing failures (e.g., `13,4050` vs `13.4050`). +- **GeographicDdm/GeographicDms own their parse:** ADR-0007 established that representation-specific types own their string representation. Parsing is the inverse of formatting, so it follows the same ownership principle. `GeographicDdm.Parse` produces a `GeographicDdm`; `GeographicDms.Parse` produces a `GeographicDms`. +- **Cross-format conversion is a converter responsibility:** `GeographicDdm` and `GeographicDms` do not accept input in other formats. Parsing a DD string into a `GeographicDdm` (or a DMS string into a `GeographicDdm`) is a format conversion, which is the role of `GeographicConverter`. This keeps the parsing surface narrow and predictable — each type parses exactly one format, the one it produces. +- **GeographicCoordinateSystem as the general parser:** `GeographicCoordinateSystem.Parse` accepts all supported angular formats and normalises the result into its canonical radians, as defined by ADR-0009. Bare pairs remain decimal-degree input for compatibility with common GPS and interchange data. +- **GeographicDd owns decimal-degree parsing:** `GeographicDd.Parse` and `TryParse` accept only decimal-degree strings, following the same representation-specific parsing rule as `GeographicDdm` and `GeographicDms`. + +## Consequences + +- Consumers can round-trip coordinates between string and object without external libraries. +- The `Parse`/`TryParse` pattern is familiar to .NET developers and integrates with existing infrastructure (e.g., `Convert.ChangeType`, source generators). +- Parsing is culture-independent by default, which is predictable but may differ from the default behaviour of `double.Parse` in localised applications. +- DDM and DMS parsing must handle direction characters (N/S/E/W), degree symbols, and optional altitude suffixes. The parser must be tolerant of reasonable whitespace variation while remaining strict about required components. +- `FormatException` is the error type for non-conforming input, consistent with .NET conventions (e.g., `int.Parse`, `DateTime.Parse`). +- Unit tests will be required for: + - All supported input formats (DD, DDM, DMS, explicitly marked radians; metres and feet for geocentric). + - Direction indicator variations (upper/lower case). + - Whitespace tolerance (e.g., `"52°31.2'N,13°24.3'E"` vs `"52° 31.2' N, 13° 24.3' E"`). + - Missing altitude (default to 0). + - Invalid input (non-numeric values, out-of-range coordinates, missing direction, malformed symbols). +- Mixed-format rejection (e.g., symbol-delimited latitude with space-delimited longitude, or DDM latitude with DMS longitude). +- Round-trip fidelity: `TryParse(str) → obj → ToString() → str'` where `str'` is semantically equivalent to `str`. +- `GeographicDd`, `GeographicDdm`, and `GeographicDms` parsing needs boundary tests at 90° latitude and 180° longitude, including rejection of values outside those ranges. +- `GeographicDd.Parse` and `GeographicDd.TryParse` will need tests for decimal-degree parsing, optional altitude, ranges, and rejection of other representations. + +## Alternatives Considered + +### Overriding `object.Parse` as a static method only + +A static `Parse` method on a converter (e.g., `GeographicConverter.Parse(string)`) was considered. This would centralise parsing but would require the converter to detect the input format internally, introducing ambiguity about which type the result should be. The `Parse`/`TryParse` pattern on the target type itself is more explicit and idiomatic in .NET. + +### Providing only `GeographicCoordinateSystem.Parse` + +Limiting parsing to `GeographicCoordinateSystem` would avoid duplicating parse logic across types. However, ADR-0007 established that representation-specific types own their own string representation, and consumers of `GeographicDdm` or `GeographicDms` should not need to parse into `GeographicCoordinateSystem` and then convert. Each type should be able to parse its own format directly. + +### Using `string.Split`-based parsing without symbol tolerance + +A simpler parser that only accepts a single fixed format would reduce implementation complexity but would be fragile with real-world input. Tolerating whitespace and case variations in direction characters is low-cost and significantly improves usability. + +### Using a separate parser class + +Introducing a `GeographicCoordinateParser` or similar would isolate parsing logic but would add a type that does not follow the `Parse`/`TryParse` convention, making it less discoverable and inconsistent with .NET idioms. + +## Relationship to ADR-0009 + +ADR-0009 adds `GeographicDd` and defines `GeographicCoordinateSystem` as the canonical radians-based model. This ADR therefore requires `GeographicDd.Parse` and `GeographicDd.TryParse` to parse decimal degrees, while `GeographicCoordinateSystem.Parse` converts the accepted representation into radians. Bare numeric pairs mean decimal degrees. Radian strings must identify radians explicitly; unmarked-radian examples in this ADR and ADR-0004 are superseded. diff --git a/docs/adr/ADR-0009-si-based-geographic-model-and-decimal-degree-representation.md b/docs/adr/ADR-0009-si-based-geographic-model-and-decimal-degree-representation.md new file mode 100644 index 0000000..aeebe39 --- /dev/null +++ b/docs/adr/ADR-0009-si-based-geographic-model-and-decimal-degree-representation.md @@ -0,0 +1,64 @@ +# ADR-0009: SI-Based Geographic Model and Decimal-Degree Representation + +- **Status:** Accepted +- **Date:** 2026-10-24 + +## Context + +`GeographicCoordinateSystem` currently represents latitude and longitude in decimal degrees, while the library also supports radians, DDM, and DMS formatting and parsing. The model therefore mixes a convenient human-facing representation with the units needed for consistent numerical computation. + +Radians and metres are SI units for the angular and linear quantities represented by the geographic model. Decimal degrees, DDM, and DMS remain useful named representations for users and interchange formats. The library needs a clear canonical model and explicit conversions among these representations. + +## Decision + +1. `GeographicCoordinateSystem` is the canonical geographic point model. Its numeric latitude and longitude values are stored and exposed in radians; altitude is stored and exposed in metres. +2. Add `GeographicDd` as a representation-specific model whose latitude and longitude values are expressed in decimal degrees. Its altitude is expressed in metres. +3. Do not add a separate `GeographicRadians` type. Radians are the unit of the canonical `GeographicCoordinateSystem` model. +4. `GeographicConverter` supports conversion between every distinct pair of the four geographic representations: `GeographicCoordinateSystem`, `GeographicDd`, `GeographicDdm`, and `GeographicDms`. Each pair is supported in both directions. The full conversion set is: + - `GeographicCoordinateSystem` ↔ `GeographicDd` + - `GeographicCoordinateSystem` ↔ `GeographicDdm` + - `GeographicCoordinateSystem` ↔ `GeographicDms` + - `GeographicDd` ↔ `GeographicDdm` + - `GeographicDd` ↔ `GeographicDms` + - `GeographicDdm` ↔ `GeographicDms` + + Converting a representation to the same representation is an identity operation and does not require a separate converter. Implementations may share conversion logic internally, but callers must not need to chain through another representation. Conversion preserves the represented point, subject to normal floating-point precision. +5. The coordinate ranges are: + - Latitude: -π/2 to π/2 radians in `GeographicCoordinateSystem`; -90 to 90 degrees in `GeographicDd`. + - Longitude: -π to π radians in `GeographicCoordinateSystem`; -180 to 180 degrees in `GeographicDd`. +6. String parsing and formatting are representation boundaries. A bare numeric latitude/longitude pair is interpreted as decimal degrees for compatibility with common GPS and geographic interchange conventions. Radian-formatted input must be explicitly identified; parsers must not infer units from numeric ranges. Parsed values are converted into the target model's declared units. +7. Formatting may continue to offer DD, DDM, DMS, and radians where already supported, but the requested representation must be explicit when ambiguity is possible. + +The models represent the same geographic point, not different datums, reference systems, or levels of accuracy. The initial geodetic conversion assumption remains WGS 84, as established by ADR-0002. + +## Rationale + +The canonical model should use consistent computational units, while named representation models make commonly used external forms clear. `GeographicDd` follows the established `GeographicDdm` and `GeographicDms` naming pattern and is useful for GPS input, display, and formats such as GeoJSON. + +There is no need for a second type to represent radians because `GeographicCoordinateSystem` already owns the canonical point and can use radians directly. Explicit conversions keep unit changes visible and prevent a numeric value from being silently interpreted under a guessed unit. + +## Consequences + +- `GeographicCoordinateSystem` numeric property semantics change from degrees to radians; this is a behavioral compatibility change that must be called out in release notes and reflected in API documentation. +- Existing callers that read or assign degree values through `GeographicCoordinateSystem` must convert those values or use `GeographicDd`. +- `GeographicConverter` must support all 12 directed conversions between the four distinct geographic representations listed above. +- Parsing common unmarked coordinate pairs continues to accept decimal degrees, then stores the resulting point in radians when the target is `GeographicCoordinateSystem`. +- Tests must cover unit conversions in both directions, boundary values, altitude preservation in metres, parsing/formatting by declared representation, and round trips within defined floating-point tolerances. + +## Alternatives Considered + +### Keep decimal degrees as the canonical units of `GeographicCoordinateSystem` + +Rejected because it leaves the computational model in a non-SI angular unit and keeps the model's unit semantics tied to its common text representation. + +### Add `GeographicRadians` alongside `GeographicCoordinateSystem` + +Rejected because it would duplicate the canonical model's responsibility. The existing type itself will use radians. + +### Infer whether unmarked values are degrees or radians + +Rejected because valid values overlap and magnitude-based interpretation can silently change a coordinate's meaning. Unmarked pairs use the documented decimal-degree input convention. + +## Relationship to Earlier Decisions + +This ADR follows ADR-0001 for destination-oriented converter ownership. ADR-0001, ADR-0006, and ADR-0007 establish that geographic representations are convertible, but do not enumerate complete pairwise coverage; the matrix above makes that coverage explicit. This ADR supersedes the degree-based canonical-unit descriptions in ADR-0007 and the unmarked-radian parsing examples in ADR-0008. ADR-0007 continues to apply to representation-specific formatting. An unmarked numeric pair is decimal-degree input, and a parsed `GeographicCoordinateSystem` stores that point in radians.