/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.elasticsearch.test.geo;
import com.carrotsearch.randomizedtesting.generators.RandomNumbers;
import com.vividsolutions.jts.algorithm.ConvexHull;
import com.vividsolutions.jts.geom.Coordinate;
import com.vividsolutions.jts.geom.Geometry;
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.common.geo.builders.CoordinateCollection;
import org.elasticsearch.common.geo.builders.CoordinatesBuilder;
import org.elasticsearch.common.geo.builders.GeometryCollectionBuilder;
import org.elasticsearch.common.geo.builders.LineStringBuilder;
import org.elasticsearch.common.geo.builders.MultiLineStringBuilder;
import org.elasticsearch.common.geo.builders.MultiPointBuilder;
import org.elasticsearch.common.geo.builders.PointBuilder;
import org.elasticsearch.common.geo.builders.PolygonBuilder;
import org.elasticsearch.common.geo.builders.ShapeBuilder;
import org.elasticsearch.search.geo.GeoShapeQueryTests;
import org.junit.Assert;
import org.locationtech.spatial4j.context.jts.JtsSpatialContext;
import org.locationtech.spatial4j.distance.DistanceUtils;
import org.locationtech.spatial4j.exception.InvalidShapeException;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Rectangle;
import org.locationtech.spatial4j.shape.impl.Range;
import java.util.Random;
import static org.locationtech.spatial4j.shape.SpatialRelation.CONTAINS;
/**
* Random geoshape generation utilities for randomized {@code geo_shape} type testing
* depends on jts and spatial4j
*/
public class RandomShapeGenerator extends RandomGeoGenerator {
protected static JtsSpatialContext ctx = ShapeBuilder.SPATIAL_CONTEXT;
protected static final double xDIVISIBLE = 2;
protected static boolean ST_VALIDATE = true;
public enum ShapeType {
POINT, MULTIPOINT, LINESTRING, MULTILINESTRING, POLYGON;
private static final ShapeType[] types = values();
public static ShapeType randomType(Random r) {
return types[RandomNumbers.randomIntBetween(r, 0, types.length - 1)];
}
}
public static ShapeBuilder createShape(Random r) throws InvalidShapeException {
return createShapeNear(r, null);
}
public static ShapeBuilder createShape(Random r, ShapeType st) {
return createShapeNear(r, null, st);
}
public static ShapeBuilder createShapeNear(Random r, Point nearPoint) throws InvalidShapeException {
return createShape(r, nearPoint, null, null);
}
public static ShapeBuilder createShapeNear(Random r, Point nearPoint, ShapeType st) throws InvalidShapeException {
return createShape(r, nearPoint, null, st);
}
public static ShapeBuilder createShapeWithin(Random r, Rectangle bbox) throws InvalidShapeException {
return createShape(r, null, bbox, null);
}
public static ShapeBuilder createShapeWithin(Random r, Rectangle bbox, ShapeType st) throws InvalidShapeException {
return createShape(r, null, bbox, st);
}
public static GeometryCollectionBuilder createGeometryCollection(Random r) throws InvalidShapeException {
return createGeometryCollection(r, null, null, 0);
}
public static GeometryCollectionBuilder createGeometryCollectionNear(Random r, Point nearPoint) throws InvalidShapeException {
return createGeometryCollection(r, nearPoint, null, 0);
}
public static GeometryCollectionBuilder createGeometryCollectionNear(Random r, Point nearPoint, int size) throws
InvalidShapeException {
return createGeometryCollection(r, nearPoint, null, size);
}
public static GeometryCollectionBuilder createGeometryCollectionWithin(Random r, Rectangle within) throws InvalidShapeException {
return createGeometryCollection(r, null, within, 0);
}
public static GeometryCollectionBuilder createGeometryCollectionWithin(Random r, Rectangle within, int size) throws
InvalidShapeException {
return createGeometryCollection(r, null, within, size);
}
protected static GeometryCollectionBuilder createGeometryCollection(Random r, Point nearPoint, Rectangle bounds, int numGeometries)
throws InvalidShapeException {
if (numGeometries <= 0) {
// cap geometry collection at 4 shapes (to save test time)
numGeometries = RandomNumbers.randomIntBetween(r, 2, 4);
}
if (nearPoint == null) {
nearPoint = xRandomPoint(r);
}
if (bounds == null) {
bounds = xRandomRectangle(r, nearPoint);
}
GeometryCollectionBuilder gcb = new GeometryCollectionBuilder();
for (int i=0; i<numGeometries;) {
ShapeBuilder builder = createShapeWithin(r, bounds);
// due to world wrapping, and the possibility for ambiguous polygons, the random shape generation could bail with
// a null shape. We catch that situation here, and only increment the counter when a valid shape is returned.
// Not the most efficient but its the lesser of the evil alternatives
if (builder != null) {
gcb.shape(builder);
++i;
}
}
return gcb;
}
private static ShapeBuilder createShape(Random r, Point nearPoint, Rectangle within, ShapeType st) throws InvalidShapeException {
ShapeBuilder shape;
short i=0;
do {
shape = createShape(r, nearPoint, within, st, ST_VALIDATE);
if (shape != null) {
return shape;
}
} while (++i != 100);
throw new InvalidShapeException("Unable to create a valid random shape with provided seed");
}
/**
* Creates a random shape useful for randomized testing, NOTE: exercise caution when using this to build random GeometryCollections
* as creating a large random number of random shapes can result in massive resource consumption
* see: {@link GeoShapeQueryTests#testShapeFilterWithRandomGeoCollection}
*
* The following options are included
* @param nearPoint Create a shape near a provided point
* @param within Create a shape within the provided rectangle (note: if not null this will override the provided point)
* @param st Create a random shape of the provided type
* @return the ShapeBuilder for a random shape
*/
private static ShapeBuilder createShape(Random r, Point nearPoint, Rectangle within, ShapeType st, boolean validate) throws
InvalidShapeException {
if (st == null) {
st = ShapeType.randomType(r);
}
if (within == null) {
within = xRandomRectangle(r, nearPoint);
}
// NOTE: multipolygon not yet supported. Overlapping polygons are invalid so randomization
// requires an approach to avoid overlaps. This could be approached by creating polygons
// inside non overlapping bounding rectangles
switch (st) {
case POINT:
Point p = xRandomPointIn(r, within);
PointBuilder pb = new PointBuilder().coordinate(new Coordinate(p.getX(), p.getY(), Double.NaN));
return pb;
case MULTIPOINT:
case LINESTRING:
// for random testing having a maximum number of 10 points for a line string is more than sufficient
// if this number gets out of hand, the number of self intersections for a linestring can become
// (n^2-n)/2 and computing the relation intersection matrix will become NP-Hard
int numPoints = RandomNumbers.randomIntBetween(r, 3, 10);
CoordinatesBuilder coordinatesBuilder = new CoordinatesBuilder();
for (int i=0; i<numPoints; ++i) {
p = xRandomPointIn(r, within);
coordinatesBuilder.coordinate(p.getX(), p.getY());
}
CoordinateCollection pcb = (st == ShapeType.MULTIPOINT) ? new MultiPointBuilder(coordinatesBuilder.build()) : new LineStringBuilder(coordinatesBuilder);
return pcb;
case MULTILINESTRING:
MultiLineStringBuilder mlsb = new MultiLineStringBuilder();
for (int i=0; i<RandomNumbers.randomIntBetween(r, 1, 10); ++i) {
mlsb.linestring((LineStringBuilder) createShape(r, nearPoint, within, ShapeType.LINESTRING, false));
}
return mlsb;
case POLYGON:
numPoints = RandomNumbers.randomIntBetween(r, 5, 25);
Coordinate[] coordinates = new Coordinate[numPoints];
for (int i=0; i<numPoints; ++i) {
p = (Point) createShape(r, nearPoint, within, ShapeType.POINT, false).build();
coordinates[i] = new Coordinate(p.getX(), p.getY());
}
// random point order or random linestrings can lead to invalid self-crossing polygons,
// compute the convex hull for a set of points to ensure polygon does not self cross
Geometry shell = new ConvexHull(coordinates, ctx.getGeometryFactory()).getConvexHull();
Coordinate[] shellCoords = shell.getCoordinates();
// if points are in a line the convex hull will be 2 points which will also lead to an invalid polygon
// when all else fails, use the bounding box as the polygon
if (shellCoords.length < 3) {
shellCoords = new Coordinate[4];
shellCoords[0] = new Coordinate(within.getMinX(), within.getMinY());
shellCoords[1] = new Coordinate(within.getMinX(), within.getMaxY());
shellCoords[2] = new Coordinate(within.getMaxX(), within.getMaxY());
shellCoords[3] = new Coordinate(within.getMaxX(), within.getMinY());
}
PolygonBuilder pgb = new PolygonBuilder(new CoordinatesBuilder().coordinates(shellCoords).close());
if (validate) {
// This test framework builds semi-random geometry (in the sense that points are not truly random due to spatial
// auto-correlation) As a result of the semi-random nature of the geometry, one can not predict the orientation
// intent for ambiguous polygons. Therefore, an invalid oriented dateline crossing polygon could be built.
// The validate flag will check for these possibilities and bail if an incorrect geometry is created
try {
pgb.build();
} catch (AssertionError | InvalidShapeException e) {
// jts bug may occasionally misinterpret coordinate order causing an unhelpful ('geom' assertion)
// or InvalidShapeException
return null;
}
}
return pgb;
default:
throw new ElasticsearchException("Unable to create shape of type [" + st + "]");
}
}
public static Point xRandomPoint(Random r) {
return xRandomPointIn(r, ctx.getWorldBounds());
}
protected static Point xRandomPointIn(Random rand, Rectangle r) {
double[] pt = new double[2];
randomPointIn(rand, r.getMinX(), r.getMinY(), r.getMaxX(), r.getMaxY(), pt);
Point p = ctx.makePoint(pt[0], pt[1]);
Assert.assertEquals(CONTAINS, r.relate(p));
return p;
}
private static Rectangle xRandomRectangle(Random r, Point nearP, Rectangle bounds, boolean small) {
if (nearP == null)
nearP = xRandomPointIn(r, bounds);
if (small) {
// between 3 and 6 degrees
final double latRange = 3 * r.nextDouble() + 3;
final double lonRange = 3 * r.nextDouble() + 3;
double minX = nearP.getX();
double maxX = minX + lonRange;
if (maxX > 180) {
maxX = minX;
minX -= lonRange;
}
double minY = nearP.getY();
double maxY = nearP.getY() + latRange;
if (maxY > 90) {
maxY = minY;
minY -= latRange;
}
return ctx.makeRectangle(minX, maxX, minY, maxY);
}
Range xRange = xRandomRange(r, rarely(r) ? 0 : nearP.getX(), Range.xRange(bounds, ctx));
Range yRange = xRandomRange(r, rarely(r) ? 0 : nearP.getY(), Range.yRange(bounds, ctx));
return xMakeNormRect(
xDivisible(xRange.getMin()*10e3)/10e3,
xDivisible(xRange.getMax()*10e3)/10e3,
xDivisible(yRange.getMin()*10e3)/10e3,
xDivisible(yRange.getMax()*10e3)/10e3);
}
/** creates a small random rectangle by default to keep shape test performance at bay */
public static Rectangle xRandomRectangle(Random r, Point nearP) {
return xRandomRectangle(r, nearP, ctx.getWorldBounds(), true);
}
public static Rectangle xRandomRectangle(Random r, Point nearP, boolean small) {
return xRandomRectangle(r, nearP, ctx.getWorldBounds(), small);
}
private static boolean rarely(Random r) {
return r.nextInt(100) >= 90;
}
private static Range xRandomRange(Random r, double near, Range bounds) {
double mid = near + r.nextGaussian() * bounds.getWidth() / 6;
double width = Math.abs(r.nextGaussian()) * bounds.getWidth() / 6;//1/3rd
return new Range(mid - width / 2, mid + width / 2);
}
private static double xDivisible(double v, double divisible) {
return (int) (Math.round(v / divisible) * divisible);
}
private static double xDivisible(double v) {
return xDivisible(v, xDIVISIBLE);
}
protected static Rectangle xMakeNormRect(double minX, double maxX, double minY, double maxY) {
minX = DistanceUtils.normLonDEG(minX);
maxX = DistanceUtils.normLonDEG(maxX);
if (maxX < minX) {
double t = minX;
minX = maxX;
maxX = t;
}
double minWorldY = ctx.getWorldBounds().getMinY();
double maxWorldY = ctx.getWorldBounds().getMaxY();
if (minY < minWorldY || minY > maxWorldY) {
minY = DistanceUtils.normLatDEG(minY);
}
if (maxY < minWorldY || maxY > maxWorldY) {
maxY = DistanceUtils.normLatDEG(maxY);
}
if (maxY < minY) {
double t = minY;
minY = maxY;
maxY = t;
}
return ctx.makeRectangle(minX, maxX, minY, maxY);
}
}