Skip to content

Commit 9ee68a4

Browse files
Copilotanidotnet
andcommitted
Add GeographicLib dependency and geodesic distance calculations for NearFilter
- Added GeographicLib 2.0 dependency for accurate geodesic calculations - Created GeodesicUtils class to convert meters to degrees accounting for Earth's curvature - Updated NearFilter to detect geographic coordinates and use geodesic calculations - Maintains backward compatibility for Cartesian coordinates (existing tests pass) - Added comprehensive test suite for geodesic near filter functionality Note: Tests are currently failing due to suspected R-tree edge case with points at (0,0). Investigation ongoing. Co-authored-by: anidotnet <[email protected]>
1 parent c0e5007 commit 9ee68a4

File tree

4 files changed

+362
-3
lines changed

4 files changed

+362
-3
lines changed

nitrite-spatial/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
<groupId>com.fasterxml.jackson.core</groupId>
5353
<artifactId>jackson-databind</artifactId>
5454
</dependency>
55+
<dependency>
56+
<groupId>net.sf.geographiclib</groupId>
57+
<artifactId>GeographicLib-Java</artifactId>
58+
<version>2.0</version>
59+
</dependency>
5560

5661
<dependency>
5762
<groupId>junit</groupId>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (c) 2017-2020. Nitrite author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.dizitart.no2.spatial;
18+
19+
import net.sf.geographiclib.Geodesic;
20+
import net.sf.geographiclib.GeodesicData;
21+
import org.locationtech.jts.geom.Coordinate;
22+
23+
/**
24+
* Utility class for geodesic distance calculations on Earth's surface.
25+
* This class handles the conversion between meters and degrees of latitude/longitude,
26+
* accounting for the curvature of the Earth using the WGS84 ellipsoid model.
27+
*
28+
* @since 4.0
29+
* @author Anindya Chatterjee
30+
*/
31+
class GeodesicUtils {
32+
private static final Geodesic WGS84 = Geodesic.WGS84;
33+
34+
/**
35+
* Determines if coordinates appear to be geographic (lat/long) rather than Cartesian.
36+
* This is a heuristic check based on valid lat/long ranges:
37+
* - Latitude: -90 to 90
38+
* - Longitude: -180 to 180
39+
*
40+
* @param center the coordinate to check
41+
* @return true if the coordinate appears to be geographic, false otherwise
42+
*/
43+
static boolean isGeographic(Coordinate center) {
44+
double x = center.getX();
45+
double y = center.getY();
46+
47+
// Check if coordinates fall within valid lat/long ranges
48+
// We use slightly relaxed bounds to be conservative
49+
return Math.abs(y) <= 90.0 && Math.abs(x) <= 180.0;
50+
}
51+
52+
/**
53+
* Calculates the approximate radius in degrees for a given distance in meters
54+
* at a specific geographic coordinate. This accounts for the fact that one degree
55+
* of longitude varies with latitude.
56+
*
57+
* @param center the center coordinate (longitude, latitude)
58+
* @param radiusMeters the radius in meters
59+
* @return the approximate radius in degrees
60+
*/
61+
static double metersToDegreesRadius(Coordinate center, double radiusMeters) {
62+
double lat = center.getY();
63+
double lon = center.getX();
64+
65+
// Calculate how many degrees we need to go in different directions
66+
// to cover the specified radius in meters
67+
68+
// East-West: Calculate a point at the given distance east
69+
GeodesicData eastPoint = WGS84.Direct(lat, lon, 90.0, radiusMeters);
70+
double lonDiff = Math.abs(eastPoint.lon2 - lon);
71+
72+
// North-South: Calculate a point at the given distance north
73+
GeodesicData northPoint = WGS84.Direct(lat, lon, 0.0, radiusMeters);
74+
double latDiff = Math.abs(northPoint.lat2 - lat);
75+
76+
// Use the maximum of the two to ensure we cover the full circle
77+
// This creates a slightly larger search area but ensures we don't miss points
78+
return Math.max(lonDiff, latDiff);
79+
}
80+
}

nitrite-spatial/src/main/java/org/dizitart/no2/spatial/NearFilter.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,34 @@ class NearFilter extends WithinFilter {
3434
super(field, createCircle(point.getCoordinate(), distance));
3535
}
3636

