From d448012a03357c4e7bf8c604342a6bb542b505f2 Mon Sep 17 00:00:00 2001 From: Ignacio Vera Date: Thu, 22 Dec 2022 18:55:50 +0100 Subject: [PATCH] Add Geohex aggregation on geo_shape field (#91956) This commit adds support for geohex aggregation on geoshape fields using cartesian geometry. --- docs/changelog/91956.yaml | 5 + .../GeoGridAggAndQueryConsistencyIT.java | 37 +- .../xpack/spatial/SpatialPlugin.java | 61 ++- .../spatial/common/H3CartesianGeometry.java | 351 +++++++++++++ .../xpack/spatial/common/H3CartesianUtil.java | 462 ++++++++++++++++++ .../spatial/index/fielddata/ShapeValues.java | 2 +- .../index/query/GeoGridQueryBuilder.java | 20 +- .../geogrid/AbstractGeoHexGridTiler.java | 191 ++++++++ .../geogrid/BoundedGeoHexGridTiler.java | 146 ++++++ .../bucket/geogrid/GeoHexVisitor.java | 353 +++++++++++++ .../geogrid/GeoShapeHexGridAggregator.java | 43 ++ .../geogrid/UnboundedGeoHexGridTiler.java | 69 +++ .../xpack/spatial/SpatialPluginTests.java | 10 + .../spatial/common/H3CartesianUtilTests.java | 241 +++++++++ .../bucket/geogrid/GeoGridTilerTestCase.java | 70 ++- .../bucket/geogrid/GeoHexTilerTests.java | 214 ++++++++ .../bucket/geogrid/GeoHexVisitorTests.java | 198 ++++++++ .../geogrid/GeoShapeGeoGridTestCase.java | 10 +- .../GeoShapeGeoHexGridAggregatorTests.java | 88 ++++ .../vectortile/rest/GridAggregation.java | 21 +- 20 files changed, 2494 insertions(+), 98 deletions(-) create mode 100644 docs/changelog/91956.yaml create mode 100644 x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/common/H3CartesianGeometry.java create mode 100644 x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/common/H3CartesianUtil.java create mode 100644 x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/AbstractGeoHexGridTiler.java create mode 100644 x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/BoundedGeoHexGridTiler.java create mode 100644 x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexVisitor.java create mode 100644 x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeHexGridAggregator.java create mode 100644 x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/UnboundedGeoHexGridTiler.java create mode 100644 x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/common/H3CartesianUtilTests.java create mode 100644 x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexTilerTests.java create mode 100644 x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexVisitorTests.java create mode 100644 x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoHexGridAggregatorTests.java diff --git a/docs/changelog/91956.yaml b/docs/changelog/91956.yaml new file mode 100644 index 0000000000000..4df8fa057dcc3 --- /dev/null +++ b/docs/changelog/91956.yaml @@ -0,0 +1,5 @@ +pr: 91956 +summary: Geohex aggregation on `geo_shape` field +area: Geo +type: feature +issues: [] diff --git a/x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoGridAggAndQueryConsistencyIT.java b/x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoGridAggAndQueryConsistencyIT.java index 1b497b7ee33eb..4e6a586465ef8 100644 --- a/x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoGridAggAndQueryConsistencyIT.java +++ b/x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoGridAggAndQueryConsistencyIT.java @@ -33,6 +33,7 @@ import org.elasticsearch.xcontent.XContentFactory; import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin; +import org.elasticsearch.xpack.spatial.common.H3CartesianUtil; import org.elasticsearch.xpack.spatial.index.mapper.GeoShapeWithDocValuesFieldMapper; import org.elasticsearch.xpack.spatial.index.query.GeoGridQueryBuilder; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregationBuilder; @@ -87,6 +88,10 @@ public void testGeoShapeGeoTile() throws IOException { ); } + public void testGeoShapeGeoHex() throws IOException { + doTestGeohexGrid(GeoShapeWithDocValuesFieldMapper.CONTENT_TYPE, () -> GeometryTestUtils.randomGeometryWithoutCircle(0, false)); + } + private void doTestGeohashGrid(String fieldType, Supplier randomGeometriesSupplier) throws IOException { doTestGrid( 1, @@ -124,43 +129,13 @@ private void doTestGeohexGrid(String fieldType, Supplier randomGeometr } return points; }, - this::toGeoHexRectangle, + h3 -> H3CartesianUtil.toBoundingBox(H3.stringToH3(h3)), GeoHexGridAggregationBuilder::new, (s1, s2) -> new GeoGridQueryBuilder(s1).setGridId(GeoGridQueryBuilder.Grid.GEOHEX, s2), randomGeometriesSupplier ); } - private Rectangle toGeoHexRectangle(String bucketKey) { - final long h3 = H3.stringToH3(bucketKey); - final CellBoundary boundary = H3.h3ToGeoBoundary(h3); - double minLat = Double.POSITIVE_INFINITY; - double minLon = Double.POSITIVE_INFINITY; - double maxLat = Double.NEGATIVE_INFINITY; - double maxLon = Double.NEGATIVE_INFINITY; - for (int i = 0; i < boundary.numPoints(); i++) { - final double boundaryLat = boundary.getLatLon(i).getLatDeg(); - final double boundaryLon = boundary.getLatLon(i).getLonDeg(); - minLon = Math.min(minLon, boundaryLon); - maxLon = Math.max(maxLon, boundaryLon); - minLat = Math.min(minLat, boundaryLat); - maxLat = Math.max(maxLat, boundaryLat); - } - final int resolution = H3.getResolution(h3); - if (H3.geoToH3(90, 0, resolution) == h3) { - // north pole - return new Rectangle(-180d, 180d, 90, minLat); - } else if (H3.geoToH3(-90, 0, resolution) == h3) { - // south pole - return new Rectangle(-180d, 180d, maxLat, -90); - } else if (maxLon - minLon > 180d) { - // crosses dateline - return new Rectangle(maxLon, minLon, maxLat, minLat); - } else { - return new Rectangle(minLon, maxLon, maxLat, minLat); - } - } - private void doTestGrid( int minPrecision, int maxPrecision, diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java index f01b7ab366d7b..6bddcf5089a61 100644 --- a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java @@ -51,6 +51,7 @@ import org.elasticsearch.xpack.spatial.search.aggregations.GeoLineAggregationBuilder; import org.elasticsearch.xpack.spatial.search.aggregations.InternalGeoLine; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.BoundedGeoHashGridTiler; +import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.BoundedGeoHexGridTiler; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.BoundedGeoTileGridTiler; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoGridTiler; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexCellIdSource; @@ -58,9 +59,11 @@ import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregator; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeCellIdSource; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeHashGridAggregator; +import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeHexGridAggregator; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeTileGridAggregator; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.InternalGeoHexGrid; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.UnboundedGeoHashGridTiler; +import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.UnboundedGeoHexGridTiler; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.UnboundedGeoTileGridTiler; import org.elasticsearch.xpack.spatial.search.aggregations.metrics.CartesianBoundsAggregationBuilder; import org.elasticsearch.xpack.spatial.search.aggregations.metrics.CartesianBoundsAggregator; @@ -310,12 +313,9 @@ private void registerGeoShapeGridAggregators(ValuesSourceRegistry.Builder builde collectsFromSingleBucket, metadata) -> { if (GEO_GRID_AGG_FEATURE.check(getLicenseState())) { - final GeoGridTiler tiler; - if (geoBoundingBox.isUnbounded()) { - tiler = new UnboundedGeoHashGridTiler(precision); - } else { - tiler = new BoundedGeoHashGridTiler(precision, geoBoundingBox); - } + final GeoGridTiler tiler = geoBoundingBox.isUnbounded() + ? new UnboundedGeoHashGridTiler(precision) + : new BoundedGeoHashGridTiler(precision, geoBoundingBox); GeoShapeCellIdSource cellIdSource = new GeoShapeCellIdSource((GeoShapeValuesSource) valuesSource, tiler); GeoShapeHashGridAggregator agg = new GeoShapeHashGridAggregator( name, @@ -353,12 +353,9 @@ private void registerGeoShapeGridAggregators(ValuesSourceRegistry.Builder builde collectsFromSingleBucket, metadata) -> { if (GEO_GRID_AGG_FEATURE.check(getLicenseState())) { - final GeoGridTiler tiler; - if (geoBoundingBox.isUnbounded()) { - tiler = new UnboundedGeoTileGridTiler(precision); - } else { - tiler = new BoundedGeoTileGridTiler(precision, geoBoundingBox); - } + final GeoGridTiler tiler = geoBoundingBox.isUnbounded() + ? new UnboundedGeoTileGridTiler(precision) + : new BoundedGeoTileGridTiler(precision, geoBoundingBox); GeoShapeCellIdSource cellIdSource = new GeoShapeCellIdSource((GeoShapeValuesSource) valuesSource, tiler); GeoShapeTileGridAggregator agg = new GeoShapeTileGridAggregator( name, @@ -379,6 +376,46 @@ private void registerGeoShapeGridAggregators(ValuesSourceRegistry.Builder builde }, true ); + + builder.register( + GeoHexGridAggregationBuilder.REGISTRY_KEY, + GeoShapeValuesSourceType.instance(), + ( + name, + factories, + valuesSource, + precision, + geoBoundingBox, + requiredSize, + shardSize, + context, + parent, + collectsFromSingleBucket, + metadata) -> { + if (GEO_GRID_AGG_FEATURE.check(getLicenseState())) { + final GeoGridTiler tiler = geoBoundingBox.isUnbounded() + ? new UnboundedGeoHexGridTiler(precision) + : new BoundedGeoHexGridTiler(precision, geoBoundingBox); + GeoShapeCellIdSource cellIdSource = new GeoShapeCellIdSource((GeoShapeValuesSource) valuesSource, tiler); + GeoShapeHexGridAggregator agg = new GeoShapeHexGridAggregator( + name, + factories, + cellIdSource, + requiredSize, + shardSize, + context, + parent, + collectsFromSingleBucket, + metadata + ); + // this would ideally be something set in an immutable way on the ValuesSource + cellIdSource.setCircuitBreakerConsumer(agg::addRequestBytes); + return agg; + } + throw LicenseUtils.newComplianceException("geohex_grid aggregation on geo_shape fields"); + }, + true + ); } private static void registerValueCountAggregator(ValuesSourceRegistry.Builder builder) { diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/common/H3CartesianGeometry.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/common/H3CartesianGeometry.java new file mode 100644 index 0000000000000..6391a9198048e --- /dev/null +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/common/H3CartesianGeometry.java @@ -0,0 +1,351 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.common; + +import org.apache.lucene.geo.Component2D; +import org.apache.lucene.geo.LatLonGeometry; +import org.apache.lucene.geo.Rectangle; +import org.apache.lucene.index.PointValues; +import org.apache.lucene.util.ArrayUtil; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; + +/** + * Lucene geometry representing an H3 bin on the cartesian space. + */ +class H3CartesianGeometry extends LatLonGeometry { + + private final long h3; + + H3CartesianGeometry(long h3) { + this.h3 = h3; + } + + @Override + protected Component2D toComponent2D() { + return new H3CartesianComponent(h3); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + H3CartesianGeometry that = (H3CartesianGeometry) o; + return h3 == that.h3; + } + + @Override + public int hashCode() { + return Long.hashCode(h3); + } + + private static class H3CartesianComponent implements Component2D { + + private final double[] xs, ys; + private final double minX, maxX, minY, maxY; + private final boolean crossesDateline; + + H3CartesianComponent(long h3) { + final double[] xs = new double[H3CartesianUtil.MAX_ARRAY_SIZE]; + final double[] ys = new double[H3CartesianUtil.MAX_ARRAY_SIZE]; + final int numPoints = H3CartesianUtil.computePoints(h3, xs, ys); + this.xs = ArrayUtil.copyOfSubArray(xs, 0, numPoints); + this.ys = ArrayUtil.copyOfSubArray(ys, 0, numPoints); + double minX = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < numPoints; i++) { + minX = Math.min(minX, xs[i]); + maxX = Math.max(maxX, xs[i]); + minY = Math.min(minY, ys[i]); + maxY = Math.max(maxY, ys[i]); + } + this.minX = minX; + this.maxX = maxX; + this.minY = minY; + this.maxY = maxY; + this.crossesDateline = maxX - minX > 180d && H3CartesianUtil.isPolar(h3) == false; + } + + @Override + public double getMinX() { + return crossesDateline ? -180d : minX; + } + + @Override + public double getMaxX() { + return crossesDateline ? 180d : maxX; + } + + @Override + public double getMinY() { + return minY; + } + + @Override + public double getMaxY() { + return maxY; + } + + @Override + public boolean contains(double x, double y) { + // fail fast if we're outside the bounding box + if (Rectangle.containsPoint(y, x, getMinY(), getMaxY(), getMinX(), getMaxX()) == false) { + return false; + } + return H3CartesianUtil.relatePoint(xs, ys, xs.length, crossesDateline, x, y) != GeoRelation.QUERY_DISJOINT; + } + + @Override + public PointValues.Relation relate(double minX, double maxX, double minY, double maxY) { + if (Component2D.disjoint(getMinX(), getMaxX(), getMinY(), getMaxY(), minX, maxX, minY, maxY)) { + return PointValues.Relation.CELL_OUTSIDE_QUERY; + } + if (Component2D.within(getMinX(), getMaxX(), getMinY(), getMaxY(), minX, maxX, minY, maxY)) { + return PointValues.Relation.CELL_CROSSES_QUERY; + } + int numCorners = numberOfCorners(minX, maxX, minY, maxY); + if (numCorners == 4) { + // need to check in case we are crossing the dateline + if (crossesDateline && H3CartesianUtil.crossesBox(xs, ys, xs.length, crossesDateline, minX, maxX, minY, maxY, true)) { + return PointValues.Relation.CELL_CROSSES_QUERY; + } + return PointValues.Relation.CELL_INSIDE_QUERY; + } else if (numCorners == 0) { + if (Component2D.containsPoint(xs[0], ys[0], minX, maxX, minY, maxY)) { + return PointValues.Relation.CELL_CROSSES_QUERY; + } + if (H3CartesianUtil.crossesBox(xs, ys, xs.length, crossesDateline, minX, maxX, minY, maxY, true)) { + return PointValues.Relation.CELL_CROSSES_QUERY; + } + return PointValues.Relation.CELL_OUTSIDE_QUERY; + } + return PointValues.Relation.CELL_CROSSES_QUERY; + } + + @Override + public boolean intersectsLine(double minX, double maxX, double minY, double maxY, double aX, double aY, double bX, double bY) { + return contains(aX, aY) || contains(bX, bY) || crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, true); + } + + @Override + public boolean intersectsTriangle( + double minX, + double maxX, + double minY, + double maxY, + double aX, + double aY, + double bX, + double bY, + double cX, + double cY + ) { + if (Component2D.disjoint(getMinX(), getMaxX(), getMinY(), getMaxY(), minX, maxX, minY, maxY)) { + return false; + } + return contains(aX, aY) + || contains(bX, bY) + || contains(cX, cY) + || Component2D.pointInTriangle(minX, maxX, minY, maxY, xs[0], ys[0], aX, aY, bX, bY, cX, cY) + || H3CartesianUtil.crossesTriangle( + xs, + ys, + xs.length, + crossesDateline, + minX, + maxX, + minY, + maxY, + aX, + aY, + bX, + bY, + cX, + cY, + true + ); + } + + @Override + public boolean containsLine(double minX, double maxX, double minY, double maxY, double aX, double aY, double bX, double bY) { + return contains(aX, aY) && contains(bX, bY) && crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, false) == false; + } + + @Override + public boolean containsTriangle( + double minX, + double maxX, + double minY, + double maxY, + double aX, + double aY, + double bX, + double bY, + double cX, + double cY + ) { + return (contains(aX, aY) + && contains(bX, bY) + && contains(cX, cY) + && H3CartesianUtil.crossesTriangle( + xs, + ys, + xs.length, + crossesDateline, + minX, + maxX, + minY, + maxY, + aX, + aY, + bX, + bY, + cX, + cY, + false + ) == false); + + } + + @Override + public WithinRelation withinPoint(double x, double y) { + return contains(x, y) ? WithinRelation.NOTWITHIN : WithinRelation.DISJOINT; + } + + @Override + public WithinRelation withinLine( + double minX, + double maxX, + double minY, + double maxY, + double aX, + double aY, + boolean ab, + double bX, + double bY + ) { + if (Component2D.disjoint(getMinX(), getMaxX(), getMinY(), getMaxY(), minX, maxX, minY, maxY)) { + return WithinRelation.DISJOINT; + } + if (contains(aX, aY) || contains(bX, bY)) { + return WithinRelation.NOTWITHIN; + } + if (ab && crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, true)) { + return WithinRelation.NOTWITHIN; + } + return WithinRelation.DISJOINT; + } + + @Override + public WithinRelation withinTriangle( + double minX, + double maxX, + double minY, + double maxY, + double aX, + double aY, + boolean ab, + double bX, + double bY, + boolean bc, + double cX, + double cY, + boolean ca + ) { + if (Component2D.disjoint(getMinX(), getMaxX(), getMinY(), getMaxY(), minX, maxX, minY, maxY)) { + return WithinRelation.DISJOINT; + } + + // if any of the points is inside the polygon, the polygon cannot be within this indexed + // shape because points belong to the original indexed shape. + if (contains(aX, aY) || contains(bX, bY) || contains(cX, cY)) { + return WithinRelation.NOTWITHIN; + } + + WithinRelation relation = WithinRelation.DISJOINT; + // if any of the edges intersects and the edge belongs to the shape then it cannot be within. + // if it only intersects edges that do not belong to the shape, then it is a candidate + // we skip edges at the dateline to support shapes crossing it + if (crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, true)) { + if (ab) { + return WithinRelation.NOTWITHIN; + } else { + relation = WithinRelation.CANDIDATE; + } + } + + if (crossesLine(minX, maxX, minY, maxY, bX, bY, cX, cY, true)) { + if (bc) { + return WithinRelation.NOTWITHIN; + } else { + relation = WithinRelation.CANDIDATE; + } + } + if (crossesLine(minX, maxX, minY, maxY, cX, cY, aX, aY, true)) { + if (ca) { + return WithinRelation.NOTWITHIN; + } else { + relation = WithinRelation.CANDIDATE; + } + } + + // if any of the edges crosses and edge that does not belong to the shape + // then it is a candidate for within + if (relation == WithinRelation.CANDIDATE) { + return WithinRelation.CANDIDATE; + } + + // Check if shape is within the triangle + if (Component2D.pointInTriangle(minX, maxX, minY, maxY, xs[0], ys[0], aX, aY, bX, bY, cX, cY)) { + return WithinRelation.CANDIDATE; + } + return relation; + } + + private boolean crossesLine( + double minX, + double maxX, + double minY, + double maxY, + double aX, + double aY, + double bX, + double bY, + boolean includeBoundary + ) { + if (Component2D.disjoint(getMinX(), getMaxX(), getMinY(), getMaxY(), minX, maxX, minY, maxY)) { + return false; + } + return H3CartesianUtil.crossesLine(xs, ys, xs.length, crossesDateline, minX, maxX, minY, maxY, aX, aY, bX, bY, includeBoundary); + } + + private int numberOfCorners(double minX, double maxX, double minY, double maxY) { + int containsCount = 0; + if (contains(minX, minY)) { + containsCount++; + } + if (contains(maxX, minY)) { + containsCount++; + } + if (containsCount == 1) { + return containsCount; + } + if (contains(maxX, maxY)) { + containsCount++; + } + if (containsCount == 2) { + return containsCount; + } + if (contains(minX, maxY)) { + containsCount++; + } + return containsCount; + } + } +} diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/common/H3CartesianUtil.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/common/H3CartesianUtil.java new file mode 100644 index 0000000000000..f03e024d4f20e --- /dev/null +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/common/H3CartesianUtil.java @@ -0,0 +1,462 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.common; + +import org.apache.lucene.geo.GeoEncodingUtils; +import org.apache.lucene.geo.GeoUtils; +import org.apache.lucene.geo.LatLonGeometry; +import org.apache.lucene.geo.Rectangle; +import org.apache.lucene.util.ArrayUtil; +import org.apache.lucene.util.IntroSorter; +import org.elasticsearch.h3.CellBoundary; +import org.elasticsearch.h3.H3; +import org.elasticsearch.h3.LatLng; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.DoubleUnaryOperator; + +import static org.apache.lucene.geo.GeoUtils.lineCrossesLine; +import static org.apache.lucene.geo.GeoUtils.lineCrossesLineWithBoundary; + +/** + * Utility class that generates H3 bins coordinates projected on the cartesian plane (equirectangular projection). + * Provides spatial methods to compute spatial intersections on those coordinates. + */ +public final class H3CartesianUtil { + public static final int MAX_ARRAY_SIZE = 15; + private static final DoubleUnaryOperator NORMALIZE_LONG_POS = lon -> lon < 0 ? lon + 360d : lon; + private static final DoubleUnaryOperator NORMALIZE_LONG_NEG = lon -> lon > 0 ? lon - 360d : lon; + private static final long[] NORTH = new long[16]; + private static final long[] SOUTH = new long[16]; + static { + for (int res = 0; res <= H3.MAX_H3_RES; res++) { + NORTH[res] = H3.geoToH3(90, 0, res); + SOUTH[res] = H3.geoToH3(-90, 0, res); + } + } + // we cache the first two levels and polar polygons + private static final Map CACHED_H3 = new HashMap<>(); + static { + for (long res0Cell : H3.getLongRes0Cells()) { + CACHED_H3.put(res0Cell, getCoordinates(res0Cell)); + for (long h3 : H3.h3ToChildren(res0Cell)) { + CACHED_H3.put(h3, getCoordinates(h3)); + } + } + for (int res = 2; res <= H3.MAX_H3_RES; res++) { + CACHED_H3.put(NORTH[res], getCoordinates(NORTH[res])); + CACHED_H3.put(SOUTH[res], getCoordinates(SOUTH[res])); + } + } + + private static final double[] NORTH_BOUND = new double[16]; + private static final double[] SOUTH_BOUND = new double[16]; + static { + for (int res = 0; res <= H3.MAX_H3_RES; res++) { + NORTH_BOUND[res] = toBoundingBox(NORTH[res]).getMinY(); + SOUTH_BOUND[res] = toBoundingBox(SOUTH[res]).getMaxY(); + } + } + + /** For the given resolution, it returns the maximum latitude of the h3 bin containing the south pole */ + public static boolean isPolar(long h3) { + final int resolution = H3.getResolution(h3); + return SOUTH[resolution] == h3 || NORTH[resolution] == h3; + } + + /** For the given resolution, it returns the maximum latitude of the h3 bin containing the south pole */ + public static double getSouthPolarBound(int resolution) { + return SOUTH_BOUND[resolution]; + } + + /** For the given resolution, it returns the minimum latitude of the h3 bin containing the north pole */ + public static double getNorthPolarBound(int resolution) { + return NORTH_BOUND[resolution]; + } + + private static double[][] getCoordinates(final long h3) { + final double[] xs = new double[MAX_ARRAY_SIZE]; + final double[] ys = new double[MAX_ARRAY_SIZE]; + final int numPoints = computePoints(h3, xs, ys); + return new double[][] { ArrayUtil.copyOfSubArray(xs, 0, numPoints), ArrayUtil.copyOfSubArray(ys, 0, numPoints), }; + } + + /** It stores the points for the given h3 in the provided arrays.The arrays + * should be at least have the length of {@link #MAX_ARRAY_SIZE}. It returns the number of point added. */ + public static int computePoints(final long h3, final double[] xs, final double[] ys) { + final double[][] cached = CACHED_H3.get(h3); + if (cached != null) { + System.arraycopy(cached[0], 0, xs, 0, cached[0].length); + System.arraycopy(cached[1], 0, ys, 0, cached[0].length); + return cached[0].length; + } + final int resolution = H3.getResolution(h3); + final double pole = NORTH[resolution] == h3 ? GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(90d)) + : SOUTH[resolution] == h3 ? GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(-90d)) + : Double.NaN; + final CellBoundary cellBoundary = H3.h3ToGeoBoundary(h3); + final int numPoints; + if (Double.isNaN(pole)) { + numPoints = cellBoundary.numPoints() + 1; + } else { + numPoints = cellBoundary.numPoints() + 5; + } + for (int i = 0; i < cellBoundary.numPoints(); i++) { + final LatLng latLng = cellBoundary.getLatLon(i); + xs[i] = GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.encodeLongitude(latLng.getLonDeg())); + ys[i] = GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(latLng.getLatDeg())); + } + if (Double.isNaN(pole)) { + xs[cellBoundary.numPoints()] = xs[0]; + ys[cellBoundary.numPoints()] = ys[0]; + } else { + closePolarComponent(xs, ys, cellBoundary.numPoints(), pole); + } + return numPoints; + } + + private static void closePolarComponent(double[] xs, double[] ys, int numBoundaryPoints, double pole) { + sort(xs, ys, numBoundaryPoints); + assert xs[0] > 0 != xs[numBoundaryPoints - 1] > 0 : "expected first and last element with different sign"; + final double y = datelineIntersectionLatitude(xs[0], ys[0], xs[numBoundaryPoints - 1], ys[numBoundaryPoints - 1]); + xs[numBoundaryPoints] = GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.MAX_LON_ENCODED); + ys[numBoundaryPoints] = y; + xs[numBoundaryPoints + 1] = GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.MAX_LON_ENCODED); + ys[numBoundaryPoints + 1] = pole; + xs[numBoundaryPoints + 2] = GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.MIN_LON_ENCODED); + ys[numBoundaryPoints + 2] = pole; + xs[numBoundaryPoints + 3] = GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.MIN_LON_ENCODED); + ys[numBoundaryPoints + 3] = y; + xs[numBoundaryPoints + 4] = xs[0]; + ys[numBoundaryPoints + 4] = ys[0]; + } + + private static void sort(double[] xs, double[] ys, int length) { + new IntroSorter() { + int pivotPos = -1; + + @Override + protected void swap(int i, int j) { + double tmp = xs[i]; + xs[i] = xs[j]; + xs[j] = tmp; + tmp = ys[i]; + ys[i] = ys[j]; + ys[j] = tmp; + } + + @Override + protected void setPivot(int i) { + pivotPos = i; + } + + @Override + protected int comparePivot(int j) { + // all xs are different + return Double.compare(xs[pivotPos], xs[j]); + } + }.sort(0, length); + } + + private static double datelineIntersectionLatitude(double x1, double y1, double x2, double y2) { + final double t = (180d - NORMALIZE_LONG_POS.applyAsDouble(x1)) / (NORMALIZE_LONG_POS.applyAsDouble(x2) - NORMALIZE_LONG_POS + .applyAsDouble(x1)); + assert t > 0 && t <= 1; + return y1 + t * (y2 - y1); + } + + /** Return the {@link LatLonGeometry} representing the provided H3 bin */ + public static LatLonGeometry getLatLonGeometry(long h3) { + return new H3CartesianGeometry(h3); + } + + /** Return the bounding box of the provided H3 bin */ + public static org.elasticsearch.geometry.Rectangle toBoundingBox(long h3) { + final CellBoundary boundary = H3.h3ToGeoBoundary(h3); + double minLat = Double.POSITIVE_INFINITY; + double minLon = Double.POSITIVE_INFINITY; + double maxLat = Double.NEGATIVE_INFINITY; + double maxLon = Double.NEGATIVE_INFINITY; + for (int i = 0; i < boundary.numPoints(); i++) { + final LatLng latLng = boundary.getLatLon(i); + minLat = Math.min(minLat, latLng.getLatDeg()); + minLon = Math.min(minLon, latLng.getLonDeg()); + maxLat = Math.max(maxLat, latLng.getLatDeg()); + maxLon = Math.max(maxLon, latLng.getLonDeg()); + } + final int res = H3.getResolution(h3); + if (h3 == NORTH[res]) { + return new org.elasticsearch.geometry.Rectangle(-180d, 180d, 90d, minLat); + } else if (h3 == SOUTH[res]) { + return new org.elasticsearch.geometry.Rectangle(-180d, 180d, maxLat, -90d); + } else if (maxLon - minLon > 180d) { + return new org.elasticsearch.geometry.Rectangle(maxLon, minLon, maxLat, minLat); + } else { + return new org.elasticsearch.geometry.Rectangle(minLon, maxLon, maxLat, minLat); + } + } + + /** Return the spatial relationship between an H3 and a point.*/ + public static GeoRelation relatePoint(double[] xs, double[] ys, int numPoints, boolean crossesDateline, double x, double y) { + final DoubleUnaryOperator normalizeLong = crossesDateline ? NORMALIZE_LONG_POS : DoubleUnaryOperator.identity(); + return relatePoint(xs, ys, numPoints, x, y, normalizeLong); + } + + private static GeoRelation relatePoint(double[] xs, double[] ys, int numPoints, double x, double y, DoubleUnaryOperator normalize_lon) { + boolean res = false; + x = normalize_lon.applyAsDouble(x); + for (int i = 0; i < numPoints - 1; i++) { + final double x1 = normalize_lon.applyAsDouble(xs[i]); + final double x2 = normalize_lon.applyAsDouble(xs[i + 1]); + final double y1 = ys[i]; + final double y2 = ys[i + 1]; + if (y == y1 && y == y2 || (y <= y1 && y >= y2) != (y >= y1 && y <= y2)) { + if ((x == x1 && x == x2) || ((x <= x1 && x >= x2) != (x >= x1 && x <= x2) && GeoUtils.orient(x1, y1, x2, y2, x, y) == 0)) { + return GeoRelation.QUERY_CROSSES; + } else if (y1 > y != y2 > y) { + res ^= x < (x2 - x1) * (y - y1) / (y2 - y1) + x1; + } + } + } + return res ? GeoRelation.QUERY_CONTAINS : GeoRelation.QUERY_DISJOINT; + } + + /** Checks if a line crosses a h3 bin.*/ + public static boolean crossesLine( + double[] xs, + double[] ys, + int numPoints, + boolean crossesDateline, + double minX, + double maxX, + double minY, + double maxY, + double aX, + double aY, + double bX, + double bY, + boolean includeBoundary + ) { + if (crossesDateline) { + return crossesLine(xs, ys, numPoints, minX, maxX, minY, maxY, aX, aY, bX, bY, includeBoundary, NORMALIZE_LONG_POS) + || crossesLine(xs, ys, numPoints, minX, maxX, minY, maxY, aX, aY, bX, bY, includeBoundary, NORMALIZE_LONG_NEG); + } else { + return crossesLine(xs, ys, numPoints, minX, maxX, minY, maxY, aX, aY, bX, bY, includeBoundary, DoubleUnaryOperator.identity()); + } + } + + private static boolean crossesLine( + double[] xs, + double[] ys, + int numPoints, + double minX, + double maxX, + double minY, + double maxY, + double aX, + double aY, + double bX, + double bY, + boolean includeBoundary, + DoubleUnaryOperator normalizeLong + ) { + + for (int i = 0; i < numPoints - 1; i++) { + double cy = ys[i]; + double dy = ys[i + 1]; + double cx = normalizeLong.applyAsDouble(xs[i]); + double dx = normalizeLong.applyAsDouble(xs[i + 1]); + // compute bounding box of line + double lMinX = StrictMath.min(cx, dx); + double lMaxX = StrictMath.max(cx, dx); + double lMinY = StrictMath.min(cy, dy); + double lMaxY = StrictMath.max(cy, dy); + + // 2. check bounding boxes are disjoint + if (lMaxX < minX || lMinX > maxX || lMinY > maxY || lMaxY < minY) { + continue; + } + if (includeBoundary) { + if (GeoUtils.lineCrossesLineWithBoundary(cx, cy, dx, dy, aX, aY, bX, bY)) { + return true; + } + } else { + if (GeoUtils.lineCrossesLine(cx, cy, dx, dy, aX, aY, bX, bY)) { + return true; + } + } + } + return false; + } + + /** Checks if a triangle crosses a h3 bin.*/ + public static boolean crossesTriangle( + double[] xs, + double[] ys, + int numPoints, + boolean crossesDateline, + double minX, + double maxX, + double minY, + double maxY, + double ax, + double ay, + double bx, + double by, + double cx, + double cy, + boolean includeBoundary + ) { + if (crossesDateline) { + return crossesTriangle(xs, ys, numPoints, minX, maxX, minY, maxY, ax, ay, bx, by, cx, cy, includeBoundary, NORMALIZE_LONG_POS) + || crossesTriangle(xs, ys, numPoints, minX, maxX, minY, maxY, ax, ay, bx, by, cx, cy, includeBoundary, NORMALIZE_LONG_NEG); + } else { + return crossesTriangle( + xs, + ys, + numPoints, + minX, + maxX, + minY, + maxY, + ax, + ay, + bx, + by, + cx, + cy, + includeBoundary, + DoubleUnaryOperator.identity() + ); + } + } + + private static boolean crossesTriangle( + double[] xs, + double[] ys, + int numPoints, + double minX, + double maxX, + double minY, + double maxY, + double ax, + double ay, + double bx, + double by, + double cx, + double cy, + boolean includeBoundary, + DoubleUnaryOperator normalizeLong + ) { + for (int i = 0; i < numPoints - 1; i++) { + double dy = ys[i]; + double ey = ys[i + 1]; + double dx = normalizeLong.applyAsDouble(xs[i]); + double ex = normalizeLong.applyAsDouble(xs[i + 1]); + + // optimization: see if the rectangle is outside of the "bounding box" of the polyline at all + // if not, don't waste our time trying more complicated stuff + boolean outside = (dy < minY && ey < minY) || (dy > maxY && ey > maxY) || (dx < minX && ex < minX) || (dx > maxX && ex > maxX); + + if (outside == false) { + if (includeBoundary) { + if (lineCrossesLineWithBoundary(dx, dy, ex, ey, ax, ay, bx, by) + || lineCrossesLineWithBoundary(dx, dy, ex, ey, bx, by, cx, cy) + || lineCrossesLineWithBoundary(dx, dy, ex, ey, cx, cy, ax, ay)) { + return true; + } + } else { + if (lineCrossesLine(dx, dy, ex, ey, ax, ay, bx, by) + || lineCrossesLine(dx, dy, ex, ey, bx, by, cx, cy) + || lineCrossesLine(dx, dy, ex, ey, cx, cy, ax, ay)) { + return true; + } + } + } + + } + return false; + } + + /** Checks if a rectangle crosses a h3 bin.*/ + public static boolean crossesBox( + double[] xs, + double[] ys, + int numPoints, + boolean crossesDateline, + double minX, + double maxX, + double minY, + double maxY, + boolean includeBoundary + ) { + if (crossesDateline) { + return crossesBox(xs, ys, numPoints, minX, maxX, minY, maxY, includeBoundary, NORMALIZE_LONG_POS) + || crossesBox(xs, ys, numPoints, minX, maxX, minY, maxY, includeBoundary, NORMALIZE_LONG_NEG); + } else { + return crossesBox(xs, ys, numPoints, minX, maxX, minY, maxY, includeBoundary, DoubleUnaryOperator.identity()); + } + } + + private static boolean crossesBox( + double[] xs, + double[] ys, + int numPoints, + double minX, + double maxX, + double minY, + double maxY, + boolean includeBoundary, + DoubleUnaryOperator normalizeLong + ) { + // we just have to cross one edge to answer the question, so we descend the tree and return when + // we do. + for (int i = 0; i < numPoints - 1; i++) { + // we compute line intersections of every polygon edge with every box line. + // if we find one, return true. + // for each box line (AB): + // for each poly line (CD): + // intersects = orient(C,D,A) * orient(C,D,B) <= 0 && orient(A,B,C) * orient(A,B,D) <= 0 + double cy = ys[i]; + double dy = ys[i + 1]; + double cx = normalizeLong.applyAsDouble(xs[i]); + double dx = normalizeLong.applyAsDouble(xs[i + 1]); + + // optimization: see if either end of the line segment is contained by the rectangle + if (Rectangle.containsPoint(cy, cx, minY, maxY, minX, maxX) || Rectangle.containsPoint(dy, dx, minY, maxY, minX, maxX)) { + return true; + } + + // optimization: see if the rectangle is outside of the "bounding box" of the polyline at all + // if not, don't waste our time trying more complicated stuff + boolean outside = (cy < minY && dy < minY) || (cy > maxY && dy > maxY) || (cx < minX && dx < minX) || (cx > maxX && dx > maxX); + + if (outside == false) { + if (includeBoundary) { + if (lineCrossesLineWithBoundary(cx, cy, dx, dy, minX, minY, maxX, minY) + || lineCrossesLineWithBoundary(cx, cy, dx, dy, maxX, minY, maxX, maxY) + || lineCrossesLineWithBoundary(cx, cy, dx, dy, maxX, maxY, minX, maxY) + || lineCrossesLineWithBoundary(cx, cy, dx, dy, minX, maxY, minX, minY)) { + // include boundaries: ensures box edges that terminate on the polygon are included + return true; + } + } else { + if (lineCrossesLine(cx, cy, dx, dy, minX, minY, maxX, minY) + || lineCrossesLine(cx, cy, dx, dy, maxX, minY, maxX, maxY) + || lineCrossesLine(cx, cy, dx, dy, maxX, maxY, minX, maxY) + || lineCrossesLine(cx, cy, dx, dy, minX, maxY, minX, minY)) { + return true; + } + } + } + } + return false; + } +} diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/ShapeValues.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/ShapeValues.java index 03b9375b19b6e..1036030546bcf 100644 --- a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/ShapeValues.java +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/ShapeValues.java @@ -155,7 +155,7 @@ public SpatialPoint labelPosition() throws IOException { * simple geometries, therefore it will fail if the LatLonGeometry is a {@link org.apache.lucene.geo.Rectangle} * that crosses the dateline. */ - protected GeoRelation relate(Component2D component2D) throws IOException { + public GeoRelation relate(Component2D component2D) throws IOException { component2DRelationVisitor.reset(component2D); reader.visit(component2DRelationVisitor); return component2DRelationVisitor.relation(); diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/query/GeoGridQueryBuilder.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/query/GeoGridQueryBuilder.java index 663ffa5c8be48..26e568dd602cd 100644 --- a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/query/GeoGridQueryBuilder.java +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/query/GeoGridQueryBuilder.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.spatial.index.query; import org.apache.lucene.geo.GeoEncodingUtils; +import org.apache.lucene.geo.LatLonGeometry; import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.Query; import org.elasticsearch.ElasticsearchParseException; @@ -30,6 +31,8 @@ import org.elasticsearch.xcontent.ParseField; import org.elasticsearch.xcontent.XContentBuilder; import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xpack.spatial.common.H3CartesianUtil; +import org.elasticsearch.xpack.spatial.index.mapper.GeoShapeWithDocValuesFieldMapper; import java.io.IOException; import java.util.Objects; @@ -102,11 +105,18 @@ protected void validate(String gridId) { @Override protected Query toQuery(SearchExecutionContext context, String fieldName, MappedFieldType fieldType, String id) { - H3LatLonGeometry geometry = new H3LatLonGeometry(id); - if (fieldType instanceof GeoPointFieldMapper.GeoPointFieldType pointFieldType) { - return pointFieldType.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, geometry); - } else if (fieldType instanceof GeoPointScriptFieldType scriptType) { - return scriptType.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, geometry); + if (fieldType instanceof GeoShapeWithDocValuesFieldMapper.GeoShapeWithDocValuesFieldType geoShapeFieldType) { + // shapes are solved on the cartesian geometry + final LatLonGeometry geometry = H3CartesianUtil.getLatLonGeometry(H3.stringToH3(id)); + return geoShapeFieldType.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, geometry); + } else { + // points are solved on the spherical geometry + final H3LatLonGeometry geometry = new H3LatLonGeometry(id); + if (fieldType instanceof GeoPointFieldMapper.GeoPointFieldType pointFieldType) { + return pointFieldType.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, geometry); + } else if (fieldType instanceof GeoPointScriptFieldType scriptType) { + return scriptType.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, geometry); + } } throw new QueryShardException( context, diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/AbstractGeoHexGridTiler.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/AbstractGeoHexGridTiler.java new file mode 100644 index 0000000000000..7064c5659ac61 --- /dev/null +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/AbstractGeoHexGridTiler.java @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid; + +import org.elasticsearch.h3.H3; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues; + +import java.io.IOException; + +/** + * Implements most of the logic for the GeoHex aggregation. + */ +abstract class AbstractGeoHexGridTiler extends GeoGridTiler { + + private static final long[] RES0CELLS = H3.getLongRes0Cells(); + + AbstractGeoHexGridTiler(int precision) { + super(precision); + } + + /** check if the provided H3 bin is in the solution space of this tiler */ + protected abstract boolean h3IntersectsBounds(long h3); + + /** Return the relation between the H3 bin and the geoValue. If the h3 is out of the tiler solution (e.g. + * {@link #h3IntersectsBounds(long)} is false), it should return {@link GeoRelation#QUERY_DISJOINT} + */ + protected abstract GeoRelation relateTile(GeoShapeValues.GeoShapeValue geoValue, long h3) throws IOException; + + /** Return true if the provided {@link GeoShapeValues.GeoShapeValue} is fully contained in our solution space. + */ + protected abstract boolean valueInsideBounds(GeoShapeValues.GeoShapeValue geoValue) throws IOException; + + @Override + public long encode(double x, double y) { + // TODO: maybe we should remove this method from the API + throw new IllegalArgumentException("no supported"); + } + + @Override + public int setValues(GeoShapeCellValues values, GeoShapeValues.GeoShapeValue geoValue) throws IOException { + final GeoShapeValues.BoundingBox bounds = geoValue.boundingBox(); + assert bounds.minX() <= bounds.maxX(); + // first check if we are touching just fetch cells + if (bounds.maxX() - bounds.minX() < 180d) { + final long minH3 = H3.geoToH3(bounds.minY(), bounds.minX(), precision); + final long maxH3 = H3.geoToH3(bounds.maxY(), bounds.maxX(), precision); + if (minH3 == maxH3) { + return setValuesFromPointResolution(minH3, values, geoValue); + } + // TODO: specialize when they are neighbour cells. + } + // recurse tree + return setValuesByRecursion(values, geoValue, bounds); + } + + /** + * It calls {@link #maybeAdd(long, GeoRelation, GeoShapeCellValues, int)} for {@code h3} and the neighbour cells if necessary. + */ + private int setValuesFromPointResolution(long h3, GeoShapeCellValues values, GeoShapeValues.GeoShapeValue geoValue) throws IOException { + int valueIndex = 0; + { + final GeoRelation relation = relateTile(geoValue, h3); + valueIndex = maybeAdd(h3, relation, values, valueIndex); + if (relation == GeoRelation.QUERY_CONTAINS) { + return valueIndex; + } + } + // Point resolution is done using H3 library which uses spherical geometry. It might happen that in cartesian, the + // actual point value is in a neighbour cell as well. + { + for (long n : H3.hexRing(h3)) { + final GeoRelation relation = relateTile(geoValue, n); + valueIndex = maybeAdd(n, relation, values, valueIndex); + if (relation == GeoRelation.QUERY_CONTAINS) { + return valueIndex; + } + } + } + return valueIndex; + } + + /** + * Adds {@code h3} to {@link GeoShapeCellValues} if {@link #relateTile(GeoShapeValues.GeoShapeValue, long)} returns + * a relation different to {@link GeoRelation#QUERY_DISJOINT}. + */ + private int maybeAdd(long h3, GeoRelation relation, GeoShapeCellValues values, int valueIndex) { + if (relation != GeoRelation.QUERY_DISJOINT) { + values.resizeCell(valueIndex + 1); + values.add(valueIndex++, h3); + } + return valueIndex; + } + + /** + * Recursively search the H3 tree, only following branches that intersect the geometry. + * Once at the required depth, then all cells that intersect are added to the collection. + */ + // package private for testing + int setValuesByRecursion(GeoShapeCellValues values, GeoShapeValues.GeoShapeValue geoValue, GeoShapeValues.BoundingBox bounds) + throws IOException { + // NOTE: When we recurse, we cannot shortcut for CONTAINS relationship because it might fail when visiting noChilds. + int valueIndex = 0; + if (bounds.maxX() - bounds.minX() < 180d) { + final long minH3 = H3.geoToH3(bounds.minY(), bounds.minX(), 0); + final long maxH3 = H3.geoToH3(bounds.maxY(), bounds.maxX(), 0); + if (minH3 == maxH3) { + valueIndex = setValuesByRecursion(values, geoValue, minH3, 0, valueIndex); + for (long n : H3.hexRing(minH3)) { + valueIndex = setValuesByRecursion(values, geoValue, n, 0, valueIndex); + } + return valueIndex; + } + // TODO: specialize when they are neighbour cells. + } + for (long h3 : RES0CELLS) { + valueIndex = setValuesByRecursion(values, geoValue, h3, 0, valueIndex); + } + return valueIndex; + } + + /** + * Recursively search the H3 tree, only following branches that intersect the geometry. + * Once at the required depth, then all cells that intersect are added to the collection. + */ + private int setValuesByRecursion( + GeoShapeCellValues values, + GeoShapeValues.GeoShapeValue geoValue, + long h3, + int precision, + int valueIndex + ) throws IOException { + assert H3.getResolution(h3) == precision; + final GeoRelation relation = relateTile(geoValue, h3); + if (precision == this.precision) { + // When we're at the desired level + return maybeAdd(h3, relation, values, valueIndex); + } else { + assert precision < this.precision; + // When we're at higher tree levels, check if we want to keep iterating. + if (relation != GeoRelation.QUERY_DISJOINT) { + int i = 0; + if (relation == GeoRelation.QUERY_INSIDE) { + // H3 cells do not fully contain the children. The only one we know we fully contain + // is the center child which is always at position 0. + final long centerChild = H3.childPosToH3(h3, i++); + valueIndex = setAllValuesByRecursion(values, centerChild, precision + 1, valueIndex, valueInsideBounds(geoValue)); + } + final int numChildren = H3.h3ToChildrenSize(h3); + for (; i < numChildren; i++) { + final long child = H3.childPosToH3(h3, i); + valueIndex = setValuesByRecursion(values, geoValue, child, precision + 1, valueIndex); + } + // H3 cells do intersects with other cells that are not part of the children cells. If the parent cell of those + // cells is disjoint, they will not be visited, therefore visit them here. + final int numNoChildren = H3.h3ToNotIntersectingChildrenSize(h3); + for (int j = 0; j < numNoChildren; j++) { + final long noChild = H3.noChildIntersectingPosToH3(h3, j); + if (relateTile(geoValue, H3.h3ToParent(noChild)) == GeoRelation.QUERY_DISJOINT) { + valueIndex = setValuesByRecursion(values, geoValue, noChild, precision + 1, valueIndex); + } + } + } + } + return valueIndex; + } + + /** + * Recursively scan the H3 tree, assuming all children are fully contained in the geometry. + * Once at the required depth, then all cells that intersect are added to the collection. + */ + private int setAllValuesByRecursion(GeoShapeCellValues values, long h3, int precision, int valueIndex, boolean valueInsideBounds) { + if (valueInsideBounds || h3IntersectsBounds(h3)) { + if (precision == this.precision) { + values.resizeCell(valueIndex + 1); + values.add(valueIndex++, h3); + } else { + final int numChildren = H3.h3ToChildrenSize(h3); + for (int i = 0; i < numChildren; i++) { + valueIndex = setAllValuesByRecursion(values, H3.childPosToH3(h3, i), precision + 1, valueIndex, valueInsideBounds); + } + } + } + return valueIndex; + } +} diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/BoundedGeoHexGridTiler.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/BoundedGeoHexGridTiler.java new file mode 100644 index 0000000000000..6f81ff014ee47 --- /dev/null +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/BoundedGeoHexGridTiler.java @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid; + +import org.elasticsearch.common.geo.GeoBoundingBox; +import org.elasticsearch.common.geo.GeoPoint; +import org.elasticsearch.common.geo.GeoUtils; +import org.elasticsearch.geometry.Rectangle; +import org.elasticsearch.h3.H3; +import org.elasticsearch.xpack.spatial.common.H3CartesianUtil; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues; + +import java.io.IOException; + +/** + * Bounded geohex aggregation. It accepts H3 addresses that intersect the provided bounds. + * The additional support for testing intersection with inflated bounds is used when testing + * parent cells, since child cells can exceed the bounds of their parent. We inflate the bounds + * by half of the width and half of the height. + */ +public class BoundedGeoHexGridTiler extends AbstractGeoHexGridTiler { + private final GeoBoundingBox inflatedBbox; + private final GeoBoundingBox bbox; + private final GeoHexVisitor visitor; + private final int precision; + private static final double FACTOR = 0.06; + + public BoundedGeoHexGridTiler(int precision, GeoBoundingBox bbox) { + super(precision); + this.bbox = bbox; + this.visitor = new GeoHexVisitor(); + this.precision = precision; + inflatedBbox = inflateBbox(precision, bbox); + } + + private static GeoBoundingBox inflateBbox(int precision, GeoBoundingBox bbox) { + /* + * Here is the tricky part of this approach. We need to be able to filter cells at higher precisions + * because they are not in bounds, but we need to make sure we don't filter too much. We use h3 bins at the given + * resolution to check the height ands width at that level, and we factor it depending on the precision. + * + * The values have been tune using test GeoHexTilerTests#testLargeShapeWithBounds + */ + final double factor = FACTOR * (1 << precision); + final Rectangle minMin = H3CartesianUtil.toBoundingBox(H3.geoToH3(bbox.bottom(), bbox.left(), precision)); + final Rectangle maxMax = H3CartesianUtil.toBoundingBox(H3.geoToH3(bbox.top(), bbox.right(), precision)); + // compute height and width at the given precision + final double height = Math.max(height(minMin), height(maxMax)); + final double width = Math.max(width(minMin), width(maxMax)); + // inflate the coordinates using the factor + final double minY = Math.max(bbox.bottom() - factor * height, -90d); + final double maxY = Math.min(bbox.top() + factor * height, 90d); + final double left = GeoUtils.normalizeLon(bbox.left() - factor * width); + final double right = GeoUtils.normalizeLon(bbox.right() + factor * width); + if (2 * factor * width + width(bbox) >= 360d) { + // if the total width bigger than the world, then it covers all longitude range. + return new GeoBoundingBox(new GeoPoint(maxY, -180d), new GeoPoint(minY, 180d)); + } else { + return new GeoBoundingBox(new GeoPoint(maxY, left), new GeoPoint(minY, right)); + } + } + + private static double height(Rectangle rectangle) { + return rectangle.getMaxY() - rectangle.getMinY(); + } + + private static double width(Rectangle rectangle) { + if (rectangle.getMinX() > rectangle.getMaxX()) { + return 360d + rectangle.getMaxX() - rectangle.getMinX(); + } else { + return rectangle.getMaxX() - rectangle.getMinX(); + } + } + + private static double width(GeoBoundingBox bbox) { + if (bbox.left() > bbox.right()) { + return 360d + bbox.right() - bbox.left(); + } else { + return bbox.right() - bbox.left(); + } + } + + @Override + protected long getMaxCells() { + // TODO: Calculate correctly based on bounds + return UnboundedGeoHexGridTiler.calcMaxAddresses(precision); + } + + @Override + protected boolean h3IntersectsBounds(long h3) { + visitor.reset(h3); + final int resolution = H3.getResolution(h3); + if (resolution != precision) { + return cellIntersectsBounds(visitor, inflatedBbox); + } + return cellIntersectsBounds(visitor, bbox); + } + + @Override + protected GeoRelation relateTile(GeoShapeValues.GeoShapeValue geoValue, long h3) throws IOException { + visitor.reset(h3); + final int resolution = H3.getResolution(h3); + if (resolution != precision) { + if (cellIntersectsBounds(visitor, inflatedBbox)) { + // close to the poles, the properties of the H3 grid are lost because of the equirectangular projection, + // therefore we cannot ensure that the relationship at this level make any sense in the next level. + // Therefore, we just return CROSSES which just mean keep recursing. + if (visitor.getMaxY() > H3CartesianUtil.getNorthPolarBound(resolution) + || visitor.getMinY() < H3CartesianUtil.getSouthPolarBound(resolution)) { + return GeoRelation.QUERY_CROSSES; + } + geoValue.visit(visitor); + return visitor.relation(); + } else { + return GeoRelation.QUERY_DISJOINT; + } + } + if (cellIntersectsBounds(visitor, bbox)) { + geoValue.visit(visitor); + return visitor.relation(); + } + return GeoRelation.QUERY_DISJOINT; + } + + @Override + protected boolean valueInsideBounds(GeoShapeValues.GeoShapeValue geoValue) { + if (bbox.bottom() <= geoValue.boundingBox().minY() && bbox.top() >= geoValue.boundingBox().maxY()) { + if (bbox.right() < bbox.left()) { + return bbox.left() <= geoValue.boundingBox().minX() || bbox.right() >= geoValue.boundingBox().maxX(); + } else { + return bbox.left() <= geoValue.boundingBox().minX() && bbox.right() >= geoValue.boundingBox().maxX(); + } + } + return false; + } + + private static boolean cellIntersectsBounds(GeoHexVisitor visitor, GeoBoundingBox bbox) { + return visitor.intersectsBbox(bbox.left(), bbox.right(), bbox.bottom(), bbox.top()); + } +} diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexVisitor.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexVisitor.java new file mode 100644 index 0000000000000..0f8d1f65ed6d9 --- /dev/null +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexVisitor.java @@ -0,0 +1,353 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid; + +import org.apache.lucene.geo.Component2D; +import org.apache.lucene.geo.GeoUtils; +import org.apache.lucene.util.ArrayUtil; +import org.elasticsearch.xpack.spatial.common.H3CartesianUtil; +import org.elasticsearch.xpack.spatial.index.fielddata.CoordinateEncoder; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; +import org.elasticsearch.xpack.spatial.index.fielddata.TriangleTreeVisitor; + +/** + * A reusable tree reader visitor for a previous serialized {@link org.elasticsearch.geometry.Geometry}. + * + * This class supports checking H3 cells relations against a serialized triangle tree. It has the special property that if + * a geometry touches any of the edges, then it will never return {@link GeoRelation#QUERY_CONTAINS}, for example a point on the boundary + * return {@link GeoRelation#QUERY_CROSSES}. + */ +class GeoHexVisitor extends TriangleTreeVisitor.TriangleTreeDecodedVisitor { + + private GeoRelation relation; + private final double[] xs, ys; + private double minX, maxX, minY, maxY; + private boolean crossesDateline; + private int numPoints; + + GeoHexVisitor() { + super(CoordinateEncoder.GEO); + xs = new double[H3CartesianUtil.MAX_ARRAY_SIZE]; + ys = new double[H3CartesianUtil.MAX_ARRAY_SIZE]; + } + + public double[] getXs() { + return ArrayUtil.copyOfSubArray(xs, 0, numPoints); + } + + public double[] getYs() { + return ArrayUtil.copyOfSubArray(ys, 0, numPoints); + } + + public double getLeftX() { + return crossesDateline ? maxX : minX; + } + + public double getRightX() { + return crossesDateline ? minX : maxX; + } + + public double getMinY() { + return minY; + } + + public double getMaxY() { + return maxY; + } + + /** + * reset this visitor to the provided h3 cell + */ + public void reset(long h3) { + numPoints = H3CartesianUtil.computePoints(h3, xs, ys); + double minX = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < numPoints; i++) { + minX = Math.min(minX, xs[i]); + maxX = Math.max(maxX, xs[i]); + minY = Math.min(minY, ys[i]); + maxY = Math.max(maxY, ys[i]); + } + this.minX = minX; + this.maxX = maxX; + this.minY = minY; + this.maxY = maxY; + this.crossesDateline = maxX - minX > 180d && H3CartesianUtil.isPolar(h3) == false; + } + + /** + * return the computed relation. + */ + public GeoRelation relation() { + return relation; + } + + @Override + public void visitDecodedPoint(double x, double y) { + updateRelation(relatePoint(x, y)); + } + + @Override + protected void visitDecodedLine(double aX, double aY, double bX, double bY, byte metadata) { + updateRelation(relateLine(aX, aY, bX, bY)); + } + + @Override + protected void visitDecodedTriangle(double aX, double aY, double bX, double bY, double cX, double cY, byte metadata) { + final boolean ab = (metadata & 1 << 4) == 1 << 4; + final boolean bc = (metadata & 1 << 5) == 1 << 5; + final boolean ca = (metadata & 1 << 6) == 1 << 6; + updateRelation(relateTriangle(aX, aY, ab, bX, bY, bc, cX, cY, ca)); + } + + private void updateRelation(GeoRelation relation) { + if (relation != GeoRelation.QUERY_DISJOINT) { + if (relation == GeoRelation.QUERY_INSIDE && canBeInside()) { + this.relation = GeoRelation.QUERY_INSIDE; + } else if (relation == GeoRelation.QUERY_CONTAINS && canBeContained()) { + this.relation = GeoRelation.QUERY_CONTAINS; + } else { + this.relation = GeoRelation.QUERY_CROSSES; + } + } else { + adjustRelationForNotIntersectingComponent(); + } + } + + private void adjustRelationForNotIntersectingComponent() { + if (relation == null) { + this.relation = GeoRelation.QUERY_DISJOINT; + } else if (relation == GeoRelation.QUERY_CONTAINS) { + this.relation = GeoRelation.QUERY_CROSSES; + } + } + + private boolean canBeContained() { + return this.relation == null || this.relation == GeoRelation.QUERY_CONTAINS; + } + + private boolean canBeInside() { + return this.relation != GeoRelation.QUERY_CONTAINS; + } + + @Override + public boolean push() { + return this.relation != GeoRelation.QUERY_CROSSES; + } + + @Override + public boolean pushDecodedX(double minX) { + if (crossesDateline || this.maxX >= minX) { + return true; + } + adjustRelationForNotIntersectingComponent(); + return false; + } + + @Override + public boolean pushDecodedY(double minY) { + if (this.maxY >= minY) { + return true; + } + adjustRelationForNotIntersectingComponent(); + return false; + } + + @Override + public boolean pushDecoded(double maxX, double maxY) { + if (this.minY <= maxY && (this.crossesDateline || minX <= maxX)) { + return true; + } + adjustRelationForNotIntersectingComponent(); + return false; + } + + @Override + @SuppressWarnings("HiddenField") + public boolean pushDecoded(double minX, double minY, double maxX, double maxY) { + if (boxesAreDisjoint(minX, maxX, minY, maxY)) { + // shapes are disjoint + this.relation = GeoRelation.QUERY_DISJOINT; + return false; + } + relation = null; + return true; + } + + /** Check if the provided bounding box intersect the H3 bin. It supports bounding boxes + * crossing the dateline. */ + public boolean intersectsBbox(double minX, double maxX, double minY, double maxY) { + if (minX > maxX) { + return relateBbox(minX, GeoUtils.MAX_LON_INCL, minY, maxY) || relateBbox(GeoUtils.MIN_LON_INCL, maxX, minY, maxY); + } else { + return relateBbox(minX, maxX, minY, maxY); + } + } + + private boolean relateBbox(double minX, double maxX, double minY, double maxY) { + if (boxesAreDisjoint(minX, maxX, minY, maxY)) { + return false; + } + if (minX <= xs[0] && maxX >= xs[0] && minY <= ys[0] && maxY >= ys[0]) { + return true; + } + return relatePoint(minX, minY) != GeoRelation.QUERY_DISJOINT + || H3CartesianUtil.crossesBox(xs, ys, numPoints, crossesDateline, minX, maxX, minY, maxY, true); + } + + /** + * Checks if the rectangle contains the provided point + **/ + private GeoRelation relatePoint(double x, double y) { + if (boxesAreDisjoint(x, x, y, y)) { + return GeoRelation.QUERY_DISJOINT; + } + return H3CartesianUtil.relatePoint(xs, ys, numPoints, crossesDateline, x, y); + } + + /** + * Compute the relationship between the provided line and this h3 bin + **/ + private GeoRelation relateLine(double aX, double aY, double bX, double bY) { + // query contains any points + GeoRelation relation1 = relatePoint(aX, aY); + GeoRelation relation2 = relatePoint(bX, bY); + if (relation1 != relation2 || relation1 == GeoRelation.QUERY_CROSSES) { + return GeoRelation.QUERY_CROSSES; + } else if (relation1 == GeoRelation.QUERY_CONTAINS) { + if (crossesDateline) { + final double minX = Math.min(aX, bX); + final double maxX = Math.max(aX, bX); + final double minY = Math.min(aY, bY); + final double maxY = Math.max(aY, bY); + if (H3CartesianUtil.crossesLine(xs, ys, numPoints, crossesDateline, minX, maxX, minY, maxY, aX, aY, bX, bY, true)) { + return GeoRelation.QUERY_CROSSES; + } + } + return GeoRelation.QUERY_CONTAINS; + } + // 2. check crossings + if (edgeIntersectsQuery(aX, aY, bX, bY, true)) { + return GeoRelation.QUERY_CROSSES; + } + return GeoRelation.QUERY_DISJOINT; + } + + /** + * Compute the relationship between the provided triangle and this h3 bin + **/ + private GeoRelation relateTriangle( + double aX, + double aY, + boolean ab, + double bX, + double bY, + boolean bc, + double cX, + double cY, + boolean ca + ) { + // compute bounding box of triangle + double tMinX = StrictMath.min(StrictMath.min(aX, bX), cX); + double tMaxX = StrictMath.max(StrictMath.max(aX, bX), cX); + double tMinY = StrictMath.min(StrictMath.min(aY, bY), cY); + double tMaxY = StrictMath.max(StrictMath.max(aY, bY), cY); + + // 1. check bounding boxes are disjoint + if (boxesAreDisjoint(tMinX, tMaxX, tMinY, tMaxY)) { + return GeoRelation.QUERY_DISJOINT; + } + + GeoRelation relation1 = relatePoint(aX, aY); + GeoRelation relation2 = relatePoint(bX, bY); + GeoRelation relation3 = relatePoint(cX, cY); + + // 2. query contains any triangle points + if (relation1 != relation2 || relation1 != relation3 || relation1 == GeoRelation.QUERY_CROSSES) { + return GeoRelation.QUERY_CROSSES; + } else if (relation1 == GeoRelation.QUERY_CONTAINS) { + if (crossesDateline + && H3CartesianUtil.crossesTriangle( + xs, + ys, + numPoints, + crossesDateline, + tMinX, + tMaxX, + tMinY, + tMaxY, + aX, + aY, + bX, + bY, + cX, + cY, + true + )) { + return GeoRelation.QUERY_CROSSES; + } + return GeoRelation.QUERY_CONTAINS; + } + + boolean within = false; + if (edgeIntersectsQuery(aX, aY, bX, bY, false)) { + if (ab) { + return GeoRelation.QUERY_CROSSES; + } + within = true; + } + + if (edgeIntersectsQuery(bX, bY, cX, cY, false)) { + if (bc) { + return GeoRelation.QUERY_CROSSES; + } + within = true; + } + + if (edgeIntersectsQuery(cX, cY, aX, aY, false)) { + if (ca) { + return GeoRelation.QUERY_CROSSES; + } + within = true; + } + + if (within || Component2D.pointInTriangle(tMinX, tMaxX, tMinY, tMaxY, xs[0], ys[0], aX, aY, bX, bY, cX, cY)) { + return GeoRelation.QUERY_INSIDE; + } + + return GeoRelation.QUERY_DISJOINT; + } + + /** + * returns true if the edge (defined by (ax, ay) (bx, by)) intersects the query + */ + private boolean edgeIntersectsQuery(double ax, double ay, double bx, double by, boolean includeBoundary) { + final double minX = Math.min(ax, bx); + final double maxX = Math.max(ax, bx); + final double minY = Math.min(ay, by); + final double maxY = Math.max(ay, by); + return boxesAreDisjoint(minX, maxX, minY, maxY) == false + && H3CartesianUtil.crossesLine(xs, ys, numPoints, crossesDateline, minX, maxX, minY, maxY, ax, ay, bx, by, includeBoundary); + } + + /** + * utility method to check if two boxes are disjoint + */ + private boolean boxesAreDisjoint(final double minX, final double maxX, final double minY, final double maxY) { + if ((maxY < this.minY || minY > this.maxY) == false) { + if (crossesDateline) { + return maxX < this.minX && minX > this.maxX; + } else { + return maxX < this.minX || minX > this.maxX; + } + } + return true; + } +} diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeHexGridAggregator.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeHexGridAggregator.java new file mode 100644 index 0000000000000..e941a0d4af687 --- /dev/null +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeHexGridAggregator.java @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid; + +import org.elasticsearch.search.aggregations.Aggregator; +import org.elasticsearch.search.aggregations.AggregatorFactories; +import org.elasticsearch.search.aggregations.CardinalityUpperBound; +import org.elasticsearch.search.aggregations.support.AggregationContext; +import org.elasticsearch.search.aggregations.support.ValuesSource; + +import java.io.IOException; +import java.util.Map; + +public class GeoShapeHexGridAggregator extends GeoHexGridAggregator { + public GeoShapeHexGridAggregator( + String name, + AggregatorFactories factories, + ValuesSource.Numeric valuesSource, + int requiredSize, + int shardSize, + AggregationContext context, + Aggregator parent, + CardinalityUpperBound cardinality, + Map metadata + ) throws IOException { + super(name, factories, valuesSource, requiredSize, shardSize, context, parent, cardinality, metadata); + } + + /** + * This is a wrapper method to expose this protected method to {@link GeoShapeCellIdSource} + * + * @param bytes the number of bytes to register or negative to deregister the bytes + * @return the cumulative size in bytes allocated by this aggregator to service this request + */ + public long addRequestBytes(long bytes) { + return addRequestCircuitBreakerBytes(bytes); + } +} diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/UnboundedGeoHexGridTiler.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/UnboundedGeoHexGridTiler.java new file mode 100644 index 0000000000000..3b805784b0c85 --- /dev/null +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/UnboundedGeoHexGridTiler.java @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid; + +import org.elasticsearch.h3.H3; +import org.elasticsearch.xpack.spatial.common.H3CartesianUtil; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues; + +import java.io.IOException; + +/** + * Unbounded geohex aggregation. It accepts any hash. + */ +public class UnboundedGeoHexGridTiler extends AbstractGeoHexGridTiler { + + private final long maxAddresses; + + private final GeoHexVisitor visitor; + + public UnboundedGeoHexGridTiler(int precision) { + super(precision); + this.visitor = new GeoHexVisitor(); + maxAddresses = calcMaxAddresses(precision); + } + + @Override + protected boolean h3IntersectsBounds(long h3) { + return true; + } + + @Override + protected GeoRelation relateTile(GeoShapeValues.GeoShapeValue geoValue, long h3) throws IOException { + visitor.reset(h3); + final int resolution = H3.getResolution(h3); + if (resolution != precision + && (visitor.getMaxY() > H3CartesianUtil.getNorthPolarBound(resolution) + || visitor.getMinY() < H3CartesianUtil.getSouthPolarBound(resolution))) { + // close to the poles, the properties of the H3 grid are lost because of the equirectangular projection, + // therefore we cannot ensure that the relationship at this level make any sense in the next level. + // Therefore, we just return CROSSES which just mean keep recursing. + return GeoRelation.QUERY_CROSSES; + } + geoValue.visit(visitor); + return visitor.relation(); + } + + @Override + protected boolean valueInsideBounds(GeoShapeValues.GeoShapeValue geoValue) { + return true; + } + + @Override + protected long getMaxCells() { + return maxAddresses; + } + + public static long calcMaxAddresses(int precision) { + // TODO: Verify this (and perhaps move the calculation into H3 and based on NUM_BASE_CELLS and others) + final int baseHexagons = 110; + final int basePentagons = 12; + return baseHexagons * (long) Math.pow(7, precision) + basePentagons * (long) Math.pow(6, precision); + } +} diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java index 6a1fe0e548553..f8696afb9e131 100644 --- a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java @@ -59,6 +59,16 @@ public void testGeoHexLicenseCheck() { }, "geohex_grid", "geo_point"); } + public void testGeoShapeHexLicenseCheck() { + checkLicenseRequired(GeoShapeValuesSourceType.instance(), GeoHexGridAggregationBuilder.REGISTRY_KEY, (agg) -> { + try { + agg.build(null, AggregatorFactories.EMPTY, null, 0, null, 0, 0, null, null, CardinalityUpperBound.NONE, null); + } catch (IOException e) { + fail("Unexpected exception: " + e.getMessage()); + } + }, "geohex_grid", "geo_shape"); + } + public void testGeoGridLicenseCheck() { for (ValuesSourceRegistry.RegistryKey registryKey : Arrays.asList( GeoHashGridAggregationBuilder.REGISTRY_KEY, diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/common/H3CartesianUtilTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/common/H3CartesianUtilTests.java new file mode 100644 index 0000000000000..44e0488334639 --- /dev/null +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/common/H3CartesianUtilTests.java @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.common; + +import org.apache.lucene.geo.Component2D; +import org.apache.lucene.geo.LatLonGeometry; +import org.apache.lucene.tests.geo.GeoTestUtil; +import org.apache.lucene.util.ArrayUtil; +import org.elasticsearch.common.geo.GeometryNormalizer; +import org.elasticsearch.common.geo.Orientation; +import org.elasticsearch.geo.GeometryTestUtils; +import org.elasticsearch.geometry.Geometry; +import org.elasticsearch.geometry.LinearRing; +import org.elasticsearch.geometry.Point; +import org.elasticsearch.geometry.Polygon; +import org.elasticsearch.geometry.utils.WellKnownText; +import org.elasticsearch.h3.H3; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues; +import org.elasticsearch.xpack.spatial.util.GeoTestUtils; +import org.hamcrest.Matchers; + +import java.io.IOException; + +public class H3CartesianUtilTests extends ESTestCase { + + public void testLevel1() throws IOException { + for (int i = 0; i < 10000; i++) { + Point point = GeometryTestUtils.randomPoint(); + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(point); + boolean inside = false; + for (long h3 : H3.getLongRes0Cells()) { + if (geoValue.relate(H3CartesianUtil.getLatLonGeometry(h3)) != GeoRelation.QUERY_DISJOINT) { + inside = true; + break; + } + } + if (inside == false) { + fail( + "failing matching point: " + WellKnownText.toWKT(new org.elasticsearch.geometry.Point(point.getLon(), point.getLat())) + ); + } + } + } + + public void testLevel2() throws IOException { + for (int i = 0; i < 10000; i++) { + Point point = GeometryTestUtils.randomPoint(); + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(point); + boolean inside = false; + for (long res0Cell : H3.getLongRes0Cells()) { + for (long h3 : H3.h3ToChildren(res0Cell)) { + if (geoValue.relate(H3CartesianUtil.getLatLonGeometry(h3)) != GeoRelation.QUERY_DISJOINT) { + inside = true; + break; + } + } + } + if (inside == false) { + fail( + "failing matching point: " + WellKnownText.toWKT(new org.elasticsearch.geometry.Point(point.getLon(), point.getLat())) + ); + } + } + } + + public void testNorthPole() throws IOException { + for (int res = 0; res <= H3.MAX_H3_RES; res++) { + final long h3 = H3.geoToH3(90, 0, res); + final LatLonGeometry latLonGeometry = H3CartesianUtil.getLatLonGeometry(h3); + final double lon = GeoTestUtil.nextLongitude(); + { + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(new Point(lon, 90)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + } + { + final double bound = H3CartesianUtil.getNorthPolarBound(res); + final double lat = randomValueOtherThanMany(l -> l > bound, GeoTestUtil::nextLatitude); + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(new Point(lon, lat)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_DISJOINT)); + } + } + } + + public void testSouthPole() throws IOException { + for (int res = 0; res <= H3.MAX_H3_RES; res++) { + final long h3 = H3.geoToH3(-90, 0, res); + final LatLonGeometry latLonGeometry = H3CartesianUtil.getLatLonGeometry(h3); + final double lon = GeoTestUtil.nextLongitude(); + { + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(new Point(lon, -90)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + } + { + final double bound = H3CartesianUtil.getSouthPolarBound(res); + final double lat = randomValueOtherThanMany(l -> l < bound, GeoTestUtil::nextLatitude); + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(new Point(lon, lat)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_DISJOINT)); + } + } + } + + public void testDateline() throws IOException { + final long h3 = H3.geoToH3(0, 180, 0); + final LatLonGeometry latLonGeometry = H3CartesianUtil.getLatLonGeometry(h3); + // points + { + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Point(0, 0)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_DISJOINT)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Point(180, 0)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Point(-180, 0)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Point(179, 0)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Point(-179, 0)); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + } + // lines + { + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue( + new org.elasticsearch.geometry.Line(new double[] { 0, 0 }, new double[] { -1, 1 }) + ); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_DISJOINT)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Line(new double[] { 180, 180 }, new double[] { -1, 1 })); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Line(new double[] { -180, -180 }, new double[] { -1, 1 })); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Line(new double[] { 179, 179 }, new double[] { -1, 1 })); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Line(new double[] { -179, -179 }, new double[] { -1, 1 })); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue(new org.elasticsearch.geometry.Line(new double[] { -179, 179 }, new double[] { -1, 1 })); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CROSSES)); + } + // polygons + { + GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue( + new org.elasticsearch.geometry.Polygon(new LinearRing(new double[] { 0, 0, 1, 0 }, new double[] { -1, 1, 1, -1 })) + ); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_DISJOINT)); + geoValue = GeoTestUtils.geoShapeValue( + new org.elasticsearch.geometry.Polygon(new LinearRing(new double[] { 180, 180, 179, 180 }, new double[] { -1, 1, 1, -1 })) + ); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue( + new org.elasticsearch.geometry.Polygon( + new LinearRing(new double[] { -180, -180, -179, -180 }, new double[] { -1, 1, 1, -1 }) + ) + ); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue( + new org.elasticsearch.geometry.Polygon(new LinearRing(new double[] { 179, 179, 179.5, 179 }, new double[] { -1, 1, 1, -1 })) + ); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue( + new org.elasticsearch.geometry.Polygon( + new LinearRing(new double[] { -179, -179, -179.5, -179 }, new double[] { -1, 1, 1, -1 }) + ) + ); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CONTAINS)); + geoValue = GeoTestUtils.geoShapeValue( + new org.elasticsearch.geometry.Polygon( + new LinearRing(new double[] { -179, 179, -178, -179 }, new double[] { -1, 1, 1, -1 }) + ) + ); + assertThat(geoValue.relate(latLonGeometry), Matchers.equalTo(GeoRelation.QUERY_CROSSES)); + } + } + + public void testRandomBasic() throws IOException { + for (int res = 0; res < H3.MAX_H3_RES; res++) { + final long h3 = H3.geoToH3(0, 0, res); + final GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(getGeometry(h3)); + final long[] children = H3.h3ToChildren(h3); + assertThat(geoValue.relate(getComponent(children[0])), Matchers.equalTo(GeoRelation.QUERY_INSIDE)); + for (int i = 1; i < children.length; i++) { + assertThat(geoValue.relate(getComponent(children[i])), Matchers.equalTo(GeoRelation.QUERY_CROSSES)); + } + for (long noChild : H3.h3ToNoChildrenIntersecting(h3)) { + assertThat(geoValue.relate(getComponent(noChild)), Matchers.equalTo(GeoRelation.QUERY_CROSSES)); + } + } + } + + public void testRandomDateline() throws IOException { + for (int res = 0; res < H3.MAX_H3_RES; res++) { + final long h3 = H3.geoToH3(0, 180, res); + final GeoShapeValues.GeoShapeValue geoValue = GeoTestUtils.geoShapeValue(getGeometry(h3)); + final long[] children = H3.h3ToChildren(h3); + final Component2D component2D = getComponent(children[0]); + // this is a current limitation because we break polygons around the dateline. + final GeoRelation expected = component2D.getMaxX() - component2D.getMinX() == 360d + ? GeoRelation.QUERY_CROSSES + : GeoRelation.QUERY_INSIDE; + assertThat(geoValue.relate(component2D), Matchers.equalTo(expected)); + for (int i = 1; i < children.length; i++) { + assertThat(geoValue.relate(getComponent(children[i])), Matchers.equalTo(GeoRelation.QUERY_CROSSES)); + } + for (long noChild : H3.h3ToNoChildrenIntersecting(h3)) { + assertThat(geoValue.relate(getComponent(noChild)), Matchers.equalTo(GeoRelation.QUERY_CROSSES)); + } + } + } + + private static Component2D getComponent(long h3) { + return LatLonGeometry.create(H3CartesianUtil.getLatLonGeometry(h3)); + } + + private static Geometry getGeometry(long h3) { + final double[] xs = new double[H3CartesianUtil.MAX_ARRAY_SIZE]; + final double[] ys = new double[H3CartesianUtil.MAX_ARRAY_SIZE]; + final int numPoints = H3CartesianUtil.computePoints(h3, xs, ys); + final Polygon polygon = new Polygon( + new LinearRing(ArrayUtil.copyOfSubArray(xs, 0, numPoints), ArrayUtil.copyOfSubArray(ys, 0, numPoints)) + ); + double minX = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + for (int i = 0; i < numPoints; i++) { + minX = Math.min(minX, xs[i]); + maxX = Math.max(maxX, xs[i]); + } + if (maxX - minX > 180d && H3CartesianUtil.isPolar(h3) == false) { + final Geometry geometry = GeometryNormalizer.apply(Orientation.CCW, polygon); + if (geometry instanceof Polygon) { + // there is a bug on the code that breaks polygons across the dateline + // when polygon is close to the pole (I think) so we need to try again + return GeometryNormalizer.apply(Orientation.CW, polygon); + } + return geometry; + } else { + return polygon; + } + } +} diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoGridTilerTestCase.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoGridTilerTestCase.java index 13174a3582b35..704e0723dcd0e 100644 --- a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoGridTilerTestCase.java +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoGridTilerTestCase.java @@ -19,6 +19,7 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.geo.GeometryTestUtils; import org.elasticsearch.geometry.Geometry; +import org.elasticsearch.geometry.Line; import org.elasticsearch.geometry.LinearRing; import org.elasticsearch.geometry.MultiLine; import org.elasticsearch.geometry.MultiPolygon; @@ -57,6 +58,11 @@ public abstract class GeoGridTilerTestCase extends ESTestCase { protected abstract Rectangle getCell(double lon, double lat, int precision); + /** Tilers that are not rectangular cannot run all tests, eg. H3 tiler */ + protected boolean isRectangularTiler() { + return true; + } + protected abstract long getCellsForDiffPrecision(int precisionDiff); protected abstract void assertSetValuesBruteAndRecursive(Geometry geometry) throws Exception; @@ -85,6 +91,12 @@ public void testMaxCellsUnBounded() { } } + public void testGeoGridSetValuesBruteAndRecursiveLine() throws Exception { + Line geometry = GeometryTestUtils.randomLine(false); + assertSetValuesBruteAndRecursive(geometry); + + } + public void testGeoGridSetValuesBruteAndRecursiveMultiline() throws Exception { MultiLine geometry = GeometryTestUtils.randomMultiLine(false); assertSetValuesBruteAndRecursive(geometry); @@ -95,8 +107,13 @@ public void testGeoGridSetValuesBruteAndRecursivePolygon() throws Exception { assertSetValuesBruteAndRecursive(geometry); } - public void testGeoGridSetValuesBruteAndRecursivePoints() throws Exception { - Geometry geometry = randomBoolean() ? GeometryTestUtils.randomPoint(false) : GeometryTestUtils.randomMultiPoint(false); + public void testGeoGridSetValuesBruteAndRecursivePoint() throws Exception { + Geometry geometry = GeometryTestUtils.randomPoint(false); + assertSetValuesBruteAndRecursive(geometry); + } + + public void testGeoGridSetValuesBruteAndRecursiveMultiPoint() throws Exception { + Geometry geometry = GeometryTestUtils.randomMultiPoint(false); assertSetValuesBruteAndRecursive(geometry); } @@ -104,15 +121,10 @@ public void testGeoGridSetValuesBruteAndRecursivePoints() throws Exception { public void testGeoGridSetValuesBoundingBoxes_BoundedGeoShapeCellValues() throws Exception { for (int i = 0; i < 10; i++) { int precision = randomIntBetween(0, 3); - Geometry geometry = GeometryNormalizer.apply(Orientation.CCW, randomValueOtherThanMany(g -> { - try { - // make sure is a valid shape - new GeoShapeIndexer(Orientation.CCW, "test").indexShape(g); - return false; - } catch (Exception e) { - return true; - } - }, () -> boxToGeo(randomBBox()))); + Geometry geometry = GeometryNormalizer.apply( + Orientation.CCW, + randomValueOtherThanMany(this::geometryIsInvalid, () -> boxToGeo(randomBBox())) + ); GeoBoundingBox geoBoundingBox = randomValueOtherThanMany(b -> b.right() == -180 && b.left() == 180, () -> randomBBox()); GeoShapeValues.GeoShapeValue value = geoShapeValue(geometry); @@ -125,11 +137,11 @@ public void testGeoGridSetValuesBoundingBoxes_BoundedGeoShapeCellValues() throws assertTrue(cellValues.advanceExact(0)); int numBuckets = cellValues.docValueCount(); int expected = expectedBuckets(value, precision, geoBoundingBox); - assertThat(numBuckets, equalTo(expected)); + assertThat("[" + i + ":" + precision + "] bucket count", numBuckets, equalTo(expected)); } } - // tests that bounding boxes that crosses the dateline and cover all longitude values are correctly wrapped + // tests that bounding boxes that cross the dateline and cover all longitude values are correctly wrapped public void testGeoGridSetValuesBoundingBoxes_coversAllLongitudeValues() throws Exception { int precision = 3; Geometry geometry = new Rectangle(-92, 180, 0.99, -89); @@ -148,27 +160,20 @@ public void testGeoGridSetValuesBoundingBoxes_coversAllLongitudeValues() throws } public void testGeoGridSetValuesBoundingBoxes_UnboundedGeoShapeCellValues() throws Exception { - GeoShapeIndexer indexer = new GeoShapeIndexer(Orientation.CCW, "test"); - for (int i = 0; i < 1000; i++) { + for (int i = 0; i < 100; i++) { int precision = randomIntBetween(0, 3); - Geometry geometry = randomValueOtherThanMany(g -> { - try { - indexer.indexShape(g); - return false; - } catch (Exception e) { - return true; - } - }, () -> boxToGeo(randomBBox())); + Geometry geometry = randomValueOtherThanMany(this::geometryIsInvalid, () -> boxToGeo(randomBBox())); GeoShapeValues.GeoShapeValue value = geoShapeValue(geometry); GeoShapeCellValues unboundedCellValues = new GeoShapeCellValues( makeGeoShapeValues(value), getUnboundedGridTiler(precision), NOOP_BREAKER ); + assertTrue(unboundedCellValues.advanceExact(0)); - int numTiles = unboundedCellValues.docValueCount(); + int numBuckets = unboundedCellValues.docValueCount(); int expected = expectedBuckets(value, precision, null); - assertThat(numTiles, equalTo(expected)); + assertThat("[" + i + ":" + precision + "] bucket count", numBuckets, equalTo(expected)); } } @@ -191,6 +196,7 @@ public void testGeoTileShapeContainsBoundDateLine() throws Exception { public void testBoundsExcludeTouchingTiles() throws Exception { final int precision = randomIntBetween(4, maxPrecision()) - 4; + assumeTrue("Test only works for rectangular tilers", isRectangularTiler()); final Rectangle rectangle = getCell(GeoTestUtil.nextLongitude(), GeoTestUtil.nextLatitude(), precision); final GeoBoundingBox box = new GeoBoundingBox( @@ -210,7 +216,7 @@ public void testBoundsExcludeTouchingTiles() throws Exception { assertTrue(values.advanceExact(0)); final int numTiles = values.docValueCount(); final int expected = (int) getCellsForDiffPrecision(i); - assertThat(numTiles, equalTo(expected)); + assertThat("For precision " + (precision + i), numTiles, equalTo(expected)); } } @@ -229,7 +235,7 @@ public void testGridCircuitBreaker() throws IOException { final long maxNumBytes; final long curNumBytes; if (byteChangeHistory.size() == 1) { - curNumBytes = maxNumBytes = byteChangeHistory.get(byteChangeHistory.size() - 1); + curNumBytes = maxNumBytes = byteChangeHistory.get(0); } else { long oldNumBytes = -byteChangeHistory.get(byteChangeHistory.size() - 1); curNumBytes = byteChangeHistory.get(byteChangeHistory.size() - 2); @@ -252,6 +258,16 @@ public void testGridCircuitBreaker() throws IOException { }); } + protected boolean geometryIsInvalid(Geometry g) { + try { + // make sure is a valid shape + new GeoShapeIndexer(Orientation.CCW, "test").indexShape(g); + return false; + } catch (Exception e) { + return true; + } + } + protected GeoShapeValues makeGeoShapeValues(GeoShapeValues.GeoShapeValue... values) { return new GeoShapeValues() { int index = 0; diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexTilerTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexTilerTests.java new file mode 100644 index 0000000000000..1ca45dd9bd2e5 --- /dev/null +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexTilerTests.java @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid; + +import org.apache.lucene.geo.GeoEncodingUtils; +import org.apache.lucene.util.ArrayUtil; +import org.elasticsearch.common.geo.GeoBoundingBox; +import org.elasticsearch.common.geo.GeoPoint; +import org.elasticsearch.geo.GeometryTestUtils; +import org.elasticsearch.geometry.Geometry; +import org.elasticsearch.geometry.Point; +import org.elasticsearch.geometry.Rectangle; +import org.elasticsearch.geometry.utils.WellKnownText; +import org.elasticsearch.h3.H3; +import org.elasticsearch.xpack.spatial.common.H3CartesianUtil; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues; + +import java.io.IOException; +import java.util.Arrays; + +import static org.elasticsearch.xpack.spatial.util.GeoTestUtils.geoShapeValue; +import static org.hamcrest.Matchers.equalTo; + +public class GeoHexTilerTests extends GeoGridTilerTestCase { + @Override + protected GeoGridTiler getUnboundedGridTiler(int precision) { + return new UnboundedGeoHexGridTiler(precision); + } + + @Override + protected GeoGridTiler getBoundedGridTiler(GeoBoundingBox bbox, int precision) { + return new BoundedGeoHexGridTiler(precision, bbox); + } + + @Override + protected int maxPrecision() { + return H3.MAX_H3_RES; + } + + @Override + protected Rectangle getCell(double lon, double lat, int precision) { + return H3CartesianUtil.toBoundingBox(H3.geoToH3(lat, lon, precision)); + } + + /** The H3 tilers does not produce rectangular tiles, and some tests assume this */ + @Override + protected boolean isRectangularTiler() { + return false; + } + + @Override + protected long getCellsForDiffPrecision(int precisionDiff) { + return UnboundedGeoHexGridTiler.calcMaxAddresses(precisionDiff); + } + + public void testLargeShape() throws Exception { + // We have a shape and a tile both covering all mercator space, so we expect all level0 H3 cells to match + Rectangle shapeRectangle = new Rectangle(-180, 180, 90, -90); + GeoShapeValues.GeoShapeValue value = geoShapeValue(shapeRectangle); + + GeoBoundingBox boundingBox = new GeoBoundingBox( + new GeoPoint(shapeRectangle.getMaxLat(), shapeRectangle.getMinLon()), + new GeoPoint(shapeRectangle.getMinLat(), shapeRectangle.getMaxLon()) + ); + + for (int precision = 0; precision < 4; precision++) { + GeoShapeCellValues values = new GeoShapeCellValues( + makeGeoShapeValues(value), + getBoundedGridTiler(boundingBox, precision), + NOOP_BREAKER + ); + assertTrue(values.advanceExact(0)); + int numTiles = values.docValueCount(); + int expectedTiles = expectedBuckets(value, precision, boundingBox); + assertThat(expectedTiles, equalTo(numTiles)); + } + } + + public void testLargeShapeWithBounds() throws Exception { + // We have a shape covering all space + Rectangle shapeRectangle = new Rectangle(-180, 180, 90, -90); + GeoShapeValues.GeoShapeValue value = geoShapeValue(shapeRectangle); + + Point point = GeometryTestUtils.randomPoint(); + int res = randomIntBetween(0, H3.MAX_H3_RES - 4); + long h3 = H3.geoToH3(point.getLat(), point.getLon(), res); + Rectangle tile = H3CartesianUtil.toBoundingBox(h3); + GeoBoundingBox boundingBox = new GeoBoundingBox( + new GeoPoint( + GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(tile.getMaxLat())), + GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.encodeLongitude(tile.getMinLon())) + ), + new GeoPoint( + GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(tile.getMinLat())), + GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.encodeLongitude(tile.getMaxLon())) + ) + ); + + for (int precision = res; precision < res + 4; precision++) { + String msg = "Failed " + WellKnownText.toWKT(point) + " at resolution " + res + " with precision " + precision; + GeoShapeCellValues values = new GeoShapeCellValues( + makeGeoShapeValues(value), + getBoundedGridTiler(boundingBox, precision), + NOOP_BREAKER + ); + assertTrue(values.advanceExact(0)); + long[] h3bins = ArrayUtil.copyOfSubArray(values.getValues(), 0, values.docValueCount()); + assertCorner(h3bins, new Point(tile.getMinLon(), tile.getMinLat()), precision, msg); + assertCorner(h3bins, new Point(tile.getMaxLon(), tile.getMinLat()), precision, msg); + assertCorner(h3bins, new Point(tile.getMinLon(), tile.getMaxLat()), precision, msg); + assertCorner(h3bins, new Point(tile.getMaxLon(), tile.getMaxLat()), precision, msg); + } + } + + private void assertCorner(long[] h3bins, Point point, int precision, String msg) throws IOException { + GeoShapeValues.GeoShapeValue cornerValue = geoShapeValue(point); + GeoShapeCellValues cornerValues = new GeoShapeCellValues( + makeGeoShapeValues(cornerValue), + getUnboundedGridTiler(precision), + NOOP_BREAKER + ); + assertTrue(cornerValues.advanceExact(0)); + long[] h3binsCorner = ArrayUtil.copyOfSubArray(cornerValues.getValues(), 0, cornerValues.docValueCount()); + for (long corner : h3binsCorner) { + assertTrue(msg, Arrays.binarySearch(h3bins, corner) != -1); + } + } + + @Override + protected void assertSetValuesBruteAndRecursive(Geometry geometry) throws Exception { + int precision = randomIntBetween(1, 4); + UnboundedGeoHexGridTiler tiler = new UnboundedGeoHexGridTiler(precision); + GeoShapeValues.GeoShapeValue value = geoShapeValue(geometry); + + GeoShapeCellValues recursiveValues = new GeoShapeCellValues(null, tiler, NOOP_BREAKER); + int recursiveCount = tiler.setValuesByRecursion(recursiveValues, value, value.boundingBox()); + + GeoShapeCellValues bruteForceValues = new GeoShapeCellValues(null, tiler, NOOP_BREAKER); + int bruteForceCount = 0; + for (long h3 : H3.getLongRes0Cells()) { + bruteForceCount = addBruteForce(tiler, bruteForceValues, value, h3, precision, bruteForceCount); + } + + long[] recursive = Arrays.copyOf(recursiveValues.getValues(), recursiveCount); + long[] bruteForce = Arrays.copyOf(bruteForceValues.getValues(), bruteForceCount); + + Arrays.sort(recursive); + Arrays.sort(bruteForce); + assertArrayEquals(geometry.toString(), recursive, bruteForce); + } + + private int addBruteForce( + AbstractGeoHexGridTiler tiler, + GeoShapeCellValues values, + GeoShapeValues.GeoShapeValue geoValue, + long h3, + int precision, + int valueIndex + ) throws IOException { + if (H3.getResolution(h3) == precision) { + if (tiler.relateTile(geoValue, h3) != GeoRelation.QUERY_DISJOINT) { + values.resizeCell(valueIndex + 1); + values.add(valueIndex++, h3); + } + } else { + for (long child : H3.h3ToChildren(h3)) { + valueIndex = addBruteForce(tiler, values, geoValue, child, precision, valueIndex); + } + } + return valueIndex; + } + + @Override + protected int expectedBuckets(GeoShapeValues.GeoShapeValue geoValue, int precision, GeoBoundingBox bbox) throws Exception { + return computeBuckets(H3.getLongRes0Cells(), bbox, geoValue, precision); + } + + private int computeBuckets(long[] children, GeoBoundingBox bbox, GeoShapeValues.GeoShapeValue geoValue, int finalPrecision) + throws IOException { + int count = 0; + for (long child : children) { + if (H3.getResolution(child) == finalPrecision) { + if (intersects(child, geoValue, bbox, finalPrecision)) { + count++; + } + } else { + count += computeBuckets(H3.h3ToChildren(child), bbox, geoValue, finalPrecision); + } + } + return count; + } + + private boolean intersects(long h3, GeoShapeValues.GeoShapeValue geoValue, GeoBoundingBox bbox, int finalPrecision) throws IOException { + if (addressIntersectsBounds(h3, bbox, finalPrecision) == false) { + return false; + } + UnboundedGeoHexGridTiler predicate = new UnboundedGeoHexGridTiler(finalPrecision); + return predicate.relateTile(geoValue, h3) != GeoRelation.QUERY_DISJOINT; + } + + private boolean addressIntersectsBounds(long h3, GeoBoundingBox bbox, int finalPrecision) { + if (bbox == null) { + return true; + } + BoundedGeoHexGridTiler predicate = new BoundedGeoHexGridTiler(finalPrecision, bbox); + return predicate.h3IntersectsBounds(h3); + } +} diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexVisitorTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexVisitorTests.java new file mode 100644 index 0000000000000..3fdfd3b82e2bd --- /dev/null +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexVisitorTests.java @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid; + +import org.apache.lucene.tests.geo.GeoTestUtil; +import org.elasticsearch.common.geo.GeometryNormalizer; +import org.elasticsearch.common.geo.Orientation; +import org.elasticsearch.geometry.Geometry; +import org.elasticsearch.geometry.GeometryCollection; +import org.elasticsearch.geometry.Line; +import org.elasticsearch.geometry.LinearRing; +import org.elasticsearch.geometry.MultiPoint; +import org.elasticsearch.geometry.Point; +import org.elasticsearch.geometry.Polygon; +import org.elasticsearch.geometry.Rectangle; +import org.elasticsearch.h3.H3; +import org.elasticsearch.h3.LatLng; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.spatial.index.fielddata.CoordinateEncoder; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; +import org.elasticsearch.xpack.spatial.index.fielddata.GeometryDocValueReader; +import org.elasticsearch.xpack.spatial.util.GeoTestUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.LongFunction; + +public class GeoHexVisitorTests extends ESTestCase { + + public void testPoint() throws IOException { + doTestGeometry(GeoHexVisitorTests::getGeometryAsPoints, false); + } + + public void testLine() throws IOException { + doTestGeometry(GeoHexVisitorTests::getGeometryAsLine, false); + } + + public void testTriangle() throws IOException { + doTestGeometry(GeoHexVisitorTests::getGeometryAsPolygon, true); + } + + private void doTestGeometry(LongFunction h3ToGeometry, boolean hasArea) throws IOException { + // we ignore polar cells are they are problematic and do not keep the relationships + long h3 = randomValueOtherThanMany( + l -> l == H3.geoToH3(90, 0, H3.getResolution(l)) || l == H3.geoToH3(-90, 0, H3.getResolution(l)), + () -> H3.geoToH3(GeoTestUtil.nextLatitude(), GeoTestUtil.nextLongitude(), randomIntBetween(2, 14)) + ); + long centerChild = H3.childPosToH3(h3, 0); + // children position 3 is chosen so we never use a polar polygon + long noChildIntersecting = H3.noChildIntersectingPosToH3(h3, 3); + GeoHexVisitor visitor = new GeoHexVisitor(); + visitor.reset(h3); + final String failMsg = "failing h3: " + h3; + boolean h3CrossesDateline = visitor.getLeftX() > visitor.getRightX(); + { + GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(h3ToGeometry.apply(h3), CoordinateEncoder.GEO); + visitor.reset(h3); + reader.visit(visitor); + assertEquals(failMsg, GeoRelation.QUERY_CROSSES, visitor.relation()); + + Rectangle rectangle = getGeometryAsRectangle(h3); + assertTrue(failMsg, visitor.intersectsBbox(rectangle.getMinX(), rectangle.getMaxX(), rectangle.getMinY(), rectangle.getMaxY())); + } + { + GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(h3ToGeometry.apply(centerChild), CoordinateEncoder.GEO); + visitor.reset(h3); + reader.visit(visitor); + assertEquals("failing h3: " + h3, GeoRelation.QUERY_CONTAINS, visitor.relation()); + + Rectangle rectangle = getGeometryAsRectangle(centerChild); + assertTrue(failMsg, visitor.intersectsBbox(rectangle.getMinX(), rectangle.getMaxX(), rectangle.getMinY(), rectangle.getMaxY())); + } + { + GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(h3ToGeometry.apply(h3), CoordinateEncoder.GEO); + visitor.reset(centerChild); + reader.visit(visitor); + if (hasArea) { + if (h3CrossesDateline && visitor.getLeftX() > visitor.getRightX()) { + // if both polygons crosses the dateline it cannot be inside due to the polygon splitting technique + assertEquals("failing h3: " + h3, GeoRelation.QUERY_CROSSES, visitor.relation()); + } else { + assertEquals("failing h3: " + h3, GeoRelation.QUERY_INSIDE, visitor.relation()); + } + } else { + assertEquals("failing h3: " + h3, GeoRelation.QUERY_DISJOINT, visitor.relation()); + } + } + { + GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader( + h3ToGeometry.apply(noChildIntersecting), + CoordinateEncoder.GEO + ); + visitor.reset(centerChild); + reader.visit(visitor); + assertEquals("failing h3: " + h3, GeoRelation.QUERY_DISJOINT, visitor.relation()); + + Rectangle rectangle = getGeometryAsRectangle(noChildIntersecting); + assertFalse( + failMsg, + visitor.intersectsBbox(rectangle.getMinX(), rectangle.getMaxX(), rectangle.getMinY(), rectangle.getMaxY()) + ); + } + { + GeometryCollection collection = new GeometryCollection<>( + List.of(h3ToGeometry.apply(centerChild), h3ToGeometry.apply(noChildIntersecting)) + ); + GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(collection, CoordinateEncoder.GEO); + visitor.reset(h3); + reader.visit(visitor); + assertEquals("failing h3: " + h3, GeoRelation.QUERY_CROSSES, visitor.relation()); + } + { + LatLng latLng1 = H3.h3ToLatLng(centerChild); + LatLng latLng2 = H3.h3ToLatLng(noChildIntersecting); + MultiPoint multiPoint = new MultiPoint( + List.of(new Point(latLng1.getLonDeg(), latLng1.getLatDeg()), new Point(latLng2.getLonDeg(), latLng2.getLatDeg())) + ); + GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(multiPoint, CoordinateEncoder.GEO); + visitor.reset(h3); + reader.visit(visitor); + assertEquals("failing h3: " + h3, GeoRelation.QUERY_CROSSES, visitor.relation()); + } + } + + private static Geometry getGeometryAsPolygon(long h3) { + final GeoHexVisitor visitor = new GeoHexVisitor(); + visitor.reset(h3); + final Polygon polygon = new Polygon(new LinearRing(visitor.getXs(), visitor.getYs())); + if (visitor.getLeftX() > visitor.getRightX()) { + final Geometry geometry = GeometryNormalizer.apply(Orientation.CCW, polygon); + if (geometry instanceof Polygon) { + // there is a bug on the code that breaks polygons across the dateline + // when polygon is close to the pole (I think) so we need to try again + return GeometryNormalizer.apply(Orientation.CW, polygon); + } + return geometry; + } else { + return polygon; + } + } + + private static Geometry getGeometryAsLine(long h3) { + final GeoHexVisitor visitor = new GeoHexVisitor(); + visitor.reset(h3); + if (visitor.getLeftX() > visitor.getRightX()) { + double[] translatedXs = visitor.getXs(); + for (int i = 0; i < translatedXs.length; i++) { + translatedXs[i] = translatedXs[i] < 0 ? translatedXs[i] + 360 : translatedXs[i]; + } + final Geometry geometry = GeometryNormalizer.apply(Orientation.CCW, new Line(translatedXs, visitor.getYs())); + return GeometryNormalizer.apply(Orientation.CW, geometry); + } else { + return new Line(visitor.getXs(), visitor.getYs()); + } + } + + private static Geometry getGeometryAsPoints(long h3) { + final GeoHexVisitor visitor = new GeoHexVisitor(); + visitor.reset(h3); + List points = new ArrayList<>(); + double[] xs = visitor.getXs(); + double[] ys = visitor.getYs(); + for (int i = 0; i < xs.length; i++) { + points.add(new Point(xs[i], ys[i])); + } + return new MultiPoint(points); + } + + private static Rectangle getGeometryAsRectangle(long h3) { + final GeoHexVisitor visitor = new GeoHexVisitor(); + visitor.reset(h3); + return new Rectangle(visitor.getLeftX(), visitor.getRightX(), visitor.getMaxY(), visitor.getMinY()); + } + + public void testLongGeometriesWithDateline() throws IOException { + long h3 = H3.geoToH3(0, 180, randomIntBetween(0, 4)); + GeoHexVisitor visitor = new GeoHexVisitor(); + visitor.reset(h3); + { + Line line = new Line(new double[] { -180, 180 }, new double[] { 0, 0 }); + GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(line, CoordinateEncoder.GEO); + reader.visit(visitor); + assertEquals(GeoRelation.QUERY_CROSSES, visitor.relation()); + } + { + Polygon polygon = new Polygon(new LinearRing(new double[] { -180, 180, 180, -180 }, new double[] { -1, -1, 1, -1 })); + GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(polygon, CoordinateEncoder.GEO); + reader.visit(visitor); + assertEquals(GeoRelation.QUERY_CROSSES, visitor.relation()); + } + } +} diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoGridTestCase.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoGridTestCase.java index fe8a57171a5ee..216c9ebeb414a 100644 --- a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoGridTestCase.java +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoGridTestCase.java @@ -20,7 +20,6 @@ import org.elasticsearch.geometry.Geometry; import org.elasticsearch.geometry.MultiPoint; import org.elasticsearch.geometry.Point; -import org.elasticsearch.geometry.Rectangle; import org.elasticsearch.index.mapper.MappedFieldType; import org.elasticsearch.plugins.SearchPlugin; import org.elasticsearch.search.aggregations.AggregationBuilder; @@ -54,7 +53,7 @@ import static org.hamcrest.Matchers.equalTo; public abstract class GeoShapeGeoGridTestCase extends AggregatorTestCase { - private static final String FIELD_NAME = "location"; + protected static final String FIELD_NAME = "location"; /** * Generate a random precision according to the rules of the given aggregation. @@ -77,12 +76,12 @@ public abstract class GeoShapeGeoGridTestCase e protected abstract GeoBoundingBox randomBBox(); /** - * Return the bounding tile as a {@link Rectangle} for a given point + * Return true if the point intersects the given shape value */ protected abstract boolean intersects(double lng, double lat, int precision, GeoShapeValues.GeoShapeValue value) throws IOException; /** - * Return true if the points intersects the bounds + * Return true if the point intersects the given bounding box */ protected abstract boolean intersectsBounds(double lng, double lat, int precision, GeoBoundingBox box); @@ -172,7 +171,6 @@ public void testGeoShapeBounds() throws IOException { } final long numDocsInBucket = numDocsWithin; - testCase(new MatchAllDocsQuery(), FIELD_NAME, precision, bbox, iw -> { for (BinaryShapeDocValuesField docField : docs) { iw.addDocument(Collections.singletonList(docField)); @@ -248,7 +246,7 @@ private void testCase( } @SuppressWarnings("unchecked") - private void testCase( + protected void testCase( Query query, int precision, GeoBoundingBox geoBoundingBox, diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoHexGridAggregatorTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoHexGridAggregatorTests.java new file mode 100644 index 0000000000000..316c3174d44eb --- /dev/null +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoHexGridAggregatorTests.java @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid; + +import org.apache.lucene.document.SortedSetDocValuesField; +import org.apache.lucene.geo.LatLonGeometry; +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.util.BytesRef; +import org.elasticsearch.common.geo.GeoBoundingBox; +import org.elasticsearch.geo.GeometryTestUtils; +import org.elasticsearch.geometry.Point; +import org.elasticsearch.h3.H3; +import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder; +import org.elasticsearch.xpack.spatial.common.H3CartesianUtil; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation; +import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues; +import org.elasticsearch.xpack.spatial.util.GeoTestUtils; + +import java.io.IOException; +import java.util.Collections; + +public class GeoShapeGeoHexGridAggregatorTests extends GeoShapeGeoGridTestCase { + @Override + protected int randomPrecision() { + return randomIntBetween(0, H3.MAX_H3_RES); + } + + @Override + protected String hashAsString(double lng, double lat, int precision) { + // TODO: In theory we can have more than one hash per point? + final long h3 = H3.geoToH3(lat, lng, precision); + if (LatLonGeometry.create(H3CartesianUtil.getLatLonGeometry(h3)).contains(lng, lat)) { + return H3.h3ToString(h3); + } + for (long n : H3.hexRing(h3)) { + if (LatLonGeometry.create(H3CartesianUtil.getLatLonGeometry(n)).contains(lng, lat)) { + return H3.h3ToString(n); + } + } + fail("Could not find valid h3 bin"); + return null; + } + + @Override + protected Point randomPoint() { + return GeometryTestUtils.randomPoint(); + } + + @Override + protected GeoBoundingBox randomBBox() { + return GeoTestUtils.randomBBox(); + } + + @Override + protected boolean intersects(double lng, double lat, int precision, GeoShapeValues.GeoShapeValue value) throws IOException { + return value.relate(new org.apache.lucene.geo.Point(lat, lng)) != GeoRelation.QUERY_DISJOINT; + } + + @Override + protected boolean intersectsBounds(double lng, double lat, int precision, GeoBoundingBox box) { + final BoundedGeoHexGridTiler tiler = new BoundedGeoHexGridTiler(precision, box); + return tiler.h3IntersectsBounds(H3.stringToH3(hashAsString(lng, lat, precision))); + } + + @Override + protected GeoGridAggregationBuilder createBuilder(String name) { + return new GeoHexGridAggregationBuilder(name); + } + + @Override + public void testMappedMissingGeoShape() throws IOException { + final String lineString = "LINESTRING (30 10, 10 30, 40 40)"; + final GeoGridAggregationBuilder builder = createBuilder("_name").field(FIELD_NAME).missing(lineString); + testCase( + new MatchAllDocsQuery(), + 1, + null, + iw -> { iw.addDocument(Collections.singleton(new SortedSetDocValuesField("string", new BytesRef("a")))); }, + geoGrid -> { assertEquals(8, geoGrid.getBuckets().size()); }, + builder + ); + } +} diff --git a/x-pack/plugin/vector-tile/src/main/java/org/elasticsearch/xpack/vectortile/rest/GridAggregation.java b/x-pack/plugin/vector-tile/src/main/java/org/elasticsearch/xpack/vectortile/rest/GridAggregation.java index 45a756976ea36..4d750507b30b5 100644 --- a/x-pack/plugin/vector-tile/src/main/java/org/elasticsearch/xpack/vectortile/rest/GridAggregation.java +++ b/x-pack/plugin/vector-tile/src/main/java/org/elasticsearch/xpack/vectortile/rest/GridAggregation.java @@ -19,6 +19,7 @@ import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder; import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileGridAggregationBuilder; import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils; +import org.elasticsearch.xpack.spatial.common.H3CartesianUtil; import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregationBuilder; import org.elasticsearch.xpack.vectortile.feature.FeatureFactory; @@ -118,6 +119,7 @@ public Rectangle toRectangle(String bucketKey) { 2, 3, 3, + 3, 4, 4, 5, @@ -125,16 +127,15 @@ public Rectangle toRectangle(String bucketKey) { 6, 7, 8, - 8, + 9, 9, 10, 11, 11, 12, 13, - 13, 14, - 15, + 14, 15, 15, 15, @@ -188,19 +189,7 @@ public byte[] toGrid(String bucketKey, FeatureFactory featureFactory) { @Override public Rectangle toRectangle(String bucketKey) { - final CellBoundary boundary = H3.h3ToGeoBoundary(bucketKey); - double minLat = Double.POSITIVE_INFINITY; - double minLon = Double.POSITIVE_INFINITY; - double maxLat = Double.NEGATIVE_INFINITY; - double maxLon = Double.NEGATIVE_INFINITY; - for (int i = 0; i < boundary.numPoints(); i++) { - final LatLng latLng = boundary.getLatLon(i); - minLat = Math.min(minLat, latLng.getLatDeg()); - minLon = Math.min(minLon, latLng.getLonDeg()); - maxLat = Math.max(maxLat, latLng.getLatDeg()); - maxLon = Math.max(maxLon, latLng.getLonDeg()); - } - return new Rectangle(minLon, maxLon, maxLat, minLat); + return H3CartesianUtil.toBoundingBox(H3.stringToH3(bucketKey)); } };