From 4b9d1ed70f4e15bc2d5b4f3480ab22df286ec7b3 Mon Sep 17 00:00:00 2001 From: amsga <49681949+amsga@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:33:53 +0800 Subject: [PATCH] Added DDM and DMS conversion support to `GeographicConverter` (`ToDdm`, `ToDms`, `FromDdm`, `FromDms`). Added precision-aware `ToString(int)` to `GeographicDdm` and `GeographicDms` with rounding overflow normalization. Added comprehensive tests for `GeographicDdm` and `GeographicDms` model classes and converter methods. Added boundary test for DMS minutes rounding to 60. --- CHANGELOG.md | 10 + README.md | 2 +- .../GeographicDdmTests.cs | 608 +++++++++++++++ .../GeographicDmsTests.cs | 695 ++++++++++++++++++ .../GeographicConverter.cs | 166 ++++- TensionDev.CoordinateSystems/GeographicDdm.cs | 121 +++ TensionDev.CoordinateSystems/GeographicDms.cs | 148 ++++ .../TensionDev.CoordinateSystems.csproj | 2 +- ...DR-0001-destination-oriented-converters.md | 0 ...R-0002-geographic-geocentric-conversion.md | 0 .../ADR-0003-public-licensing.md | 0 ...tring-formatting-for-coordinate-systems.md | 0 .../ADR-0005-coordinate-string-precision.md | 0 ...e-geographic-coordinate-representations.md | 32 +- ...resentation-types-and-string-formatting.md | 124 ++++ 15 files changed, 1890 insertions(+), 18 deletions(-) create mode 100644 TensionDev.CoordinateSystems.Tests/GeographicDdmTests.cs create mode 100644 TensionDev.CoordinateSystems.Tests/GeographicDmsTests.cs create mode 100644 TensionDev.CoordinateSystems/GeographicDdm.cs create mode 100644 TensionDev.CoordinateSystems/GeographicDms.cs rename docs/{decisions => adr}/ADR-0001-destination-oriented-converters.md (100%) rename docs/{decisions => adr}/ADR-0002-geographic-geocentric-conversion.md (100%) rename docs/{decisions => adr}/ADR-0003-public-licensing.md (100%) rename docs/{decisions => adr}/ADR-0004-string-formatting-for-coordinate-systems.md (100%) rename docs/{decisions => adr}/ADR-0005-coordinate-string-precision.md (100%) rename docs/{decisions => adr}/ADR-0006-alternative-geographic-coordinate-representations.md (88%) create mode 100644 docs/adr/ADR-0007-coordinate-representation-types-and-string-formatting.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 314f0d5..7a624f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + +## [v0.4.0] - 2026-09-20 +[v0.4.0](https://github.com/TensionDev/CoordinateSystems/releases/tag/v0.4.0) + +### Added +- Added DDM and DMS conversion support to `GeographicConverter` (`ToDdm`, `ToDms`, `FromDdm`, `FromDms`). +- Added precision-aware `ToString(int)` to `GeographicDdm` and `GeographicDms` with rounding overflow normalization. +- Added comprehensive tests for `GeographicDdm` and `GeographicDms` model classes and converter methods. +- Added boundary test for DMS minutes rounding to 60. + ## [v0.3.0] - 2026-09-03 [v0.3.0](https://github.com/TensionDev/CoordinateSystems/releases/tag/v0.3.0) diff --git a/README.md b/README.md index 8958ba3..55c6e01 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Conversion support is added independently from the coordinate model types. ## Documentation - [Architecture](docs/Architecture.md) -- [Architecture Decisions](docs/decisions/) +- [Architecture Decisions](docs/adr/) ## License diff --git a/TensionDev.CoordinateSystems.Tests/GeographicDdmTests.cs b/TensionDev.CoordinateSystems.Tests/GeographicDdmTests.cs new file mode 100644 index 0000000..1f9e39c --- /dev/null +++ b/TensionDev.CoordinateSystems.Tests/GeographicDdmTests.cs @@ -0,0 +1,608 @@ +using System; +using Xunit; + +namespace TensionDev.CoordinateSystems.Tests +{ + public class GeographicDdmTests : IDisposable + { + private bool disposedValue; + + private const Int32 POSITIONAL_PRECISION = 5; + + public GeographicDdmTests() + { + } + + [Fact] + public void TestDefaultConstructor() + { + GeographicDdm ddm = new GeographicDdm(); + + Assert.Equal(0, ddm.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, ddm.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', ddm.LatitudeDirection); + Assert.Equal(0, ddm.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, ddm.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', ddm.LongitudeDirection); + } + + [Fact] + public void TestPropertiesSetAndGet() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.2, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24.3, + LongitudeDirection = 'E', + }; + + 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 TestLatitudeDegreesClampedTo90() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 120, + }; + + Assert.Equal(90, ddm.LatitudeDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestLatitudeDegreesClampedToNegative() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = -120, + }; + + Assert.Equal(90, ddm.LatitudeDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestLongitudeDegreesClampedTo180() + { + GeographicDdm ddm = new GeographicDdm + { + LongitudeDegrees = 200, + }; + + Assert.Equal(180, ddm.LongitudeDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestLongitudeDegreesClampedToNegative() + { + GeographicDdm ddm = new GeographicDdm + { + LongitudeDegrees = -200, + }; + + Assert.Equal(180, ddm.LongitudeDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestToDdmFromGeographicConverter_ReturnsCorrectValues() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 52.52, + LongitudeDecimalDegrees = 13.405, + AltitudeMetres = 100.5, + }; + + GeographicDdm result = GeographicConverter.ToDdm(source); + + 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 TestToDdmFromGeographicConverter_NegativeValues() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = -52.52, + LongitudeDecimalDegrees = -13.405, + AltitudeMetres = 100.5, + }; + + GeographicDdm result = GeographicConverter.ToDdm(source); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31.2, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('S', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24.3, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('W', result.LongitudeDirection); + } + + [Fact] + public void TestToDdmFromGeographicConverter_Equator() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 0, + LongitudeDecimalDegrees = 0, + AltitudeMetres = 0, + }; + + GeographicDdm result = GeographicConverter.ToDdm(source); + + 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 TestToDdmFromGeographicConverter_NorthPole() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 90, + LongitudeDecimalDegrees = 0, + AltitudeMetres = 0, + }; + + GeographicDdm result = GeographicConverter.ToDdm(source); + + Assert.Equal(90, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + } + + [Fact] + public void TestToDdmFromGeographicConverter_SouthPole() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = -90, + LongitudeDecimalDegrees = 0, + AltitudeMetres = 0, + }; + + GeographicDdm result = GeographicConverter.ToDdm(source); + + Assert.Equal(90, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('S', result.LatitudeDirection); + } + + [Fact] + public void TestToDdmFromGeographicConverter_PrimeMeridian() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 0, + LongitudeDecimalDegrees = 0, + AltitudeMetres = 0, + }; + + GeographicDdm result = GeographicConverter.ToDdm(source); + + Assert.Equal(0, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void TestToDdmFromGeographicConverter_Antimeridian() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 0, + LongitudeDecimalDegrees = 180, + AltitudeMetres = 0, + }; + + GeographicDdm result = GeographicConverter.ToDdm(source); + + Assert.Equal(180, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal('W', result.LongitudeDirection); + } + + [Fact] + public void TestToDdmFromGeographicConverter_NullSource() + { + GeographicCoordinateSystem source = null; + + Assert.Throws(() => GeographicConverter.ToDdm(source)); + } + + [Fact] + public void TestToString_NegativeValues() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.2, + LatitudeDirection = 'S', + LongitudeDegrees = 13, + LongitudeMinutes = 24.3, + LongitudeDirection = 'W', + }; + + string result = ddm.ToString(); + Assert.Equal("52° 31.200000' S, 13° 24.300000' W", result); + } + + [Fact] + public void TestToString_ZeroValues() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 0, + LatitudeMinutes = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(); + Assert.Equal("0° 0.000000' N, 0° 0.000000' E", result); + } + + [Fact] + public void TestToString_NorthPole() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 90, + LatitudeMinutes = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(); + Assert.Equal("90° 0.000000' N, 0° 0.000000' E", result); + } + + [Fact] + public void TestToDdmFromGeographicConverter_RoundTrip() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 1.309432, + LongitudeDecimalDegrees = 103.780349, + AltitudeMetres = 25.0, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + + double reconstructedLatitude = ddm.LatitudeDegrees + ddm.LatitudeMinutes / 60.0; + if (ddm.LatitudeDirection == 'S') + { + reconstructedLatitude = -reconstructedLatitude; + } + + double reconstructedLongitude = ddm.LongitudeDegrees + ddm.LongitudeMinutes / 60.0; + if (ddm.LongitudeDirection == 'W') + { + reconstructedLongitude = -reconstructedLongitude; + } + + Assert.Equal(source.LatitudeDecimalDegrees, reconstructedLatitude, POSITIONAL_PRECISION); + Assert.Equal(source.LongitudeDecimalDegrees, reconstructedLongitude, POSITIONAL_PRECISION); + } + + [Fact] + public void TestToString_PrecisionZero() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.234567, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24.345678, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(0); + Assert.Equal("52° 31' N, 13° 24' E", result); + } + + [Fact] + public void TestToString_PrecisionThree() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.234567, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24.345678, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(3); + Assert.Equal("52° 31.235' N, 13° 24.346' E", result); + } + + [Fact] + public void TestToString_PrecisionSix() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.234567, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24.345678, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(6); + Assert.Equal("52° 31.234567' N, 13° 24.345678' E", result); + } + + [Fact] + public void TestToString_PrecisionClampedToSix() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.2345678901, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24.3456789012, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(10); + Assert.Equal("52° 31.234568' N, 13° 24.345679' E", result); + } + + [Fact] + public void TestToString_MinuteRoundingToSixty() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 59.9999995, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(6); + Assert.Equal("53° 0.000000' N, 13° 24.000000' E", result); + } + + [Fact] + public void TestToString_MinuteOverflowIntoDegrees() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 59.5, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 59.9999995, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(6); + Assert.Equal("52° 59.500000' N, 14° 0.000000' E", result); + } + + [Fact] + public void TestToString_MaximumPrecision() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.9999995, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24.9999995, + LongitudeDirection = 'E', + }; + + string result = ddm.ToString(6); + Assert.Equal("52° 32.000000' N, 13° 25.000000' E", result); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_ReturnsCorrectValues() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.2, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24.3, + LongitudeDirection = 'E', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDdm(ddm); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_NegativeValues() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 52, + LatitudeMinutes = 31.2, + LatitudeDirection = 'S', + LongitudeDegrees = 13, + LongitudeMinutes = 24.3, + LongitudeDirection = 'W', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDdm(ddm); + + Assert.Equal(-52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(-13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_Equator() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 0, + LatitudeMinutes = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeDirection = 'E', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDdm(ddm); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_NorthPole() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 90, + LatitudeMinutes = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeDirection = 'E', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDdm(ddm); + + Assert.Equal(90, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_SouthPole() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 90, + LatitudeMinutes = 0, + LatitudeDirection = 'S', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeDirection = 'E', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDdm(ddm); + + Assert.Equal(-90, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_PrimeMeridian() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 0, + LatitudeMinutes = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeDirection = 'W', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDdm(ddm); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_Antimeridian() + { + GeographicDdm ddm = new GeographicDdm + { + LatitudeDegrees = 0, + LatitudeMinutes = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 180, + LongitudeMinutes = 0, + LongitudeDirection = 'W', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDdm(ddm); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(-180, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_NullSource() + { + GeographicDdm ddm = null; + + Assert.Throws(() => GeographicConverter.FromDdm(ddm)); + } + + [Fact] + public void TestFromDdmFromGeographicConverter_RoundTrip() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 1.309432, + LongitudeDecimalDegrees = 103.780349, + AltitudeMetres = 25.0, + }; + + GeographicDdm ddm = GeographicConverter.ToDdm(source); + GeographicCoordinateSystem result = GeographicConverter.FromDdm(ddm); + + Assert.Equal(source.LatitudeDecimalDegrees, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(source.LongitudeDecimalDegrees, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + disposedValue = true; + } + } + + 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/GeographicDmsTests.cs b/TensionDev.CoordinateSystems.Tests/GeographicDmsTests.cs new file mode 100644 index 0000000..0512ce8 --- /dev/null +++ b/TensionDev.CoordinateSystems.Tests/GeographicDmsTests.cs @@ -0,0 +1,695 @@ +using System; +using Xunit; + +namespace TensionDev.CoordinateSystems.Tests +{ + public class GeographicDmsTests : IDisposable + { + private bool disposedValue; + + private const Int32 POSITIONAL_PRECISION = 5; + + public GeographicDmsTests() + { + } + + [Fact] + public void TestDefaultConstructor() + { + GeographicDms dms = new GeographicDms(); + + Assert.Equal(0, dms.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, dms.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(0, dms.LatitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('N', dms.LatitudeDirection); + Assert.Equal(0, dms.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, dms.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(0, dms.LongitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('E', dms.LongitudeDirection); + } + + [Fact] + public void TestPropertiesSetAndGet() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18, + LongitudeDirection = 'E', + }; + + Assert.Equal(52, dms.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31, dms.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(12, dms.LatitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('N', dms.LatitudeDirection); + Assert.Equal(13, dms.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24, dms.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(18, dms.LongitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('E', dms.LongitudeDirection); + } + + [Fact] + public void TestLatitudeDegreesClampedTo90() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 120, + }; + + Assert.Equal(90, dms.LatitudeDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestLatitudeDegreesClampedToNegative() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = -120, + }; + + Assert.Equal(90, dms.LatitudeDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestLongitudeDegreesClampedTo180() + { + GeographicDms dms = new GeographicDms + { + LongitudeDegrees = 200, + }; + + Assert.Equal(180, dms.LongitudeDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestLongitudeDegreesClampedToNegative() + { + GeographicDms dms = new GeographicDms + { + LongitudeDegrees = -200, + }; + + Assert.Equal(180, dms.LongitudeDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestToDmsFromGeographicConverter_ReturnsCorrectValues() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 52.52, + LongitudeDecimalDegrees = 13.405, + AltitudeMetres = 100.5, + }; + + GeographicDms result = GeographicConverter.ToDms(source); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(12, result.LatitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(18, result.LongitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void TestToDmsFromGeographicConverter_NegativeValues() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = -52.52, + LongitudeDecimalDegrees = -13.405, + AltitudeMetres = 100.5, + }; + + GeographicDms result = GeographicConverter.ToDms(source); + + Assert.Equal(52, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(31, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(12, result.LatitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('S', result.LatitudeDirection); + Assert.Equal(13, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(24, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(18, result.LongitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('W', result.LongitudeDirection); + } + + [Fact] + public void TestToDmsFromGeographicConverter_Equator() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 0, + LongitudeDecimalDegrees = 0, + AltitudeMetres = 0, + }; + + GeographicDms result = GeographicConverter.ToDms(source); + + Assert.Equal(0, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + Assert.Equal(0, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void TestToDmsFromGeographicConverter_NorthPole() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 90, + LongitudeDecimalDegrees = 0, + AltitudeMetres = 0, + }; + + GeographicDms result = GeographicConverter.ToDms(source); + + Assert.Equal(90, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('N', result.LatitudeDirection); + } + + [Fact] + public void TestToDmsFromGeographicConverter_SouthPole() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = -90, + LongitudeDecimalDegrees = 0, + AltitudeMetres = 0, + }; + + GeographicDms result = GeographicConverter.ToDms(source); + + Assert.Equal(90, result.LatitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(0, result.LatitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('S', result.LatitudeDirection); + } + + [Fact] + public void TestToDmsFromGeographicConverter_PrimeMeridian() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 0, + LongitudeDecimalDegrees = 0, + AltitudeMetres = 0, + }; + + GeographicDms result = GeographicConverter.ToDms(source); + + Assert.Equal(0, result.LongitudeDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeMinutes, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeSeconds, POSITIONAL_PRECISION); + Assert.Equal('E', result.LongitudeDirection); + } + + [Fact] + public void TestToDmsFromGeographicConverter_Antimeridian() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 0, + LongitudeDecimalDegrees = 180, + AltitudeMetres = 0, + }; + + GeographicDms result = GeographicConverter.ToDms(source); + + 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); + } + + [Fact] + public void TestToDmsFromGeographicConverter_NullSource() + { + GeographicCoordinateSystem source = null; + + Assert.Throws(() => GeographicConverter.ToDms(source)); + } + + [Fact] + public void TestToString_NegativeValues() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12, + LatitudeDirection = 'S', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18, + LongitudeDirection = 'W', + }; + + string result = dms.ToString(); + Assert.Equal("52° 31' 12.000000\" S, 13° 24' 18.000000\" W", result); + } + + [Fact] + public void TestToString_ZeroValues() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 0, + LatitudeMinutes = 0, + LatitudeSeconds = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeSeconds = 0, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(); + Assert.Equal("0° 0' 0.000000\" N, 0° 0' 0.000000\" E", result); + } + + [Fact] + public void TestToString_NorthPole() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 90, + LatitudeMinutes = 0, + LatitudeSeconds = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeSeconds = 0, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(); + Assert.Equal("90° 0' 0.000000\" N, 0° 0' 0.000000\" E", result); + } + + [Fact] + public void TestToDmsFromGeographicConverter_RoundTrip() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 1.309432, + LongitudeDecimalDegrees = 103.780349, + AltitudeMetres = 25.0, + }; + + GeographicDms dms = GeographicConverter.ToDms(source); + + double reconstructedLatitude = dms.LatitudeDegrees + dms.LatitudeMinutes / 60.0 + dms.LatitudeSeconds / 3600.0; + if (dms.LatitudeDirection == 'S') + { + reconstructedLatitude = -reconstructedLatitude; + } + + double reconstructedLongitude = dms.LongitudeDegrees + dms.LongitudeMinutes / 60.0 + dms.LongitudeSeconds / 3600.0; + if (dms.LongitudeDirection == 'W') + { + reconstructedLongitude = -reconstructedLongitude; + } + + Assert.Equal(source.LatitudeDecimalDegrees, reconstructedLatitude, POSITIONAL_PRECISION); + Assert.Equal(source.LongitudeDecimalDegrees, reconstructedLongitude, POSITIONAL_PRECISION); + } + + [Fact] + public void TestToString_PrecisionZero() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12.678901, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18.789012, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(0); + Assert.Equal("52° 31' 13\" N, 13° 24' 19\" E", result); + } + + [Fact] + public void TestToString_PrecisionThree() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12.678901, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18.789012, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(3); + Assert.Equal("52° 31' 12.679\" N, 13° 24' 18.789\" E", result); + } + + [Fact] + public void TestToString_PrecisionSix() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12.678901, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18.789012, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(6); + Assert.Equal("52° 31' 12.678901\" N, 13° 24' 18.789012\" E", result); + } + + [Fact] + public void TestToString_PrecisionClampedToSix() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12.6789012345, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18.7890123456, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(10); + Assert.Equal("52° 31' 12.678901\" N, 13° 24' 18.789012\" E", result); + } + + [Fact] + public void TestToString_SecondsRoundingToSixty() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 59.9999995, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(6); + Assert.Equal("52° 32' 0.000000\" N, 13° 24' 18.000000\" E", result); + } + + [Fact] + public void TestToString_SecondsOverflowIntoMinutes() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 59.9999995, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 59.9999995, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(6); + Assert.Equal("52° 32' 0.000000\" N, 13° 25' 0.000000\" E", result); + } + + [Fact] + public void TestToString_MinuteRoundingToSixty() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 59.9999995, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(6); + Assert.Equal("53° 0' 0.000000\" N, 13° 24' 18.000000\" E", result); + } + + [Fact] + public void TestToString_CascadingOverflow() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 59, + LatitudeSeconds = 59.9999995, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 59, + LongitudeSeconds = 59.9999995, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(6); + Assert.Equal("53° 0' 0.000000\" N, 14° 0' 0.000000\" E", result); + } + + [Fact] + public void TestToString_MaximumPrecision() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 59.9999995, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 59.9999995, + LongitudeDirection = 'E', + }; + + string result = dms.ToString(6); + Assert.Equal("52° 32' 0.000000\" N, 13° 25' 0.000000\" E", result); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_ReturnsCorrectValues() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12, + LatitudeDirection = 'N', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18, + LongitudeDirection = 'E', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDms(dms); + + Assert.Equal(52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_NegativeValues() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 52, + LatitudeMinutes = 31, + LatitudeSeconds = 12, + LatitudeDirection = 'S', + LongitudeDegrees = 13, + LongitudeMinutes = 24, + LongitudeSeconds = 18, + LongitudeDirection = 'W', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDms(dms); + + Assert.Equal(-52.52, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(-13.405, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_Equator() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 0, + LatitudeMinutes = 0, + LatitudeSeconds = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeSeconds = 0, + LongitudeDirection = 'E', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDms(dms); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_NorthPole() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 90, + LatitudeMinutes = 0, + LatitudeSeconds = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeSeconds = 0, + LongitudeDirection = 'E', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDms(dms); + + Assert.Equal(90, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_SouthPole() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 90, + LatitudeMinutes = 0, + LatitudeSeconds = 0, + LatitudeDirection = 'S', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeSeconds = 0, + LongitudeDirection = 'E', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDms(dms); + + Assert.Equal(-90, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_PrimeMeridian() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 0, + LatitudeMinutes = 0, + LatitudeSeconds = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 0, + LongitudeMinutes = 0, + LongitudeSeconds = 0, + LongitudeDirection = 'W', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDms(dms); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(0, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_Antimeridian() + { + GeographicDms dms = new GeographicDms + { + LatitudeDegrees = 0, + LatitudeMinutes = 0, + LatitudeSeconds = 0, + LatitudeDirection = 'N', + LongitudeDegrees = 180, + LongitudeMinutes = 0, + LongitudeSeconds = 0, + LongitudeDirection = 'W', + }; + + GeographicCoordinateSystem result = GeographicConverter.FromDms(dms); + + Assert.Equal(0, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(-180, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_NullSource() + { + GeographicDms dms = null; + + Assert.Throws(() => GeographicConverter.FromDms(dms)); + } + + [Fact] + public void TestFromDmsFromGeographicConverter_RoundTrip() + { + GeographicCoordinateSystem source = new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = 1.309432, + LongitudeDecimalDegrees = 103.780349, + AltitudeMetres = 25.0, + }; + + GeographicDms dms = GeographicConverter.ToDms(source); + GeographicCoordinateSystem result = GeographicConverter.FromDms(dms); + + Assert.Equal(source.LatitudeDecimalDegrees, result.LatitudeDecimalDegrees, POSITIONAL_PRECISION); + Assert.Equal(source.LongitudeDecimalDegrees, result.LongitudeDecimalDegrees, POSITIONAL_PRECISION); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + disposedValue = true; + } + } + + 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/GeographicConverter.cs b/TensionDev.CoordinateSystems/GeographicConverter.cs index 8011387..0df04c5 100644 --- a/TensionDev.CoordinateSystems/GeographicConverter.cs +++ b/TensionDev.CoordinateSystems/GeographicConverter.cs @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 // // Copyright 2021 - 2026 TensionDev // @@ -86,6 +86,170 @@ 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) + { + 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, + LatitudeDirection = latDirection, + LongitudeDegrees = lonDegrees, + LongitudeMinutes = lonMinutes, + LongitudeDirection = lonDirection, + }; + } + + /// + /// 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) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + (double latDegrees, double latMinutes, double latSeconds, char latDirection) = DecomposeWithSeconds(source.LatitudeDecimalDegrees, true); + (double lonDegrees, double lonMinutes, double lonSeconds, char lonDirection) = DecomposeWithSeconds(source.LongitudeDecimalDegrees, false); + + return new GeographicDms + { + LatitudeDegrees = latDegrees, + LatitudeMinutes = latMinutes, + LatitudeSeconds = latSeconds, + LatitudeDirection = latDirection, + LongitudeDegrees = lonDegrees, + LongitudeMinutes = lonMinutes, + LongitudeSeconds = lonSeconds, + LongitudeDirection = lonDirection, + }; + } + + /// + /// 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) + { + 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 + { + LatitudeDecimalDegrees = latitudeDecimalDegrees, + LongitudeDecimalDegrees = longitudeDecimalDegrees, + }; + } + + /// + /// 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) + { + 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); + + return new GeographicCoordinateSystem + { + LatitudeDecimalDegrees = latitudeDecimalDegrees, + LongitudeDecimalDegrees = longitudeDecimalDegrees, + }; + } + + 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; + } + + return absoluteDegrees; + } + + private static double RecomposeWithSeconds(double degrees, double minutes, double seconds, char direction, bool isLatitude) + { + double absoluteDegrees = degrees + minutes / 60.0 + seconds / 3600.0; + + if (isLatitude && direction == 'S') + { + return -absoluteDegrees; + } + + if (!isLatitude && direction == 'W') + { + return -absoluteDegrees; + } + + return absoluteDegrees; + } + + private static (double degrees, double minutes, char direction) Decompose(double degrees, bool isLatitude) + { + bool isNegative = degrees < 0; + double absDegrees = Math.Abs(degrees); + + int degreesInt = (int)absDegrees; + double minutes = (absDegrees - degreesInt) * 60.0; + + char direction = isLatitude ? + (isNegative ? 'S' : 'N') : + (degreesInt == 180 ? 'W' : (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/GeographicDdm.cs b/TensionDev.CoordinateSystems/GeographicDdm.cs new file mode 100644 index 0000000..14cf42b --- /dev/null +++ b/TensionDev.CoordinateSystems/GeographicDdm.cs @@ -0,0 +1,121 @@ +// 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 degrees and decimal minutes (DDM). + /// + public class GeographicDdm + { + private double _latitudeDegrees; + private double _longitudeDegrees; + + /// + /// Latitude degrees (0-90). Always non-negative; direction is indicated by . + /// + public double LatitudeDegrees + { + get => _latitudeDegrees; + set => _latitudeDegrees = Math.Max(Math.Min(Math.Abs(value), 90), 0); + } + + /// + /// Latitude minutes (0-60). Always non-negative; direction is indicated by . + /// + public double LatitudeMinutes { get; set; } + + /// + /// Latitude direction: 'N' for north, 'S' for south. + /// + public char LatitudeDirection { get; set; } = 'N'; + + /// + /// Longitude degrees (0-180). Always non-negative; direction is indicated by . + /// + public double LongitudeDegrees + { + get => _longitudeDegrees; + set => _longitudeDegrees = Math.Max(Math.Min(Math.Abs(value), 180), 0); + } + + /// + /// Longitude minutes (0-60). Always non-negative; direction is indicated by . + /// + public double LongitudeMinutes { get; set; } + + /// + /// Longitude direction: 'E' for east, 'W' for west. + /// + public char LongitudeDirection { get; set; } = 'E'; + + /// + /// Returns a formatted string representation of this coordinate in degrees and decimal minutes. + /// + /// A formatted string in the form "lat deg deg min' D, lon deg deg min' D". + public override string ToString() + { + return ToString(6); + } + + /// + /// Returns a formatted string representation of this coordinate in degrees and decimal minutes. + /// + /// The number of decimal places for the minutes component (default 6, maximum 6). + /// A formatted string in the form "lat deg deg min' D, lon deg deg min' D". + public string ToString(int precision) + { + int clampedPrecision = Math.Min(precision, 6); + string precisionFormat = "F" + clampedPrecision; + double roundingThreshold = 60.0 - Math.Pow(10.0, -clampedPrecision) / 2.0; + double precisionUnit = Math.Pow(10.0, -clampedPrecision) / 2.0; + + // Format latitude with rounding overflow normalization + double latDegrees = LatitudeDegrees; + double latMinutes = LatitudeMinutes; + char latDirection = LatitudeDirection; + + if (Math.Abs(latMinutes) >= roundingThreshold) + { + latDegrees += Math.Sign(latMinutes); + latMinutes -= 60.0 * Math.Sign(latMinutes); + if (Math.Abs(latMinutes) < precisionUnit) latMinutes = 0.0; // avoid -0.000000 + } + + // Format longitude with rounding overflow normalization + double lonDegrees = LongitudeDegrees; + double lonMinutes = LongitudeMinutes; + char lonDirection = LongitudeDirection; + + if (Math.Abs(lonMinutes) >= roundingThreshold) + { + lonDegrees += Math.Sign(lonMinutes); + lonMinutes -= 60.0 * Math.Sign(lonMinutes); + if (Math.Abs(lonMinutes) < precisionUnit) lonMinutes = 0.0; // avoid -0.000000 + } + + return FormatDdm(latDegrees, latMinutes, latDirection, precisionFormat) + ", " + FormatDdm(lonDegrees, lonMinutes, lonDirection, precisionFormat); + } + + private static string FormatDdm(double degrees, double minutes, char direction, string precisionFormat) + { + return degrees + "° " + minutes.ToString(precisionFormat, CultureInfo.InvariantCulture) + "' " + direction; + } + } +} diff --git a/TensionDev.CoordinateSystems/GeographicDms.cs b/TensionDev.CoordinateSystems/GeographicDms.cs new file mode 100644 index 0000000..0b8fdcd --- /dev/null +++ b/TensionDev.CoordinateSystems/GeographicDms.cs @@ -0,0 +1,148 @@ +// 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 degrees, minutes, and seconds (DMS). + /// + public class GeographicDms + { + private double _latitudeDegrees; + private double _longitudeDegrees; + + /// + /// Latitude degrees (0-90). Always non-negative; direction is indicated by . + /// + public double LatitudeDegrees + { + get => _latitudeDegrees; + set => _latitudeDegrees = Math.Max(Math.Min(Math.Abs(value), 90), 0); + } + + /// + /// Latitude minutes (0-60). Always non-negative. + /// + public double LatitudeMinutes { get; set; } + + /// + /// Latitude seconds (0-60). Always non-negative. + /// + public double LatitudeSeconds { get; set; } + + /// + /// Latitude direction: 'N' for north, 'S' for south. + /// + public char LatitudeDirection { get; set; } = 'N'; + + /// + /// Longitude degrees (0-180). Always non-negative; direction is indicated by . + /// + public double LongitudeDegrees + { + get => _longitudeDegrees; + set => _longitudeDegrees = Math.Max(Math.Min(Math.Abs(value), 180), 0); + } + + /// + /// Longitude minutes (0-60). Always non-negative. + /// + public double LongitudeMinutes { get; set; } + + /// + /// Longitude seconds (0-60). Always non-negative. + /// + public double LongitudeSeconds { get; set; } + + /// + /// Longitude direction: 'E' for east, 'W' for west. + /// + public char LongitudeDirection { get; set; } = 'E'; + + /// + /// Returns a formatted string representation of this coordinate in degrees, minutes, and seconds. + /// + /// A formatted string in the form "lat deg deg min sec" D, lon deg deg min sec" D". + public override string ToString() + { + return ToString(6); + } + + /// + /// Returns a formatted string representation of this coordinate in degrees, minutes, and seconds. + /// + /// The number of decimal places for the seconds component (default 6, maximum 6). + /// A formatted string in the form "lat deg deg min sec" D, lon deg deg min sec" D". + public string ToString(int precision) + { + int clampedPrecision = Math.Min(precision, 6); + string precisionFormat = "F" + clampedPrecision; + double secondsThreshold = 60.0 - Math.Pow(10.0, -clampedPrecision) / 2.0; + double minutesThreshold = 60.0 - Math.Pow(10.0, -clampedPrecision) / 2.0; + double precisionUnit = Math.Pow(10.0, -clampedPrecision) / 2.0; + + // Format latitude with rounding overflow normalization (cascading) + double latDegrees = LatitudeDegrees; + double latMinutes = LatitudeMinutes; + double latSeconds = LatitudeSeconds; + char latDirection = LatitudeDirection; + + if (Math.Abs(latSeconds) >= secondsThreshold) + { + latMinutes += Math.Sign(latSeconds); + latSeconds -= 60.0 * Math.Sign(latSeconds); + if (Math.Abs(latSeconds) < precisionUnit) latSeconds = 0.0; // avoid -0.000000 + } + + if (Math.Abs(latMinutes) >= minutesThreshold) + { + latDegrees += Math.Sign(latMinutes); + latMinutes -= 60.0 * Math.Sign(latMinutes); + if (Math.Abs(latMinutes) < precisionUnit) latMinutes = 0.0; // avoid -0.000000 + } + + // Format longitude with rounding overflow normalization (cascading) + double lonDegrees = LongitudeDegrees; + double lonMinutes = LongitudeMinutes; + double lonSeconds = LongitudeSeconds; + char lonDirection = LongitudeDirection; + + if (Math.Abs(lonSeconds) >= secondsThreshold) + { + lonMinutes += Math.Sign(lonSeconds); + lonSeconds -= 60.0 * Math.Sign(lonSeconds); + if (Math.Abs(lonSeconds) < precisionUnit) lonSeconds = 0.0; // avoid -0.000000 + } + + if (Math.Abs(lonMinutes) >= minutesThreshold) + { + lonDegrees += Math.Sign(lonMinutes); + lonMinutes -= 60.0 * Math.Sign(lonMinutes); + if (Math.Abs(lonMinutes) < precisionUnit) lonMinutes = 0.0; // avoid -0.000000 + } + + return FormatDms(latDegrees, latMinutes, latSeconds, latDirection, precisionFormat) + ", " + FormatDms(lonDegrees, lonMinutes, lonSeconds, lonDirection, precisionFormat); + } + + 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; + } + } +} diff --git a/TensionDev.CoordinateSystems/TensionDev.CoordinateSystems.csproj b/TensionDev.CoordinateSystems/TensionDev.CoordinateSystems.csproj index b4c15f3..4ca05b7 100644 --- a/TensionDev.CoordinateSystems/TensionDev.CoordinateSystems.csproj +++ b/TensionDev.CoordinateSystems/TensionDev.CoordinateSystems.csproj @@ -10,7 +10,7 @@ true true TensionDev.CoordinateSystems - 0.3.0 + 0.4.0 TensionDev amsga TensionDev TensionDev.CoordinateSystems diff --git a/docs/decisions/ADR-0001-destination-oriented-converters.md b/docs/adr/ADR-0001-destination-oriented-converters.md similarity index 100% rename from docs/decisions/ADR-0001-destination-oriented-converters.md rename to docs/adr/ADR-0001-destination-oriented-converters.md diff --git a/docs/decisions/ADR-0002-geographic-geocentric-conversion.md b/docs/adr/ADR-0002-geographic-geocentric-conversion.md similarity index 100% rename from docs/decisions/ADR-0002-geographic-geocentric-conversion.md rename to docs/adr/ADR-0002-geographic-geocentric-conversion.md diff --git a/docs/decisions/ADR-0003-public-licensing.md b/docs/adr/ADR-0003-public-licensing.md similarity index 100% rename from docs/decisions/ADR-0003-public-licensing.md rename to docs/adr/ADR-0003-public-licensing.md diff --git a/docs/decisions/ADR-0004-string-formatting-for-coordinate-systems.md b/docs/adr/ADR-0004-string-formatting-for-coordinate-systems.md similarity index 100% rename from docs/decisions/ADR-0004-string-formatting-for-coordinate-systems.md rename to docs/adr/ADR-0004-string-formatting-for-coordinate-systems.md diff --git a/docs/decisions/ADR-0005-coordinate-string-precision.md b/docs/adr/ADR-0005-coordinate-string-precision.md similarity index 100% rename from docs/decisions/ADR-0005-coordinate-string-precision.md rename to docs/adr/ADR-0005-coordinate-string-precision.md diff --git a/docs/decisions/ADR-0006-alternative-geographic-coordinate-representations.md b/docs/adr/ADR-0006-alternative-geographic-coordinate-representations.md similarity index 88% rename from docs/decisions/ADR-0006-alternative-geographic-coordinate-representations.md rename to docs/adr/ADR-0006-alternative-geographic-coordinate-representations.md index a7708c0..c526ac9 100644 --- a/docs/decisions/ADR-0006-alternative-geographic-coordinate-representations.md +++ b/docs/adr/ADR-0006-alternative-geographic-coordinate-representations.md @@ -1,9 +1,9 @@ -ADR-0006: Alternative Geographic Coordinate Representations +# ADR-0006: Alternative Geographic Coordinate Representations -* Status: Proposed -* Date: 2026-09-03 +- **Status:** Accepted +- **Date:** 2026-09-03 -Context +## Context Geographic represents a geographic point using decimal degrees for latitude and longitude. This provides the canonical numerical representation used by the library. @@ -16,11 +16,11 @@ are commonly used when presenting coordinates to users. These representations contain numerical components that are useful independently of their formatted string representation. For example, a user interface may need to display the degree, minute, and second components separately rather than consume the result of ToString(). -Storing these derived components directly within Geographic would duplicate information already represented by the coordinate’s decimal-degree value and introduce unnecessary state. +Storing these derived components directly within Geographic would duplicate information already represented by the coordinate's decimal-degree value and introduce unnecessary state. Returning tuples or multiple primitive values from conversion methods would provide the numerical data, but would not provide a named representation with clear domain meaning or a natural place for validation and future behaviour. -Decision +## Decision Alternative geographic coordinate representations will be represented by dedicated models: @@ -38,9 +38,9 @@ The alternative representation models SHALL expose their numerical components di Formatted output, including ToString(), remains a presentation concern and SHALL NOT be the sole mechanism for accessing the component values. -Consequences +## Consequences -Positive +### Positive * Geographic remains a simple canonical representation of a geographic point. * Derived coordinate components are not duplicated or subject to synchronization issues. @@ -49,35 +49,37 @@ Positive * The models provide a natural location for validation and representation-specific behaviour. * Additional coordinate representations can be introduced later without expanding Geographic with representation-specific properties. -Negative +### Negative * Additional types are introduced for DDM and DMS representations. * Conversion creates an additional object/value rather than exposing every representation directly from Geographic. * Consumers need to choose the appropriate representation when converting coordinates. -Alternatives Considered +## Alternatives Considered -Returning tuples or multiple primitive values +### Returning tuples or multiple primitive values Conversion could return tuples such as: +```csharp (double degrees, double minutes, double seconds) +``` This was rejected because the returned values have domain-specific meaning and are better represented by a named type. A dedicated model also provides a place for validation and future representation-specific behaviour. -Storing all representations in Geographic +### Storing all representations in Geographic Geographic could contain decimal degrees, decimal minutes, and DMS components simultaneously. This was rejected because DDM and DMS are derived representations of the same underlying coordinate. Storing them would duplicate state without providing additional source-of-truth information. -Using ToString() as the representation +### Using ToString() as the representation Consumers could obtain DDM/DMS components by parsing the formatted coordinate string. This was rejected because formatted text is presentation output, not a suitable data interface for programmatic consumers. -Scope +## Scope This decision covers the representation of geographic points in: @@ -87,4 +89,4 @@ This decision covers the representation of geographic points in: It does not prescribe additional coordinate systems or projections. -Future coordinate representations may be introduced through separate decisions where their requirements differ materially from those described here. \ No newline at end of file +Future coordinate representations may be introduced through separate decisions where their requirements differ materially from those described here. diff --git a/docs/adr/ADR-0007-coordinate-representation-types-and-string-formatting.md b/docs/adr/ADR-0007-coordinate-representation-types-and-string-formatting.md new file mode 100644 index 0000000..5f8eca1 --- /dev/null +++ b/docs/adr/ADR-0007-coordinate-representation-types-and-string-formatting.md @@ -0,0 +1,124 @@ +# ADR-0007: Coordinate Representation Types and String Formatting + +- **Status:** Accepted +- **Date:** 2026-09-04 + +## Context + +The coordinate library provides multiple representations of geographic coordinates: + +- GeographicCoordinateSystem — decimal degrees (DD) +- GeographicDdm — degrees and decimal minutes (DDM) +- GeographicDms — degrees, minutes and seconds (DMS) + +Version 0.3.0 introduced basic ToString() implementations for these representations. + +As the coordinate types evolve, there is a distinction between: + +1. A type representing a specific coordinate representation. +2. A general coordinate type capable of converting between representations. + +There is also a precision-related issue when formatting DDM and DMS values. Naïve rounding can produce invalid coordinate components, such as: + +``` +1° 60.0000′ N +``` + +or: + +``` +1° 59′ 60.0000″ N +``` + +These values are mathematically understandable but are not valid canonical DDM/DMS representations. + +The library therefore needs clear ownership of formatting responsibilities and defined behaviour when rounding causes a component to reach its upper boundary. + +## Decision + +1. Representation-specific types own their string representation. + +GeographicDdm and GeographicDms provide their own ToString() implementations. GeographicDdm.ToString() produces a DDM representation; GeographicDms.ToString() produces a DMS representation. These types do not expose formatting options that convert their output into another coordinate representation. + +GeographicCoordinateSystem remains responsible for conversion between coordinate representations and provides the flexibility to obtain DD, DDM or DMS representations where required. + +This establishes the following responsibility: + +> A representation-specific type knows how to represent itself; the general coordinate type knows how to transform between representations. + +2. Precision is supported by representation-specific formatting. + +GeographicDdm and GeographicDms support precision when producing their textual representations. Precision applies to the fractional component of the representation (decimal places of minutes for DDM, decimal places of seconds for DMS). The existing precision limit remains capped at six decimal places. + +3. Rounding must be followed by normalization. + +Formatting accounts for the possibility that rounding causes a component to reach its upper boundary (minutes < 60, seconds < 60). The formatting process is: + +``` +Coordinate value + ↓ +Convert to target representation + ↓ +Round to requested precision + ↓ +Normalize component overflow + ↓ +Format string +``` + +For example: + +``` +1° 59.999999′ N +``` + +rounded to four decimal places must become: + +``` +2° 00.0000′ N +``` + +rather than: + +``` +1° 60.0000′ N +``` + +Cascading overflow is handled for DMS: seconds overflow into minutes, minutes overflow into degrees. + +4. Canonical representations are preferred. + +The string representation always produces a valid, canonical representation rather than exposing intermediate values created by rounding. Minutes or seconds equal to 60 must never be emitted as part of a DDM or DMS representation. + +## Consequences + +- Each coordinate representation has a predictable ToString() behaviour. +- Conversion responsibilities remain centralized in GeographicCoordinateSystem. +- DDM and DMS strings remain valid and canonical at all supported precision levels. +- Rounding behaviour is deterministic and testable. +- The existing 0.3.0 string representation feature can be refined without introducing a new abstraction. +- Formatting requires additional normalization logic. +- Boundary cases require explicit testing. +- Rounding can alter the degree component, meaning the formatted result cannot always be produced by independently formatting each component. + +## Alternatives Considered + +### Allow ToString() to select any coordinate representation + +A formatting parameter could allow a GeographicDdm instance to produce DMS or DD output. + +Rejected because it gives representation-specific types responsibilities belonging to the general coordinate system. + +### Leave rounding overflow unnormalized + +This would simplify the implementation but could produce representations such as: + +``` +1° 60.0000′ N +``` + +Although numerically equivalent, this is not a valid canonical DDM representation and is undesirable for a public string representation. + +### Normalize before rounding + +Normalizing the unrounded components does not solve the problem because rounding itself can create the overflow. Normalization must therefore occur after precision rounding.