37-
private static Geometry createCircle(Coordinate center, double radius) {
37+
private static Geometry createCircle(Coordinate center, double radiusMeters) {
3838
GeometricShapeFactory shapeFactory = new GeometricShapeFactory();
3939
shapeFactory.setNumPoints(64);
4040
shapeFactory.setCentre(center);
41-
shapeFactory.setSize(radius * 2);
42-
return shapeFactory.createCircle();
41+
42+
// Determine if we're dealing with geographic coordinates (lat/long)
43+
// or simple Cartesian coordinates
44+
double radiusInDegrees;
45+
if (GeodesicUtils.isGeographic(center)) {
46+
// Convert meters to degrees accounting for Earth's curvature
47+
radiusInDegrees = GeodesicUtils.metersToDegreesRadius(center, radiusMeters);
48+
System.err.println("DEBUG NearFilter: Geographic coords detected");
49+
System.err.println(" Center: (" + center.getX() + ", " + center.getY() + ")");
50+
System.err.println(" Radius (meters): " + radiusMeters);
51+
System.err.println(" Radius (degrees): " + radiusInDegrees);
52+
} else {
53+
// For non-geographic coordinates, use the radius as-is
54+
// This maintains backward compatibility with existing tests
55+
radiusInDegrees = radiusMeters;
56+
System.err.println("DEBUG NearFilter: Cartesian coords detected");
57+
System.err.println(" Center: (" + center.getX() + ", " + center.getY() + ")");
58+
System.err.println(" Radius: " + radiusMeters);
59+
}
60+
61+
shapeFactory.setSize(radiusInDegrees * 2);
62+
Geometry circle = shapeFactory.createCircle();
63+
System.err.println(" Circle envelope: " + circle.getEnvelopeInternal());
64+
return circle;
4365
}
4466

4567
@Override
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/*
2+
* Copyright (c) 2017-2020. Nitrite author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.dizitart.no2.spatial;
18+
19+
import org.dizitart.no2.collection.Document;
20+
import org.dizitart.no2.collection.DocumentCursor;
21+
import org.dizitart.no2.collection.NitriteCollection;
22+
import org.dizitart.no2.filters.FluentFilter;
23+
import org.dizitart.no2.index.IndexOptions;
24+
import org.junit.Test;
25+
import org.locationtech.jts.geom.Coordinate;
26+
import org.locationtech.jts.geom.Point;
27+
import org.locationtech.jts.io.ParseException;
28+
import org.locationtech.jts.io.WKTReader;
29+
30+
import static org.dizitart.no2.collection.Document.createDocument;
31+
import static org.dizitart.no2.spatial.SpatialFluentFilter.where;
32+
import static org.dizitart.no2.spatial.SpatialIndexer.SPATIAL_INDEX;
33+
import static org.junit.Assert.assertEquals;
34+
import static org.junit.Assert.assertTrue;
35+
36+
/**
37+
* Test cases for NearFilter with real-world geodesic coordinates.
38+
* These tests verify that the NearFilter properly handles lat/long coordinates
39+
* on Earth's surface and correctly converts meters to degrees.
40+
*
41+
* @author Anindya Chatterjee
42+
*/
43+
public class GeodesicNearFilterTest extends BaseSpatialTest {
44+
45+
@Test
46+
public void testNearFilterAtEquator() throws ParseException {
47+
WKTReader reader = new WKTReader();
48+
49+
// Center point at (0°, 0°) - Atlantic Ocean at the equator
50+
Point centerPoint = (Point) reader.read("POINT (0 0)");
51+
52+
// Point approximately 1km east: at equator, 1 degree ≈ 111km
53+
// So 0.01 degrees ≈ 1.11km
54+
Point point1kmEast = (Point) reader.read("POINT (0.01 0)");
55+
56+
// Point approximately 111km east (1 degree at equator)
57+
Point point111kmEast = (Point) reader.read("POINT (1 0)");
58+
59+
// Point approximately 222km east (2 degrees at equator)
60+
Point point222kmEast = (Point) reader.read("POINT (2 0)");
61+
62+
NitriteCollection testCollection = db.getCollection("geodesic_test");
63+
testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location");
64+
65+
Document docCenter = createDocument("name", "center")
66+
.put("location", centerPoint);
67+
Document doc1km = createDocument("name", "1km_east")
68+
.put("location", point1kmEast);
69+
Document doc111km = createDocument("name", "111km_east")
70+
.put("location", point111kmEast);
71+
Document doc222km = createDocument("name", "222km_east")
72+
.put("location", point222kmEast);
73+
74+
testCollection.insert(docCenter, doc1km, doc111km, doc222km);
75+
76+
System.err.println("DEBUG: Inserted documents:");
77+
for (Document doc : testCollection.find().toList()) {
78+
System.err.println(" - " + doc.get("name") + " at " + doc.get("location"));
79+
}
80+
81+
// Test 1: Within 2km should return center and 1km_east only
82+
DocumentCursor within2km = testCollection.find(where("location").near(centerPoint, 2000.0));
83+
int count = 0;
84+
System.err.println("DEBUG: Iterating results...");
85+
for (Document doc : within2km.toList()) {
86+
count++;
87+
System.err.println(" Result " + count + ": " + doc.get("name") + " at " + doc.get("location"));
88+
}
89+
System.err.println("DEBUG: Found " + count + " results within 2km");
90+
assertEquals("Should find 2 points within 2km", 2, count);
91+
92+
// Test 2: Within 20cm should return only center
93+
DocumentCursor within20cm = testCollection.find(where("location").near(centerPoint, 0.2));
94+
assertEquals("Should find only center within 20cm", 1, within20cm.size());
95+
96+
// Test 3: Within 150km should return center, 1km, and 111km
97+
DocumentCursor within150km = testCollection.find(where("location").near(centerPoint, 150000.0));
98+
assertEquals("Should find 3 points within 150km", 3, within150km.size());
99+
100+
testCollection.remove(FluentFilter.where("name").eq("center"));
101+
testCollection.remove(FluentFilter.where("name").eq("1km_east"));
102+
testCollection.remove(FluentFilter.where("name").eq("111km_east"));
103+
testCollection.remove(FluentFilter.where("name").eq("222km_east"));
104+
}
105+
106+
@Test
107+
public void testNearFilterWithCoordinate() throws ParseException {
108+
WKTReader reader = new WKTReader();
109+
110+
Point centerPoint = (Point) reader.read("POINT (0 0)");
111+
Coordinate centerCoord = centerPoint.getCoordinate();
112+
113+
Point point500m = (Point) reader.read("POINT (0.005 0)");
114+
Point point5km = (Point) reader.read("POINT (0.05 0)");
115+
116+
NitriteCollection testCollection = db.getCollection("geodesic_coord_test");
117+
testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location");
118+
119+
Document docCenter = createDocument("name", "center")
120+
.put("location", centerPoint);
121+
Document doc500m = createDocument("name", "500m_east")
122+
.put("location", point500m);
123+
Document doc5km = createDocument("name", "5km_east")
124+
.put("location", point5km);
125+
126+
testCollection.insert(docCenter, doc500m, doc5km);
127+
128+
// Test with Coordinate instead of Point
129+
DocumentCursor within1km = testCollection.find(where("location").near(centerCoord, 1000.0));
130+
assertEquals("Should find 2 points within 1km", 2, within1km.size());
131+
132+
DocumentCursor within10km = testCollection.find(where("location").near(centerCoord, 10000.0));
133+
assertEquals("Should find all 3 points within 10km", 3, within10km.size());
134+
135+
testCollection.remove(FluentFilter.where("name").eq("center"));
136+
testCollection.remove(FluentFilter.where("name").eq("500m_east"));
137+
testCollection.remove(FluentFilter.where("name").eq("5km_east"));
138+
}
139+
140+
@Test
141+
public void testNearFilterAtMidLatitude() throws ParseException {
142+
WKTReader reader = new WKTReader();
143+
144+
// Center point at 45°N (e.g., near Minneapolis, MN)
145+
// At 45°N, longitude degrees are shorter: ~78.8km per degree
146+
Point centerPoint = (Point) reader.read("POINT (-93.2650 45.0000)");
147+
148+
// Point approximately 1km east at 45°N
149+
Point point1kmEast = (Point) reader.read("POINT (-93.2523 45.0000)");
150+
151+
// Point approximately 80km east
152+
Point point80kmEast = (Point) reader.read("POINT (-92.2650 45.0000)");
153+
154+
NitriteCollection testCollection = db.getCollection("geodesic_midlat_test");
155+
testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location");
156+
157+
Document docCenter = createDocument("name", "center")
158+
.put("location", centerPoint);
159+
Document doc1km = createDocument("name", "1km_east")
160+
.put("location", point1kmEast);
161+
Document doc80km = createDocument("name", "80km_east")
162+
.put("location", point80kmEast);
163+
164+
testCollection.insert(docCenter, doc1km, doc80km);
165+
166+
// Within 2km should find center and 1km_east
167+
DocumentCursor within2km = testCollection.find(where("location").near(centerPoint, 2000.0));
168+
assertEquals("Should find 2 points within 2km at 45°N", 2, within2km.size());
169+
170+
// Within 100km should find all points
171+
DocumentCursor within100km = testCollection.find(where("location").near(centerPoint, 100000.0));
172+
assertEquals("Should find all 3 points within 100km", 3, within100km.size());
173+
174+
testCollection.remove(FluentFilter.where("name").eq("center"));
175+
testCollection.remove(FluentFilter.where("name").eq("1km_east"));
176+
testCollection.remove(FluentFilter.where("name").eq("80km_east"));
177+
}
178+
179+
@Test
180+
public void testNearFilterNorthSouth() throws ParseException {
181+
WKTReader reader = new WKTReader();
182+
183+
// Test north-south distances (latitude changes)
184+
// These are consistent across all longitudes: ~111km per degree
185+
Point centerPoint = (Point) reader.read("POINT (0 0)");
186+
187+
// Point approximately 1km north
188+
Point point1kmNorth = (Point) reader.read("POINT (0 0.009)");
189+
190+
// Point approximately 111km north (1 degree)
191+
Point point111kmNorth = (Point) reader.read("POINT (0 1)");
192+
193+
NitriteCollection testCollection = db.getCollection("geodesic_ns_test");
194+
testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location");
195+
196+
Document docCenter = createDocument("name", "center")
197+
.put("location", centerPoint);
198+
Document doc1km = createDocument("name", "1km_north")
199+
.put("location", point1kmNorth);
200+
Document doc111km = createDocument("name", "111km_north")
201+
.put("location", point111kmNorth);
202+
203+
testCollection.insert(docCenter, doc1km, doc111km);
204+
205+
// Within 2km should find center and 1km_north
206+
DocumentCursor within2km = testCollection.find(where("location").near(centerPoint, 2000.0));
207+
assertEquals("Should find 2 points within 2km", 2, within2km.size());
208+
209+
// Within 200km should find all points
210+
DocumentCursor within200km = testCollection.find(where("location").near(centerPoint, 200000.0));
211+
assertEquals("Should find all 3 points within 200km", 3, within200km.size());
212+
213+
testCollection.remove(FluentFilter.where("name").eq("center"));
214+
testCollection.remove(FluentFilter.where("name").eq("1km_north"));
215+
testCollection.remove(FluentFilter.where("name").eq("111km_north"));
216+
}
217+
218+
@Test
219+
public void testNearFilterSmallDistances() throws ParseException {
220+
WKTReader reader = new WKTReader();
221+
222+
Point centerPoint = (Point) reader.read("POINT (0 0)");
223+
224+
// Very small distances
225+
Point point10m = (Point) reader.read("POINT (0.00009 0)"); // ~10m
226+
Point point100m = (Point) reader.read("POINT (0.0009 0)"); // ~100m
227+
228+
NitriteCollection testCollection = db.getCollection("geodesic_small_test");
229+
testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location");
230+
231+
Document docCenter = createDocument("name", "center")
232+
.put("location", centerPoint);
233+
Document doc10m = createDocument("name", "10m_east")
234+
.put("location", point10m);
235+
Document doc100m = createDocument("name", "100m_east")
236+
.put("location", point100m);
237+
238+
testCollection.insert(docCenter, doc10m, doc100m);
239+
240+
// Within 50m should find center and 10m only
241+
DocumentCursor within50m = testCollection.find(where("location").near(centerPoint, 50.0));
242+
assertEquals("Should find 2 points within 50m", 2, within50m.size());
243+
244+
// Within 5m should find only center
245+
DocumentCursor within5m = testCollection.find(where("location").near(centerPoint, 5.0));
246+
assertEquals("Should find only center within 5m", 1, within5m.size());
247+
248+
testCollection.remove(FluentFilter.where("name").eq("center"));
249+
testCollection.remove(FluentFilter.where("name").eq("10m_east"));
250+
testCollection.remove(FluentFilter.where("name").eq("100m_east"));
251+
}
252+
}

0 commit comments

Comments
 (0)