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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -72,24 +72,24 @@ jobs:

strategy:
matrix:
dotnet: [ '8.0.x' ]
dotnet: [ '10.0.x' ]
os: [macos-latest, ubuntu-latest, windows-latest]

runs-on: ${{ matrix.os }}

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
run: dotnet restore
- 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
6 changes: 3 additions & 3 deletions .github/workflows/package-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 22 additions & 22 deletions TensionDev.CoordinateSystems.Tests/CoordinateSystemToStringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand All @@ -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
};

Expand All @@ -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
};

Expand All @@ -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
};

Expand All @@ -74,22 +74,22 @@ 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]
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
};

Expand Down Expand Up @@ -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
};

Expand All @@ -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
};

Expand All @@ -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
};

Expand All @@ -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
};

Expand Down Expand Up @@ -231,4 +231,4 @@ public void Dispose()
GC.SuppressFinalize(this);
}
}
}
}
30 changes: 24 additions & 6 deletions TensionDev.CoordinateSystems.Tests/GeocentricConverterTests.cs
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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,
};

Expand All @@ -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,
};

Expand All @@ -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);
Expand Down
Loading
Loading