Skip to content
Open
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
75 changes: 75 additions & 0 deletions LiteDB.Benchmarks/Benchmarks/Spatial/SpatialQueryBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using BenchmarkDotNet.Attributes;
using LiteDB.Benchmarks.Models.Spatial;
using LiteDB.Spatial;
using SpatialApi = LiteDB.Spatial.Spatial;

namespace LiteDB.Benchmarks.Benchmarks.Spatial
{
[BenchmarkCategory(Constants.Categories.QUERIES)]
public class SpatialQueryBenchmarks : BenchmarkBase
{
private ILiteCollection<SpatialDocument> _collection = null!;
private GeoPoint _center = null!;
private GeoPolygon _searchArea = null!;
private double _radiusMeters;

[GlobalSetup]
public void GlobalSetup()
{
File.Delete(DatabasePath);

DatabaseInstance = new LiteDatabase(ConnectionString());
_collection = DatabaseInstance.GetCollection<SpatialDocument>("places");

SpatialApi.EnsurePointIndex(_collection, x => x.Location);
SpatialApi.EnsureShapeIndex(_collection, x => x.Region);
SpatialApi.EnsureShapeIndex(_collection, x => x.Route);

var documents = SpatialDocumentGenerator.Generate(DatasetSize);
_collection.Insert(documents);

DatabaseInstance.Checkpoint();

_center = new GeoPoint(0, 0);
_radiusMeters = 25_000;
_searchArea = SpatialDocumentGenerator.BuildSearchPolygon(0, 0, 0.1);
}

[Benchmark(Baseline = true)]
public List<SpatialDocument> NearQuery()
{
return SpatialApi.Near(_collection, x => x.Location, _center, _radiusMeters).ToList();
}

[Benchmark]
public List<SpatialDocument> BoundingBoxQuery()
{
return SpatialApi.WithinBoundingBox(_collection, x => x.Location, -0.2, -0.2, 0.2, 0.2).ToList();
}

[Benchmark]
public List<SpatialDocument> PolygonContainmentQuery()
{
return SpatialApi.Within(_collection, x => x.Region, _searchArea).ToList();
}

[Benchmark]
public List<SpatialDocument> RouteIntersectionQuery()
{
return SpatialApi.Intersects(_collection, x => x.Route, _searchArea).ToList();
}

[GlobalCleanup]
public void GlobalCleanup()
{
DatabaseInstance?.Checkpoint();
DatabaseInstance?.Dispose();
DatabaseInstance = null;

File.Delete(DatabasePath);
}
}
}
33 changes: 33 additions & 0 deletions LiteDB.Benchmarks/Models/Spatial/SpatialDocument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;
using LiteDB.Spatial;

namespace LiteDB.Benchmarks.Models.Spatial
{
public class SpatialDocument
{
public int Id { get; set; }

public string Name { get; set; } = string.Empty;

public GeoPoint Location { get; set; } = new GeoPoint(0, 0);

public GeoPolygon Region { get; set; } = new GeoPolygon(new[]
{
new GeoPoint(0, 0),
new GeoPoint(0, 0.001),
new GeoPoint(0.001, 0.001),
new GeoPoint(0.001, 0),
new GeoPoint(0, 0)
});

public GeoLineString Route { get; set; } = new GeoLineString(new[]
{
new GeoPoint(0, 0),
new GeoPoint(0.001, 0.001)
});

internal long _gh { get; set; }

internal double[] _mbb { get; set; } = Array.Empty<double>();
}
}
104 changes: 104 additions & 0 deletions LiteDB.Benchmarks/Models/Spatial/SpatialDocumentGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using LiteDB.Spatial;

