diff --git a/lucene/spatial/src/java/org/apache/lucene/spatial/query/SpatialOperation.java b/lucene/spatial/src/java/org/apache/lucene/spatial/query/SpatialOperation.java index e786974401f..247f6ae6a9e 100644 --- a/lucene/spatial/src/java/org/apache/lucene/spatial/query/SpatialOperation.java +++ b/lucene/spatial/src/java/org/apache/lucene/spatial/query/SpatialOperation.java @@ -17,6 +17,9 @@ package org.apache.lucene.spatial.query; * limitations under the License. */ +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.SpatialRelation; + import java.io.Serializable; import java.util.ArrayList; import java.util.HashMap; @@ -25,14 +28,16 @@ import java.util.Locale; import java.util.Map; /** - * A clause that compares a stored geometry to a supplied geometry. + * A clause that compares a stored geometry to a supplied geometry. For more + * explanation of each operation, consider looking at the source implementation + * of {@link #evaluate(com.spatial4j.core.shape.Shape, com.spatial4j.core.shape.Shape)}. * * @see * ESRIs docs on spatial relations * * @lucene.experimental */ -public class SpatialOperation implements Serializable { +public abstract class SpatialOperation implements Serializable { // Private registry private static final Map registry = new HashMap(); private static final List list = new ArrayList(); @@ -40,15 +45,55 @@ public class SpatialOperation implements Serializable { // Geometry Operations /** Bounding box of the *indexed* shape. */ - public static final SpatialOperation BBoxIntersects = new SpatialOperation("BBoxIntersects", true, false, false); + public static final SpatialOperation BBoxIntersects = new SpatialOperation("BBoxIntersects", true, false, false) { + @Override + public boolean evaluate(Shape indexedShape, Shape queryShape) { + return indexedShape.getBoundingBox().relate(queryShape).intersects(); + } + }; /** Bounding box of the *indexed* shape. */ - public static final SpatialOperation BBoxWithin = new SpatialOperation("BBoxWithin", true, false, false); - public static final SpatialOperation Contains = new SpatialOperation("Contains", true, true, false); - public static final SpatialOperation Intersects = new SpatialOperation("Intersects", true, false, false); - public static final SpatialOperation IsEqualTo = new SpatialOperation("IsEqualTo", false, false, false); - public static final SpatialOperation IsDisjointTo = new SpatialOperation("IsDisjointTo", false, false, false); - public static final SpatialOperation IsWithin = new SpatialOperation("IsWithin", true, false, true); - public static final SpatialOperation Overlaps = new SpatialOperation("Overlaps", true, false, true); + public static final SpatialOperation BBoxWithin = new SpatialOperation("BBoxWithin", true, false, false) { + @Override + public boolean evaluate(Shape indexedShape, Shape queryShape) { + return indexedShape.getBoundingBox().relate(queryShape) == SpatialRelation.WITHIN; + } + }; + public static final SpatialOperation Contains = new SpatialOperation("Contains", true, true, false) { + @Override + public boolean evaluate(Shape indexedShape, Shape queryShape) { + return indexedShape.hasArea() && indexedShape.relate(queryShape) == SpatialRelation.CONTAINS; + } + }; + public static final SpatialOperation Intersects = new SpatialOperation("Intersects", true, false, false) { + @Override + public boolean evaluate(Shape indexedShape, Shape queryShape) { + return indexedShape.relate(queryShape).intersects(); + } + }; + public static final SpatialOperation IsEqualTo = new SpatialOperation("IsEqualTo", false, false, false) { + @Override + public boolean evaluate(Shape indexedShape, Shape queryShape) { + return indexedShape.equals(queryShape); + } + }; + public static final SpatialOperation IsDisjointTo = new SpatialOperation("IsDisjointTo", false, false, false) { + @Override + public boolean evaluate(Shape indexedShape, Shape queryShape) { + return ! indexedShape.relate(queryShape).intersects(); + } + }; + public static final SpatialOperation IsWithin = new SpatialOperation("IsWithin", true, false, true) { + @Override + public boolean evaluate(Shape indexedShape, Shape queryShape) { + return queryShape.hasArea() && indexedShape.relate(queryShape) == SpatialRelation.WITHIN; + } + }; + public static final SpatialOperation Overlaps = new SpatialOperation("Overlaps", true, false, true) { + @Override + public boolean evaluate(Shape indexedShape, Shape queryShape) { + return queryShape.hasArea() && indexedShape.relate(queryShape).intersects(); + } + }; // Member variables private final boolean scoreIsMeaningful; @@ -90,6 +135,11 @@ public class SpatialOperation implements Serializable { return false; } + /** + * Returns whether the relationship between indexedShape and queryShape is + * satisfied by this operation. + */ + public abstract boolean evaluate(Shape indexedShape, Shape queryShape); // ================================================= Getters / Setters ============================================= diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/SpatialTestCase.java b/lucene/spatial/src/test/org/apache/lucene/spatial/SpatialTestCase.java index 13592ba7836..7e71f9c7a56 100644 --- a/lucene/spatial/src/test/org/apache/lucene/spatial/SpatialTestCase.java +++ b/lucene/spatial/src/test/org/apache/lucene/spatial/SpatialTestCase.java @@ -1,3 +1,5 @@ +package org.apache.lucene.spatial; + /* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with @@ -15,8 +17,9 @@ * limitations under the License. */ -package org.apache.lucene.spatial; - +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Rectangle; import org.apache.lucene.document.Document; import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.index.RandomIndexWriter; @@ -35,6 +38,10 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import static com.carrotsearch.randomizedtesting.RandomizedTest.randomGaussian; +import static com.carrotsearch.randomizedtesting.RandomizedTest.randomIntBetween; + +/** A base test class for spatial lucene. It's mostly Lucene generic. */ public abstract class SpatialTestCase extends LuceneTestCase { private DirectoryReader indexReader; @@ -42,6 +49,8 @@ public abstract class SpatialTestCase extends LuceneTestCase { private Directory directory; protected IndexSearcher indexSearcher; + protected SpatialContext ctx;//subclass must initialize + @Override @Before public void setUp() throws Exception { @@ -100,6 +109,63 @@ public abstract class SpatialTestCase extends LuceneTestCase { } } + protected Point randomPoint() { + final Rectangle WB = ctx.getWorldBounds(); + return ctx.makePoint( + randomIntBetween((int) WB.getMinX(), (int) WB.getMaxX()), + randomIntBetween((int) WB.getMinY(), (int) WB.getMaxY())); + } + + protected Rectangle randomRectangle() { + final Rectangle WB = ctx.getWorldBounds(); + int rW = (int) randomGaussianMeanMax(10, WB.getWidth()); + double xMin = randomIntBetween((int) WB.getMinX(), (int) WB.getMaxX() - rW); + double xMax = xMin + rW; + + int yH = (int) randomGaussianMeanMax(Math.min(rW, WB.getHeight()), WB.getHeight()); + double yMin = randomIntBetween((int) WB.getMinY(), (int) WB.getMaxY() - yH); + double yMax = yMin + yH; + + return ctx.makeRectangle(xMin, xMax, yMin, yMax); + } + + private double randomGaussianMinMeanMax(double min, double mean, double max) { + assert mean > min; + return randomGaussianMeanMax(mean - min, max - min) + min; + } + + /** + * Within one standard deviation (68% of the time) the result is "close" to + * mean. By "close": when greater than mean, it's the lesser of 2*mean or half + * way to max, when lesser than mean, it's the greater of max-2*mean or half + * way to 0. The other 32% of the time it's in the rest of the range, touching + * either 0 or max but never exceeding. + */ + private double randomGaussianMeanMax(double mean, double max) { + // DWS: I verified the results empirically + assert mean <= max && mean >= 0; + double g = randomGaussian(); + double mean2 = mean; + double flip = 1; + if (g < 0) { + mean2 = max - mean; + flip = -1; + g *= -1; + } + // pivot is the distance from mean2 towards max where the boundary of + // 1 standard deviation alters the calculation + double pivotMax = max - mean2; + double pivot = Math.min(mean2, pivotMax / 2);//from 0 to max-mean2 + assert pivot >= 0 && pivotMax >= pivot && g >= 0; + double pivotResult; + if (g <= 1) + pivotResult = pivot * g; + else + pivotResult = Math.min(pivotMax, (g - 1) * (pivotMax - pivot) + pivot); + + return mean + flip * pivotResult; + } + // ================================================= Inner Classes ================================================= protected static class SearchResults { @@ -116,7 +182,7 @@ public abstract class SpatialTestCase extends LuceneTestCase { StringBuilder str = new StringBuilder(); str.append("found: ").append(numFound).append('['); for(SearchResult r : results) { - String id = r.document.get("id"); + String id = r.getId(); str.append(id).append(", "); } str.append(']'); @@ -138,6 +204,10 @@ public abstract class SpatialTestCase extends LuceneTestCase { this.score = score; this.document = storedDocument; } + + public String getId() { + return document.get("id"); + } @Override public String toString() { diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/SpatialTestQuery.java b/lucene/spatial/src/test/org/apache/lucene/spatial/SpatialTestQuery.java index e165a7c10ca..4188983c9b5 100644 --- a/lucene/spatial/src/test/org/apache/lucene/spatial/SpatialTestQuery.java +++ b/lucene/spatial/src/test/org/apache/lucene/spatial/SpatialTestQuery.java @@ -82,6 +82,8 @@ public class SpatialTestQuery { @Override public String toString() { - return line; + if (line != null) + return line; + return args.toString()+" "+ids; } } diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/StrategyTestCase.java b/lucene/spatial/src/test/org/apache/lucene/spatial/StrategyTestCase.java index 294240d899b..91ecf206ebb 100644 --- a/lucene/spatial/src/test/org/apache/lucene/spatial/StrategyTestCase.java +++ b/lucene/spatial/src/test/org/apache/lucene/spatial/StrategyTestCase.java @@ -31,8 +31,9 @@ import org.apache.lucene.queries.function.ValueSource; import org.apache.lucene.search.CheckHits; import org.apache.lucene.search.ScoreDoc; import org.apache.lucene.search.TopDocs; +import org.apache.lucene.spatial.query.SpatialArgs; import org.apache.lucene.spatial.query.SpatialArgsParser; -import org.junit.Assert; +import org.apache.lucene.spatial.query.SpatialOperation; import java.io.FileNotFoundException; import java.io.IOException; @@ -42,6 +43,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.logging.Logger; @@ -64,7 +66,6 @@ public abstract class StrategyTestCase extends SpatialTestCase { protected final SpatialArgsParser argsParser = new SpatialArgsParser(); protected SpatialStrategy strategy; - protected SpatialContext ctx; protected boolean storeShape = true; protected void executeQueries(SpatialMatchConcern concern, String... testQueryFile) throws IOException { @@ -131,50 +132,52 @@ public abstract class StrategyTestCase extends SpatialTestCase { SpatialMatchConcern concern) { while (queries.hasNext()) { SpatialTestQuery q = queries.next(); + runTestQuery(concern, q); + } + } - String msg = q.line; //"Query: " + q.args.toString(ctx); - SearchResults got = executeQuery(strategy.makeQuery(q.args), 100); - if (storeShape && got.numFound > 0) { - //check stored value is there & parses - assertNotNull(ctx.readShape(got.results.get(0).document.get(strategy.getFieldName()))); - } - if (concern.orderIsImportant) { - Iterator ids = q.ids.iterator(); - for (SearchResult r : got.results) { - String id = r.document.get("id"); - if(!ids.hasNext()) { - Assert.fail(msg + " :: Did not get enough results. Expect" + q.ids+", got: "+got.toDebugString()); - } - Assert.assertEquals( "out of order: " + msg, ids.next(), id); + public void runTestQuery(SpatialMatchConcern concern, SpatialTestQuery q) { + String msg = q.toString(); //"Query: " + q.args.toString(ctx); + SearchResults got = executeQuery(strategy.makeQuery(q.args), Math.max(100, q.ids.size()+1)); + if (storeShape && got.numFound > 0) { + //check stored value is there & parses + assertNotNull(ctx.readShape(got.results.get(0).document.get(strategy.getFieldName()))); + } + if (concern.orderIsImportant) { + Iterator ids = q.ids.iterator(); + for (SearchResult r : got.results) { + String id = r.document.get("id"); + if (!ids.hasNext()) { + fail(msg + " :: Did not get enough results. Expect" + q.ids + ", got: " + got.toDebugString()); } - - if (ids.hasNext()) { - Assert.fail(msg + " :: expect more results then we got: " + ids.next()); + assertEquals("out of order: " + msg, ids.next(), id); + } + + if (ids.hasNext()) { + fail(msg + " :: expect more results then we got: " + ids.next()); + } + } else { + // We are looking at how the results overlap + if (concern.resultsAreSuperset) { + Set found = new HashSet(); + for (SearchResult r : got.results) { + found.add(r.document.get("id")); + } + for (String s : q.ids) { + if (!found.contains(s)) { + fail("Results are mising id: " + s + " :: " + found); + } } } else { - // We are looking at how the results overlap - if( concern.resultsAreSuperset ) { - Set found = new HashSet(); - for (SearchResult r : got.results) { - found.add(r.document.get("id")); - } - for( String s : q.ids ) { - if( !found.contains( s ) ) { - Assert.fail( "Results are mising id: "+s + " :: " + found ); - } - } + List found = new ArrayList(); + for (SearchResult r : got.results) { + found.add(r.document.get("id")); } - else { - List found = new ArrayList(); - for (SearchResult r : got.results) { - found.add(r.document.get("id")); - } - // sort both so that the order is not important - Collections.sort(q.ids); - Collections.sort(found); - Assert.assertEquals(msg, q.ids.toString(), found.toString()); - } + // sort both so that the order is not important + Collections.sort(q.ids); + Collections.sort(found); + assertEquals(msg, q.ids.toString(), found.toString()); } } } @@ -221,4 +224,19 @@ public abstract class StrategyTestCase extends SpatialTestCase { CheckHits.checkExplanations(q, "", indexSearcher); } + protected void assertOperation(Map indexedDocs, + SpatialOperation operation, Shape queryShape) { + //Generate truth via brute force + Set expectedIds = new HashSet(); + for (Map.Entry stringShapeEntry : indexedDocs.entrySet()) { + if (operation.evaluate(stringShapeEntry.getValue(), queryShape)) + expectedIds.add(stringShapeEntry.getKey()); + } + + SpatialTestQuery testQuery = new SpatialTestQuery(); + testQuery.args = new SpatialArgs(operation, queryShape); + testQuery.ids = new ArrayList(expectedIds); + runTestQuery(SpatialMatchConcern.FILTER, testQuery); + } + } diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/prefix/SpatialOpRecursivePrefixTreeTest.java b/lucene/spatial/src/test/org/apache/lucene/spatial/prefix/SpatialOpRecursivePrefixTreeTest.java new file mode 100644 index 00000000000..60ad4e760cf --- /dev/null +++ b/lucene/spatial/src/test/org/apache/lucene/spatial/prefix/SpatialOpRecursivePrefixTreeTest.java @@ -0,0 +1,133 @@ +package org.apache.lucene.spatial.prefix; + +/* + * 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. + */ + +import com.carrotsearch.randomizedtesting.annotations.Repeat; +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.shape.Rectangle; +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.impl.RectangleImpl; +import org.apache.lucene.search.Query; +import org.apache.lucene.spatial.StrategyTestCase; +import org.apache.lucene.spatial.prefix.tree.Node; +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 java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static com.carrotsearch.randomizedtesting.RandomizedTest.randomInt; +import static com.carrotsearch.randomizedtesting.RandomizedTest.randomIntBetween; + +public class SpatialOpRecursivePrefixTreeTest extends StrategyTestCase { + + private SpatialPrefixTree grid; + + @Test + @Repeat(iterations = 20) + public void testIntersects() throws IOException { + //non-geospatial makes this test a little easier + this.ctx = new SpatialContext(false, null, new RectangleImpl(0, 256, -128, 128, null)); + //A fairly shallow grid, and default 2.5% distErrPct + this.grid = new QuadPrefixTree(ctx, randomIntBetween(1, 8)); + this.strategy = new RecursivePrefixTreeStrategy(grid, getClass().getSimpleName()); + //((PrefixTreeStrategy) strategy).setDistErrPct(0);//fully precise to grid + + deleteAll(); + + Map indexedShapes = new LinkedHashMap(); + Map indexedGriddedShapes = new LinkedHashMap(); + final int numIndexedShapes = randomIntBetween(1, 6); + for (int i = 1; i <= numIndexedShapes; i++) { + String id = "" + i; + Shape indexShape = randomRectangle(); + Rectangle gridShape = gridSnapp(indexShape); + indexedShapes.put(id, indexShape); + indexedGriddedShapes.put(id, gridShape); + adoc(id, indexShape); + } + + commit(); + + final int numQueryShapes = atLeast(10); + for (int i = 0; i < numQueryShapes; i++) { + int scanLevel = randomInt(grid.getMaxLevels()); + ((RecursivePrefixTreeStrategy) strategy).setPrefixGridScanLevel(scanLevel); + Rectangle queryShape = randomRectangle(); + Rectangle queryGridShape = gridSnapp(queryShape); + + //Generate truth via brute force + final SpatialOperation operation = SpatialOperation.Intersects; + Set expectedIds = new TreeSet(); + Set optionalIds = new TreeSet(); + for (String id : indexedShapes.keySet()) { + Shape indexShape = indexedShapes.get(id); + Rectangle indexGridShape = indexedGriddedShapes.get(id); + if (operation.evaluate(indexShape, queryShape)) + expectedIds.add(id); + else if (operation.evaluate(indexGridShape, queryGridShape)) + optionalIds.add(id); + } + + //Search and verify results + Query query = strategy.makeQuery(new SpatialArgs(operation, queryShape)); + SearchResults got = executeQuery(query, 100); + Set remainingExpectedIds = new TreeSet(expectedIds); + String msg = queryShape.toString()+" Expect: "+expectedIds+" Opt: "+optionalIds; + for (SearchResult result : got.results) { + String id = result.getId(); + Object removed = remainingExpectedIds.remove(id); + if (removed == null) { + assertTrue("Shouldn't match " + id + " in "+msg, optionalIds.contains(id)); + } + } + assertTrue("Didn't match " + remainingExpectedIds + " in " + msg, remainingExpectedIds.isEmpty()); + } + + } + + protected Rectangle gridSnapp(Shape snapMe) { + //The next 4 lines mimic PrefixTreeStrategy.createIndexableFields() + double distErrPct = ((PrefixTreeStrategy) strategy).getDistErrPct(); + double distErr = SpatialArgs.calcDistanceFromErrPct(snapMe, distErrPct, ctx); + int detailLevel = grid.getLevelForDistance(distErr); + List cells = grid.getNodes(snapMe, detailLevel, false); + + //calc bounding box of cells. + double minX = Double.POSITIVE_INFINITY, maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY, maxY = Double.NEGATIVE_INFINITY; + for (Node cell : cells) { + assert cell.getLevel() <= detailLevel; + Rectangle cellR = cell.getShape().getBoundingBox(); + + minX = Math.min(minX, cellR.getMinX()); + maxX = Math.max(maxX, cellR.getMaxX()); + minY = Math.min(minY, cellR.getMinY()); + maxY = Math.max(maxY, cellR.getMaxY()); + } + return ctx.makeRectangle(minX, maxX, minY, maxY); + } + +}