blob: 6349060e24f51305fc4930acde8e4b5deeccc929 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.
*/
package org.apache.lucene.spatial.prefix;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.carrotsearch.randomizedtesting.annotations.Repeat;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.Query;
import org.apache.lucene.spatial.StrategyTestCase;
import org.apache.lucene.spatial.prefix.tree.Cell;
import org.apache.lucene.spatial.prefix.tree.CellIterator;
import org.apache.lucene.spatial.prefix.tree.GeohashPrefixTree;
import org.apache.lucene.spatial.prefix.tree.PackedQuadPrefixTree;
import org.apache.lucene.spatial.prefix.tree.QuadPrefixTree;
import org.apache.lucene.spatial.prefix.tree.SpatialPrefixTree;
import org.apache.lucene.spatial.query.SpatialArgs;
import org.apache.lucene.spatial.query.SpatialOperation;
import org.junit.Test;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.context.SpatialContextFactory;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Rectangle;
import org.locationtech.spatial4j.shape.Shape;
import org.locationtech.spatial4j.shape.ShapeCollection;
import org.locationtech.spatial4j.shape.SpatialRelation;
import org.locationtech.spatial4j.shape.impl.RectangleImpl;
import static com.carrotsearch.randomizedtesting.RandomizedTest.randomBoolean;
import static com.carrotsearch.randomizedtesting.RandomizedTest.randomInt;
import static com.carrotsearch.randomizedtesting.RandomizedTest.randomIntBetween;
import static org.locationtech.spatial4j.shape.SpatialRelation.CONTAINS;
import static org.locationtech.spatial4j.shape.SpatialRelation.DISJOINT;
import static org.locationtech.spatial4j.shape.SpatialRelation.INTERSECTS;
import static org.locationtech.spatial4j.shape.SpatialRelation.WITHIN;
/** Randomized PrefixTree test that considers the fuzziness of the
* results introduced by grid approximation. */
public class RandomSpatialOpFuzzyPrefixTreeTest extends StrategyTestCase {
static final int ITERATIONS = 10;
protected SpatialPrefixTree grid;
private SpatialContext ctx2D;
public void setupGrid(int maxLevels) throws IOException {
if (randomBoolean())
setupQuadGrid(maxLevels, randomBoolean());
else
setupGeohashGrid(maxLevels);
setupCtx2D(ctx);
// set prune independently on strategy & grid randomly; should work
((RecursivePrefixTreeStrategy)strategy).setPruneLeafyBranches(randomBoolean());
if (this.grid instanceof PackedQuadPrefixTree) {
((PackedQuadPrefixTree) this.grid).setPruneLeafyBranches(randomBoolean());
}
if (maxLevels == -1 && rarely()) {
((PrefixTreeStrategy) strategy).setPointsOnly(true);
}
log.info("Strategy: " + strategy.toString()); // nowarn
}
private void setupCtx2D(SpatialContext ctx) {
if (!ctx.isGeo())
ctx2D = ctx;
//A non-geo version of ctx.
SpatialContextFactory ctxFactory = new SpatialContextFactory();
ctxFactory.geo = false;
ctxFactory.worldBounds = ctx.getWorldBounds();
ctx2D = ctxFactory.newSpatialContext();
}
private void setupQuadGrid(int maxLevels, boolean packedQuadPrefixTree) {
//non-geospatial makes this test a little easier (in gridSnap), and using boundary values 2^X raises
// the prospect of edge conditions we want to test, plus makes for simpler numbers (no decimals).
SpatialContextFactory factory = new SpatialContextFactory();
factory.geo = false;
factory.worldBounds = new RectangleImpl(0, 256, -128, 128, null);
this.ctx = factory.newSpatialContext();
//A fairly shallow grid, and default 2.5% distErrPct
if (maxLevels == -1)
maxLevels = randomIntBetween(1, 8);//max 64k cells (4^8), also 256*256
if (packedQuadPrefixTree) {
this.grid = new PackedQuadPrefixTree(ctx, maxLevels);
} else {
this.grid = new QuadPrefixTree(ctx, maxLevels);
}
this.strategy = newRPT();
}
public void setupGeohashGrid(int maxLevels) {
this.ctx = SpatialContext.GEO;
//A fairly shallow grid, and default 2.5% distErrPct
if (maxLevels == -1)
maxLevels = randomIntBetween(1, 3);//max 16k cells (32^3)
this.grid = new GeohashPrefixTree(ctx, maxLevels);
this.strategy = newRPT();
}
protected RecursivePrefixTreeStrategy newRPT() {
return new RecursivePrefixTreeStrategy(this.grid, getClass().getSimpleName());
}
@Test
@Repeat(iterations = ITERATIONS)
public void testIntersects() throws IOException {
setupGrid(-1);
doTest(SpatialOperation.Intersects);
}
@Test
@Repeat(iterations = ITERATIONS)
public void testWithin() throws IOException {
setupGrid(-1);
doTest(SpatialOperation.IsWithin);
}
@Test
@Repeat(iterations = ITERATIONS)
public void testContains() throws IOException {
setupGrid(-1);
doTest(SpatialOperation.Contains);
}
@Test
public void testPackedQuadPointsOnlyBug() throws IOException {
setupQuadGrid(1, true); // packed quad. maxLevels doesn't matter.
setupCtx2D(ctx);
((PrefixTreeStrategy) strategy).setPointsOnly(true);
Point point = ctx.makePoint(169.0, 107.0);
adoc("0", point);
commit();
Query query = strategy.makeQuery(new SpatialArgs(SpatialOperation.Intersects, point));
assertEquals(1, executeQuery(query, 1).numFound);
}
@Test
public void testPointsOnlyOptBug() throws IOException {
setupQuadGrid(8, false);
setupCtx2D(ctx);
((PrefixTreeStrategy) strategy).setPointsOnly(true);
Point point = ctx.makePoint(86, -127.44362190053255);
adoc("0", point);
commit();
Query query = strategy.makeQuery(new SpatialArgs(SpatialOperation.Intersects,
ctx.makeRectangle(point, point)));
assertEquals(1, executeQuery(query, 1).numFound);
}
/** See LUCENE-5062, {@link ContainsPrefixTreeQuery#multiOverlappingIndexedShapes}. */
@Test
public void testContainsPairOverlap() throws IOException {
setupQuadGrid(3, randomBoolean());
adoc("0", new ShapePair(ctx.makeRectangle(0, 33, -128, 128), ctx.makeRectangle(33, 128, -128, 128), true));
commit();
Query query = strategy.makeQuery(new SpatialArgs(SpatialOperation.Contains,
ctx.makeRectangle(0, 128, -16, 128)));
SearchResults searchResults = executeQuery(query, 1);
assertEquals(1, searchResults.numFound);
}
@Test
public void testWithinDisjointParts() throws IOException {
setupQuadGrid(7, randomBoolean());
//one shape comprised of two parts, quite separated apart
adoc("0", new ShapePair(ctx.makeRectangle(0, 10, -120, -100), ctx.makeRectangle(220, 240, 110, 125), false));
commit();
//query surrounds only the second part of the indexed shape
Query query = strategy.makeQuery(new SpatialArgs(SpatialOperation.IsWithin,
ctx.makeRectangle(210, 245, 105, 128)));
SearchResults searchResults = executeQuery(query, 1);
//we shouldn't find it because it's not completely within
assertTrue(searchResults.numFound == 0);
}
@Test /** LUCENE-4916 */
public void testWithinLeafApproxRule() throws IOException {
setupQuadGrid(2, randomBoolean());//4x4 grid
//indexed shape will simplify to entire right half (2 top cells)
adoc("0", ctx.makeRectangle(192, 204, -128, 128));
commit();
((RecursivePrefixTreeStrategy) strategy).setPrefixGridScanLevel(randomInt(2));
//query does NOT contain it; both indexed cells are leaves to the query, and
// when expanded to the full grid cells, the top one's top row is disjoint
// from the query and thus not a match.
assertTrue(executeQuery(strategy.makeQuery(
new SpatialArgs(SpatialOperation.IsWithin, ctx.makeRectangle(38, 192, -72, 56))
), 1).numFound==0);//no-match
//this time the rect is a little bigger and is considered a match. It's
// an acceptable false-positive because of the grid approximation.
assertTrue(executeQuery(strategy.makeQuery(
new SpatialArgs(SpatialOperation.IsWithin, ctx.makeRectangle(38, 192, -72, 80))
), 1).numFound==1);//match
}
@Test
public void testShapePair() {
ctx = SpatialContext.GEO;
setupCtx2D(ctx);
Shape leftShape = new ShapePair(ctx.makeRectangle(-74, -56, -8, 1), ctx.makeRectangle(-180, 134, -90, 90), true);
Shape queryShape = ctx.makeRectangle(-180, 180, -90, 90);
assertEquals(SpatialRelation.WITHIN, leftShape.relate(queryShape));
}
//Override so we can index parts of a pair separately, resulting in the detailLevel
// being independent for each shape vs the whole thing
@Override
protected Document newDoc(String id, Shape shape) {
Document doc = new Document();
doc.add(new StringField("id", id, Field.Store.YES));
if (shape != null) {
Collection<Shape> shapes;
if (shape instanceof ShapePair) {
shapes = new ArrayList<>(2);
shapes.add(((ShapePair)shape).shape1);
shapes.add(((ShapePair)shape).shape2);
} else {
shapes = Collections.singleton(shape);
}
for (Shape shapei : shapes) {
for (Field f : strategy.createIndexableFields(shapei)) {
doc.add(f);
}
}
if (storeShape)//just for diagnostics
doc.add(new StoredField(strategy.getFieldName(), shape.toString()));
}
return doc;
}
@SuppressWarnings("fallthrough")
private void doTest(final SpatialOperation operation) throws IOException {
//first show that when there's no data, a query will result in no results
{
Query query = strategy.makeQuery(new SpatialArgs(operation, randomRectangle()));
SearchResults searchResults = executeQuery(query, 1);
assertEquals(0, searchResults.numFound);
}
final boolean biasContains = (operation == SpatialOperation.Contains);
//Main index loop:
Map<String, Shape> indexedShapes = new LinkedHashMap<>();
Map<String, Shape> indexedShapesGS = new LinkedHashMap<>();//grid snapped
final int numIndexedShapes = randomIntBetween(1, 6);
boolean indexedAtLeastOneShapePair = false;
final boolean pointsOnly = ((PrefixTreeStrategy) strategy).isPointsOnly();
for (int i = 0; i < numIndexedShapes; i++) {
String id = "" + i;
Shape indexedShape;
int R = random().nextInt(12);
if (R == 0) {//1 in 12
indexedShape = null;
} else if (R == 1 || pointsOnly) {//1 in 12
indexedShape = randomPoint();//just one point
} else if (R <= 4) {//3 in 12
//comprised of more than one shape
indexedShape = randomShapePairRect(biasContains);
indexedAtLeastOneShapePair = true;
} else {
indexedShape = randomRectangle();//just one rect
}
indexedShapes.put(id, indexedShape);
indexedShapesGS.put(id, gridSnap(indexedShape));
adoc(id, indexedShape);
if (random().nextInt(10) == 0)
commit();//intermediate commit, produces extra segments
}
//delete some documents randomly
Iterator<String> idIter = indexedShapes.keySet().iterator();
while (idIter.hasNext()) {
String id = idIter.next();
if (random().nextInt(10) == 0) {
deleteDoc(id);
idIter.remove();
indexedShapesGS.remove(id);
}
}
commit();
//Main query loop:
final int numQueryShapes = atLeast(20);
for (int i = 0; i < numQueryShapes; i++) {
int scanLevel = randomInt(grid.getMaxLevels());
((RecursivePrefixTreeStrategy) strategy).setPrefixGridScanLevel(scanLevel);
final Shape queryShape;
switch (randomInt(10)) {
case 0: queryShape = randomPoint(); break;
// LUCENE-5549
//TODO debug: -Dtests.method=testWithin -Dtests.multiplier=3 -Dtests.seed=5F5294CE2E075A3E:AAD2F0F79288CA64
// case 1:case 2:case 3:
// if (!indexedAtLeastOneShapePair) { // avoids ShapePair.relate(ShapePair), which isn't reliable
// queryShape = randomShapePairRect(!biasContains);//invert biasContains for query side
// break;
// }
case 4:
//choose an existing indexed shape
if (!indexedShapes.isEmpty()) {
Shape tmp = indexedShapes.values().iterator().next();
if (tmp instanceof Point || tmp instanceof Rectangle) {//avoids null and shapePair
queryShape = tmp;
break;
}
}//else fall-through
default: queryShape = randomRectangle();
}
final Shape queryShapeGS = gridSnap(queryShape);
final boolean opIsDisjoint = operation == SpatialOperation.IsDisjointTo;
//Generate truth via brute force:
// We ensure true-positive matches (if the predicate on the raw shapes match
// then the search should find those same matches).
// approximations, false-positive matches
Set<String> expectedIds = new LinkedHashSet<>();//true-positives
Set<String> secondaryIds = new LinkedHashSet<>();//false-positives (unless disjoint)
for (Map.Entry<String, Shape> entry : indexedShapes.entrySet()) {
String id = entry.getKey();
Shape indexedShapeCompare = entry.getValue();
if (indexedShapeCompare == null)
continue;
Shape queryShapeCompare = queryShape;
if (operation.evaluate(indexedShapeCompare, queryShapeCompare)) {
expectedIds.add(id);
if (opIsDisjoint) {
//if no longer intersect after buffering them, for disjoint, remember this
indexedShapeCompare = indexedShapesGS.get(id);
queryShapeCompare = queryShapeGS;
if (!operation.evaluate(indexedShapeCompare, queryShapeCompare))
secondaryIds.add(id);
}
} else if (!opIsDisjoint) {
//buffer either the indexed or query shape (via gridSnap) and try again
if (operation == SpatialOperation.Intersects) {
indexedShapeCompare = indexedShapesGS.get(id);
queryShapeCompare = queryShapeGS;
//TODO Unfortunately, grid-snapping both can result in intersections that otherwise
// wouldn't happen when the grids are adjacent. Not a big deal but our test is just a
// bit more lenient.
} else if (operation == SpatialOperation.Contains) {
indexedShapeCompare = indexedShapesGS.get(id);
} else if (operation == SpatialOperation.IsWithin) {
queryShapeCompare = queryShapeGS;
}
if (operation.evaluate(indexedShapeCompare, queryShapeCompare))
secondaryIds.add(id);
}
}
//Search and verify results
SpatialArgs args = new SpatialArgs(operation, queryShape);
if (queryShape instanceof ShapePair)
args.setDistErrPct(0.0);//a hack; we want to be more detailed than gridSnap(queryShape)
Query query = strategy.makeQuery(args);
SearchResults got = executeQuery(query, 100);
Set<String> remainingExpectedIds = new LinkedHashSet<>(expectedIds);
for (SearchResult result : got.results) {
String id = result.getId();
boolean removed = remainingExpectedIds.remove(id);
if (!removed && (!opIsDisjoint && !secondaryIds.contains(id))) {
fail("Shouldn't match", id, indexedShapes, indexedShapesGS, queryShape);
}
}
if (opIsDisjoint)
remainingExpectedIds.removeAll(secondaryIds);
if (!remainingExpectedIds.isEmpty()) {
String id = remainingExpectedIds.iterator().next();
fail("Should have matched", id, indexedShapes, indexedShapesGS, queryShape);
}
}
}
private Shape randomShapePairRect(boolean biasContains) {
Rectangle shape1 = randomRectangle();
Rectangle shape2 = randomRectangle();
return new ShapePair(shape1, shape2, biasContains);
}
private void fail(String label, String id, Map<String, Shape> indexedShapes, Map<String, Shape> indexedShapesGS, Shape queryShape) {
System.err.println("Ig:" + indexedShapesGS.get(id) + " Qg:" + gridSnap(queryShape));
fail(label + " I#" + id + ":" + indexedShapes.get(id) + " Q:" + queryShape);
}
// private Rectangle inset(Rectangle r) {
// //typically inset by 1 (whole numbers are easy to read)
// double d = Math.min(1.0, grid.getDistanceForLevel(grid.getMaxLevels()) / 4);
// return ctx.makeRectangle(r.getMinX() + d, r.getMaxX() - d, r.getMinY() + d, r.getMaxY() - d);
// }
protected Shape gridSnap(Shape snapMe) {
if (snapMe == null)
return null;
if (snapMe instanceof ShapePair) {
ShapePair me = (ShapePair) snapMe;
return new ShapePair(gridSnap(me.shape1), gridSnap(me.shape2), me.biasContainsThenWithin);
}
if (snapMe instanceof Point) {
snapMe = snapMe.getBoundingBox();
}
//The next 4 lines mimic PrefixTreeStrategy.createIndexableFields()
double distErrPct = ((PrefixTreeStrategy) strategy).getDistErrPct();
double distErr = SpatialArgs.calcDistanceFromErrPct(snapMe, distErrPct, ctx);
int detailLevel = grid.getLevelForDistance(distErr);
CellIterator cells = grid.getTreeCellIterator(snapMe, detailLevel);
//calc bounding box of cells.
List<Shape> cellShapes = new ArrayList<>(1024);
while (cells.hasNext()) {
Cell cell = cells.next();
if (!cell.isLeaf())
continue;
cellShapes.add(cell.getShape());
}
return new ShapeCollection<>(cellShapes, ctx).getBoundingBox();
}
/**
* An aggregate of 2 shapes. Unfortunately we can't simply use a ShapeCollection because:
* (a) ambiguity between CONTAINS and WITHIN for equal shapes, and
* (b) adjacent pairs could as a whole contain the input shape.
* The tests here are sensitive to these matters, although in practice ShapeCollection
* is fine.
*/
private class ShapePair extends ShapeCollection<Shape> {
final Shape shape1, shape2;
final Shape shape1_2D, shape2_2D;//not geo (bit of a hack)
final boolean biasContainsThenWithin;
public ShapePair(Shape shape1, Shape shape2, boolean containsThenWithin) {
super(Arrays.asList(shape1, shape2), RandomSpatialOpFuzzyPrefixTreeTest.this.ctx);
this.shape1 = shape1;
this.shape2 = shape2;
this.shape1_2D = toNonGeo(shape1);
this.shape2_2D = toNonGeo(shape2);
biasContainsThenWithin = containsThenWithin;
}
private Shape toNonGeo(Shape shape) {
if (!ctx.isGeo())
return shape;//already non-geo
if (shape instanceof Rectangle) {
Rectangle rect = (Rectangle) shape;
if (rect.getCrossesDateLine()) {
return new ShapePair(
ctx2D.makeRectangle(rect.getMinX(), 180, rect.getMinY(), rect.getMaxY()),
ctx2D.makeRectangle(-180, rect.getMaxX(), rect.getMinY(), rect.getMaxY()),
biasContainsThenWithin);
} else {
return ctx2D.makeRectangle(rect.getMinX(), rect.getMaxX(), rect.getMinY(), rect.getMaxY());
}
}
//no need to do others; this addresses the -180/+180 ambiguity corner test problem
return shape;
}
@Override
public SpatialRelation relate(Shape other) {
SpatialRelation r = relateApprox(other);
if (r == DISJOINT)
return r;
if (r == CONTAINS)
return r;
if (r == WITHIN && !biasContainsThenWithin)
return r;
//See if the correct answer is actually Contains, when the indexed shapes are adjacent,
// creating a larger shape that contains the input shape.
boolean pairTouches = shape1.relate(shape2).intersects();
if (!pairTouches)
return r;
//test all 4 corners
// Note: awkwardly, we use a non-geo context for this because in geo, -180 & +180 are the same place, which means
// that "other" might wrap the world horizontally and yet all its corners could be in shape1 (or shape2) even
// though shape1 is only adjacent to the dateline. I couldn't think of a better way to handle this.
Rectangle oRect = (Rectangle)other;
if (cornerContainsNonGeo(oRect.getMinX(), oRect.getMinY())
&& cornerContainsNonGeo(oRect.getMinX(), oRect.getMaxY())
&& cornerContainsNonGeo(oRect.getMaxX(), oRect.getMinY())
&& cornerContainsNonGeo(oRect.getMaxX(), oRect.getMaxY()) )
return CONTAINS;
return r;
}
private boolean cornerContainsNonGeo(double x, double y) {
Shape pt = ctx2D.makePoint(x, y);
return shape1_2D.relate(pt).intersects() || shape2_2D.relate(pt).intersects();
}
private SpatialRelation relateApprox(Shape other) {
if (biasContainsThenWithin) {
if (shape1.relate(other) == CONTAINS || shape1.equals(other)
|| shape2.relate(other) == CONTAINS || shape2.equals(other)) return CONTAINS;
if (shape1.relate(other) == WITHIN && shape2.relate(other) == WITHIN) return WITHIN;
} else {
if ((shape1.relate(other) == WITHIN || shape1.equals(other))
&& (shape2.relate(other) == WITHIN || shape2.equals(other))) return WITHIN;
if (shape1.relate(other) == CONTAINS || shape2.relate(other) == CONTAINS) return CONTAINS;
}
if (shape1.relate(other).intersects() || shape2.relate(other).intersects())
return INTERSECTS;//might actually be 'CONTAINS' if the pair are adjacent but we handle that later
return DISJOINT;
}
@Override
public String toString() {
return "ShapePair(" + shape1 + " , " + shape2 + ")";
}
}
}