namespace LiteDB.Benchmarks.Models.Spatial
{
internal static class SpatialDocumentGenerator
{
public static List<SpatialDocument> Generate(int count)
{
var random = new Random(1337);
var documents = new List<SpatialDocument>(count);

for (var i = 0; i < count; i++)
{
var lat = random.NextDouble() * 0.8 - 0.4;
var lon = random.NextDouble() * 0.8 - 0.4;
var location = new GeoPoint(lat, lon);

var region = BuildSquare(location, random.NextDouble() * 0.05 + 0.01);
var route = BuildRoute(location, random);

documents.Add(new SpatialDocument
{
Id = i + 1,
Name = $"Place #{i + 1}",
Location = location,
Region = region,
Route = route
});
}

return documents;
}

public static GeoPolygon BuildSearchPolygon(double centerLat, double centerLon, double radiusDegrees)
{
var center = new GeoPoint(centerLat, centerLon);
return BuildSquare(center, radiusDegrees);
}

private static GeoPolygon BuildSquare(GeoPoint center, double halfExtent)
{
var minLat = ClampLatitude(center.Lat - halfExtent);
var maxLat = ClampLatitude(center.Lat + halfExtent);
var minLon = NormalizeLongitude(center.Lon - halfExtent);
var maxLon = NormalizeLongitude(center.Lon + halfExtent);

var points = new List<GeoPoint>
{
new GeoPoint(maxLat, minLon),
new GeoPoint(maxLat, maxLon),
new GeoPoint(minLat, maxLon),
new GeoPoint(minLat, minLon),
new GeoPoint(maxLat, minLon)
};

return new GeoPolygon(points);
}

private static GeoLineString BuildRoute(GeoPoint start, Random random)
{
var midLat = start.Lat + random.NextDouble() * 0.1 - 0.05;
var midLon = start.Lon + random.NextDouble() * 0.1 - 0.05;
var endLat = start.Lat + random.NextDouble() * 0.2 - 0.1;
var endLon = start.Lon + random.NextDouble() * 0.2 - 0.1;

var points = new List<GeoPoint>
{
start,
new GeoPoint(midLat, midLon),
new GeoPoint(endLat, endLon)
};

return new GeoLineString(points);
}

private static double ClampLatitude(double latitude)
{
return Math.Max(-90d, Math.Min(90d, latitude));
}

private static double NormalizeLongitude(double lon)
{
if (double.IsNaN(lon))
{
return lon;
}

var result = lon % 360d;

if (result <= -180d)
{
result += 360d;
}
else if (result > 180d)
{
result -= 360d;
}

return result;
}
}
}
3 changes: 2 additions & 1 deletion LiteDB.Tests/LiteDB.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup Condition="'$(Configuration)' == 'Release'">
<Compile Remove="Internals\**" />
</ItemGroup>
Expand All @@ -38,6 +38,7 @@

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="GeographicLib.NET" Version="2.3.2" />
<PackageReference Include="MathNet.Numerics" Version="5.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" Condition="'$(TargetFrameworkIdentifier)' != '.NETFramework'" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" Condition="'$(TargetFramework)' == 'net481'" />
Expand Down
112 changes: 112 additions & 0 deletions LiteDB.Tests/Spatial/GeoMathPolarRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Collections.Generic;
using FluentAssertions;
using GeographicLib;
using LiteDB.Spatial;
using Xunit;

namespace LiteDB.Tests.Spatial;

public class GeoMathPolarRegressionTests
{
public static IEnumerable<object[]> HighLatitudeCircleCases()
{
yield return new object[] { new GeoPoint(89.0, 0.0), 100_000d, "High-latitude north, 100 km" };
yield return new object[] { new GeoPoint(-89.0, 30.0), 100_000d, "High-latitude south, 100 km" };
yield return new object[] { new GeoPoint(70.0, 10.0), 200_000d, "Mid-high north, 200 km" };
yield return new object[] { new GeoPoint(-70.0, -120.0), 200_000d, "Mid-high south, 200 km" };
}

public static IEnumerable<object[]> PoleTouchingCircleCases()
{
yield return new object[] { new GeoPoint(89.8, 0.0), 50_000d, "Pole-touching north, 50 km" };
yield return new object[] { new GeoPoint(-89.8, 0.0), 50_000d, "Pole-touching south, 50 km" };
}

[Theory]
[MemberData(nameof(HighLatitudeCircleCases))]
public void BoundingBoxForCircle_ShouldContainGeographicLibSamples(GeoPoint center, double radiusMeters, string description)
{
var bbox = GeoMath.BoundingBoxForCircle(center, radiusMeters);
var normalizedMinLon = GeoTestHelpers.NormalizeLon(bbox.MinLon);
var normalizedMaxLon = GeoTestHelpers.NormalizeLon(bbox.MaxLon);

for (var azimuth = 0; azimuth < 360; azimuth += 2)
{
var boundary = Geodesic.WGS84.Direct(center.Lat, center.Lon, azimuth, radiusMeters);
var boundaryLon = GeoTestHelpers.NormalizeLon(boundary.Longitude);

GeoTestHelpers.ContainsWrapAware(bbox, boundary.Latitude, boundaryLon)
.Should().BeTrue(
"Boundary point at azimuth {0}° ({1:F6},{2:F6}) lies outside bbox [{3:F6},{4:F6}]..[{5:F6},{6:F6}] (GeographicLib circle for {7})",
azimuth,
boundary.Latitude,
boundaryLon,
bbox.MinLat,
normalizedMinLon,
bbox.MaxLat,
normalizedMaxLon,
description);
}
}

[Theory]
[MemberData(nameof(PoleTouchingCircleCases))]
public void BoundingBoxForCircle_PoleTouchingCircleShouldSpanAllLongitudes(GeoPoint center, double radiusMeters, string description)
{
var bbox = GeoMath.BoundingBoxForCircle(center, radiusMeters);
var normalizedMinLon = GeoTestHelpers.NormalizeLon(bbox.MinLon);
var normalizedMaxLon = GeoTestHelpers.NormalizeLon(bbox.MaxLon);

for (var azimuth = 0; azimuth < 360; azimuth += 2)
{
var boundary = Geodesic.WGS84.Direct(center.Lat, center.Lon, azimuth, radiusMeters);
var boundaryLon = GeoTestHelpers.NormalizeLon(boundary.Longitude);

GeoTestHelpers.ContainsWrapAware(bbox, boundary.Latitude, boundaryLon)
.Should().BeTrue(
"Pole-touching boundary point at azimuth {0}° ({1:F6},{2:F6}) lies outside bbox [{3:F6},{4:F6}]..[{5:F6},{6:F6}] (GeographicLib circle for {7})",
azimuth,
boundary.Latitude,
boundaryLon,
bbox.MinLat,
normalizedMinLon,
bbox.MaxLat,
normalizedMaxLon,
description);
}

normalizedMinLon.Should().BeApproximately(-180d, 1e-6,
"Circles touching a pole should span all longitudes: expected -180° min lon (GeographicLib circle for {0})",
description);

normalizedMaxLon.Should().BeApproximately(180d, 1e-6,
"Circles touching a pole should span all longitudes: expected 180° max lon (GeographicLib circle for {0})",
description);
}

public static IEnumerable<object[]> PolarDistanceCases()
{
yield return new object[] { new GeoPoint(89.5, 0.0), new GeoPoint(89.5, 180.0), "Northern hemisphere" };
yield return new object[] { new GeoPoint(-89.5, 0.0), new GeoPoint(-89.5, 180.0), "Southern hemisphere" };
}

[Theory]
[MemberData(nameof(PolarDistanceCases))]
public void DistanceMeters_ShouldMatchGeographicLibNearPoles(GeoPoint a, GeoPoint b, string description)
{
var expected = Geodesic.WGS84.Inverse(a.Lat, a.Lon, b.Lat, b.Lon).Distance;
var haversine = GeoMath.DistanceMeters(a, b, DistanceFormula.Haversine);
var vincenty = GeoMath.DistanceMeters(a, b, DistanceFormula.Vincenty);

haversine.Should().BeApproximately(expected, 1.0,
"Haversine near pole diverges: expected ~{0:F3} m (GeographicLib), actual {1:F3} m ({2})",
expected,
haversine,
description);

vincenty.Should().BeApproximately(expected, 5.0,
"Vincenty should stay close to GeographicLib near poles for control pair ({0})",
description);
}
}

54 changes: 54 additions & 0 deletions LiteDB.Tests/Spatial/GeoTestHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using LiteDB.Spatial;

namespace LiteDB.Tests.Spatial;

internal static class GeoTestHelpers
{
private const double Epsilon = 1e-12;

public static double NormalizeLon(double lon)
{
if (double.IsNaN(lon) || double.IsInfinity(lon))
{
return lon;
}

var result = lon % 360d;

if (result < -180d)
{
result += 360d;
}
else if (result >= 180d)
{
result -= 360d;
}

return result;
}

public static bool ContainsWrapAware(GeoBoundingBox box, double lat, double lon)
{
if (lat < box.MinLat - Epsilon || lat > box.MaxLat + Epsilon)
{
return false;
}

var normalizedLon = NormalizeLon(lon);
var minLon = NormalizeLon(box.MinLon);
var maxLon = NormalizeLon(box.MaxLon);

if (box.SpansAllLongitudes)
{
return true;
}

if (minLon <= maxLon + Epsilon)
{
return normalizedLon >= minLon - Epsilon && normalizedLon <= maxLon + Epsilon;
}

return normalizedLon >= minLon - Epsilon || normalizedLon <= maxLon + Epsilon;
}
}

Loading
Loading