diff --git a/python/cuspatial/cuspatial/core/binpreds/basic_predicates.py b/python/cuspatial/cuspatial/core/binpreds/basic_predicates.py index 399eed58c..85438fefa 100644 --- a/python/cuspatial/cuspatial/core/binpreds/basic_predicates.py +++ b/python/cuspatial/cuspatial/core/binpreds/basic_predicates.py @@ -12,7 +12,7 @@ ) -def _basic_equals(lhs, rhs): +def _basic_equals_any(lhs, rhs): """Utility method that returns True if any point in the lhs geometry is equal to a point in the rhs geometry.""" lhs = _multipoints_from_geometry(lhs) diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_contains.py b/python/cuspatial/cuspatial/core/binpreds/feature_contains.py index d576930bf..562ce03b7 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_contains.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_contains.py @@ -6,7 +6,7 @@ from cuspatial.core.binpreds.basic_predicates import ( _basic_contains_count, - _basic_equals, + _basic_equals_any, _basic_equals_count, _basic_intersects, _basic_intersects_pli, @@ -132,20 +132,23 @@ def _compute_predicate(self, lhs, rhs, preprocessor_result): class PointPointContains(BinPred): def _preprocess(self, lhs, rhs): - return _basic_equals(lhs, rhs) + return _basic_equals_any(lhs, rhs) class LineStringPointContains(BinPred): def _preprocess(self, lhs, rhs): intersects = _basic_intersects(lhs, rhs) - equals = _basic_equals(lhs, rhs) + equals = _basic_equals_any(lhs, rhs) return intersects & ~equals class LineStringLineStringContainsPredicate(BinPred): def _preprocess(self, lhs, rhs): - count = _basic_equals_count(lhs, rhs) - return count == rhs.sizes + pli = _basic_intersects_pli(lhs, rhs) + points = _points_and_lines_to_multipoints(pli[1], pli[0]) + # Every point in B must be in the intersection + equals = _basic_equals_count(rhs, points) == rhs.sizes + return equals """DispatchDict listing the classes to use for each combination of diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_covers.py b/python/cuspatial/cuspatial/core/binpreds/feature_covers.py index 8c32ce9e4..94e25c254 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_covers.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_covers.py @@ -1,56 +1,101 @@ # Copyright (c) 2023, NVIDIA CORPORATION. -from cuspatial.core.binpreds.binpred_interface import NotImplementedPredicate +from cuspatial.core.binpreds.basic_predicates import ( + _basic_contains_any, + _basic_contains_count, + _basic_equals_count, + _basic_intersects_pli, +) +from cuspatial.core.binpreds.binpred_interface import ( + BinPred, + ImpossiblePredicate, + NotImplementedPredicate, +) from cuspatial.core.binpreds.feature_equals import EqualsPredicateBase from cuspatial.core.binpreds.feature_intersects import ( LineStringPointIntersects, - PointLineStringIntersects, ) from cuspatial.utils.binpred_utils import ( LineString, MultiPoint, Point, Polygon, + _points_and_lines_to_multipoints, + _zero_series, ) class CoversPredicateBase(EqualsPredicateBase): """Implements the covers predicate across different combinations of geometry types. For example, a Point-Polygon covers predicate is - defined in terms of a Point-Point equals predicate. The initial release - implements covers predicates that depend only on the equals predicate, or - depend on no predicate, such as impossible cases like - `LineString.covers(Polygon)`. - - For this initial release, cover is supported for the following types: + defined in terms of a Point-Polygon equals predicate. Point.covers(Point) - Point.covers(Polygon) LineString.covers(Polygon) - Polygon.covers(Point) - Polygon.covers(MultiPoint) - Polygon.covers(LineString) - Polygon.covers(Polygon) """ pass +class LineStringLineStringCovers(BinPred): + def _preprocess(self, lhs, rhs): + # A linestring A covers another linestring B iff + # no point in B is outside of A. + pli = _basic_intersects_pli(lhs, rhs) + points = _points_and_lines_to_multipoints(pli[1], pli[0]) + # Every point in B must be in the intersection + equals = _basic_equals_count(rhs, points) == rhs.sizes + return equals + + +class PolygonPointCovers(BinPred): + def _preprocess(self, lhs, rhs): + return _basic_contains_any(lhs, rhs) + + +class PolygonLineStringCovers(BinPred): + def _preprocess(self, lhs, rhs): + # A polygon covers a linestring if all of the points in the linestring + # are in the interior or exterior of the polygon. This differs from + # a polygon that contains a linestring in that some point of the + # linestring must be in the interior of the polygon. + # Count the number of points from rhs in the interior of lhs + contains_count = _basic_contains_count(lhs, rhs) + # Now count the number of points from rhs in the boundary of lhs + pli = _basic_intersects_pli(lhs, rhs) + intersections = pli[1] + # There may be no intersection, so start with _zero_series + equality = _zero_series(len(rhs)) + if len(intersections) > 0: + matching_length_multipoints = _points_and_lines_to_multipoints( + intersections, pli[0] + ) + equality = _basic_equals_count(matching_length_multipoints, rhs) + covers = contains_count + equality >= rhs.sizes + return covers + + +class PolygonPolygonCovers(BinPred): + def _preprocess(self, lhs, rhs): + contains = lhs.contains(rhs) + return contains + + DispatchDict = { (Point, Point): CoversPredicateBase, (Point, MultiPoint): NotImplementedPredicate, - (Point, LineString): PointLineStringIntersects, - (Point, Polygon): CoversPredicateBase, + (Point, LineString): ImpossiblePredicate, + (Point, Polygon): ImpossiblePredicate, (MultiPoint, Point): NotImplementedPredicate, (MultiPoint, MultiPoint): NotImplementedPredicate, (MultiPoint, LineString): NotImplementedPredicate, (MultiPoint, Polygon): NotImplementedPredicate, (LineString, Point): LineStringPointIntersects, (LineString, MultiPoint): NotImplementedPredicate, - (LineString, LineString): NotImplementedPredicate, + (LineString, LineString): LineStringLineStringCovers, (LineString, Polygon): CoversPredicateBase, - (Polygon, Point): CoversPredicateBase, + (Polygon, Point): PolygonPointCovers, (Polygon, MultiPoint): CoversPredicateBase, - (Polygon, LineString): CoversPredicateBase, - (Polygon, Polygon): CoversPredicateBase, + (Polygon, LineString): PolygonLineStringCovers, + (Polygon, Polygon): PolygonPolygonCovers, } diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_crosses.py b/python/cuspatial/cuspatial/core/binpreds/feature_crosses.py index e1ea40a92..0316f3cbd 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_crosses.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_crosses.py @@ -1,16 +1,23 @@ # Copyright (c) 2023, NVIDIA CORPORATION. +from cuspatial.core.binpreds.basic_predicates import ( + _basic_equals_count, + _basic_intersects_count, + _basic_intersects_pli, +) from cuspatial.core.binpreds.binpred_interface import ( + BinPred, ImpossiblePredicate, - NotImplementedPredicate, ) from cuspatial.core.binpreds.feature_equals import EqualsPredicateBase +from cuspatial.core.binpreds.feature_intersects import IntersectsPredicateBase from cuspatial.utils.binpred_utils import ( LineString, MultiPoint, Point, Polygon, _false_series, + _points_and_lines_to_multipoints, ) @@ -30,6 +37,33 @@ class CrossesPredicateBase(EqualsPredicateBase): pass +class LineStringLineStringCrosses(IntersectsPredicateBase): + def _compute_predicate(self, lhs, rhs, preprocessor_result): + # A linestring crosses another linestring iff + # they intersect, and none of the points of the + # intersection are in the boundary of the other + pli = _basic_intersects_pli(rhs, lhs) + intersections = _points_and_lines_to_multipoints(pli[1], pli[0]) + equals = (_basic_equals_count(intersections, lhs) > 0) | ( + _basic_equals_count(intersections, rhs) > 0 + ) + intersects = _basic_intersects_count(rhs, lhs) > 0 + return intersects & ~equals + + +class LineStringPolygonCrosses(BinPred): + def _preprocess(self, lhs, rhs): + intersects = _basic_intersects_count(rhs, lhs) > 1 + touches = rhs.touches(lhs) + contains = rhs.contains(lhs) + return ~touches & intersects & ~contains + + +class PolygonLineStringCrosses(LineStringPolygonCrosses): + def _preprocess(self, lhs, rhs): + return super()._preprocess(rhs, lhs) + + class PointPointCrosses(CrossesPredicateBase): def _preprocess(self, lhs, rhs): """Points can't cross other points, so we return False.""" @@ -38,19 +72,19 @@ def _preprocess(self, lhs, rhs): DispatchDict = { (Point, Point): PointPointCrosses, - (Point, MultiPoint): NotImplementedPredicate, - (Point, LineString): NotImplementedPredicate, + (Point, MultiPoint): ImpossiblePredicate, + (Point, LineString): ImpossiblePredicate, (Point, Polygon): CrossesPredicateBase, - (MultiPoint, Point): NotImplementedPredicate, - (MultiPoint, MultiPoint): NotImplementedPredicate, - (MultiPoint, LineString): NotImplementedPredicate, - (MultiPoint, Polygon): NotImplementedPredicate, + (MultiPoint, Point): ImpossiblePredicate, + (MultiPoint, MultiPoint): ImpossiblePredicate, + (MultiPoint, LineString): ImpossiblePredicate, + (MultiPoint, Polygon): ImpossiblePredicate, (LineString, Point): ImpossiblePredicate, - (LineString, MultiPoint): NotImplementedPredicate, - (LineString, LineString): NotImplementedPredicate, - (LineString, Polygon): NotImplementedPredicate, + (LineString, MultiPoint): ImpossiblePredicate, + (LineString, LineString): LineStringLineStringCrosses, + (LineString, Polygon): LineStringPolygonCrosses, (Polygon, Point): CrossesPredicateBase, (Polygon, MultiPoint): CrossesPredicateBase, - (Polygon, LineString): CrossesPredicateBase, - (Polygon, Polygon): CrossesPredicateBase, + (Polygon, LineString): PolygonLineStringCrosses, + (Polygon, Polygon): ImpossiblePredicate, } diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_disjoint.py b/python/cuspatial/cuspatial/core/binpreds/feature_disjoint.py index 92541b95f..a0347b76a 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_disjoint.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_disjoint.py @@ -1,13 +1,14 @@ # Copyright (c) 2023, NVIDIA CORPORATION. +from cuspatial.core.binpreds.basic_predicates import ( + _basic_contains_any, + _basic_intersects, +) from cuspatial.core.binpreds.binpred_interface import ( BinPred, NotImplementedPredicate, ) -from cuspatial.core.binpreds.feature_intersects import ( - IntersectsPredicateBase, - PointLineStringIntersects, -) +from cuspatial.core.binpreds.feature_intersects import IntersectsPredicateBase from cuspatial.utils.binpred_utils import ( LineString, MultiPoint, @@ -16,7 +17,7 @@ ) -class ContainsDisjoint(BinPred): +class DisjointByWayOfContains(BinPred): def _preprocess(self, lhs, rhs): """Disjoint is the opposite of contains, so just implement contains and then negate the result. @@ -26,20 +27,22 @@ def _preprocess(self, lhs, rhs): (Point, Polygon) (Polygon, Point) """ - from cuspatial.core.binpreds.binpred_dispatch import CONTAINS_DISPATCH + return ~_basic_contains_any(lhs, rhs) - predicate = CONTAINS_DISPATCH[(lhs.column_type, rhs.column_type)]( - align=self.config.align - ) - return ~predicate(lhs, rhs) - -class PointLineStringDisjoint(PointLineStringIntersects): - def _postprocess(self, lhs, rhs, op_result): +class PointLineStringDisjoint(BinPred): + def _preprocess(self, lhs, rhs): """Disjoint is the opposite of intersects, so just implement intersects and then negate the result.""" - result = super()._postprocess(lhs, rhs, op_result) - return ~result + intersects = _basic_intersects(lhs, rhs) + return ~intersects + + +class PointPolygonDisjoint(BinPred): + def _preprocess(self, lhs, rhs): + intersects = _basic_intersects(lhs, rhs) + contains = _basic_contains_any(lhs, rhs) + return ~intersects & ~contains class LineStringPointDisjoint(PointLineStringDisjoint): @@ -56,21 +59,33 @@ def _postprocess(self, lhs, rhs, op_result): return ~result +class LineStringPolygonDisjoint(BinPred): + def _preprocess(self, lhs, rhs): + intersects = _basic_intersects(lhs, rhs) + contains = _basic_contains_any(rhs, lhs) + return ~intersects & ~contains + + +class PolygonPolygonDisjoint(BinPred): + def _preprocess(self, lhs, rhs): + return ~_basic_contains_any(lhs, rhs) & ~_basic_contains_any(rhs, lhs) + + DispatchDict = { - (Point, Point): ContainsDisjoint, + (Point, Point): DisjointByWayOfContains, (Point, MultiPoint): NotImplementedPredicate, (Point, LineString): PointLineStringDisjoint, - (Point, Polygon): ContainsDisjoint, + (Point, Polygon): PointPolygonDisjoint, (MultiPoint, Point): NotImplementedPredicate, (MultiPoint, MultiPoint): NotImplementedPredicate, (MultiPoint, LineString): NotImplementedPredicate, - (MultiPoint, Polygon): NotImplementedPredicate, + (MultiPoint, Polygon): LineStringPolygonDisjoint, (LineString, Point): LineStringPointDisjoint, (LineString, MultiPoint): NotImplementedPredicate, (LineString, LineString): LineStringLineStringDisjoint, - (LineString, Polygon): NotImplementedPredicate, - (Polygon, Point): ContainsDisjoint, + (LineString, Polygon): LineStringPolygonDisjoint, + (Polygon, Point): DisjointByWayOfContains, (Polygon, MultiPoint): NotImplementedPredicate, - (Polygon, LineString): NotImplementedPredicate, - (Polygon, Polygon): NotImplementedPredicate, + (Polygon, LineString): DisjointByWayOfContains, + (Polygon, Polygon): PolygonPolygonDisjoint, } diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_equals.py b/python/cuspatial/cuspatial/core/binpreds/feature_equals.py index dc52423d7..bf6997e0a 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_equals.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_equals.py @@ -13,6 +13,7 @@ from cuspatial.core.binpreds.binpred_interface import ( BinPred, EqualsOpResult, + ImpossiblePredicate, NotImplementedPredicate, PreprocessorResult, ) @@ -334,11 +335,19 @@ def _preprocess(self, lhs, rhs): return _false_series(len(lhs)) +class PolygonPolygonEquals(BinPred): + def _preprocess(self, lhs, rhs): + """Two polygons are equal if they contain each other.""" + lhs_contains_rhs = lhs.contains(rhs) + rhs_contains_lhs = rhs.contains(lhs) + return lhs_contains_rhs & rhs_contains_lhs + + """DispatchDict for Equals operations.""" DispatchDict = { (Point, Point): EqualsPredicateBase, (Point, MultiPoint): NotImplementedPredicate, - (Point, LineString): NotImplementedPredicate, + (Point, LineString): ImpossiblePredicate, (Point, Polygon): EqualsPredicateBase, (MultiPoint, Point): NotImplementedPredicate, (MultiPoint, MultiPoint): MultiPointMultiPointEquals, @@ -351,5 +360,5 @@ def _preprocess(self, lhs, rhs): (Polygon, Point): EqualsPredicateBase, (Polygon, MultiPoint): EqualsPredicateBase, (Polygon, LineString): EqualsPredicateBase, - (Polygon, Polygon): EqualsPredicateBase, + (Polygon, Polygon): PolygonPolygonEquals, } diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_intersects.py b/python/cuspatial/cuspatial/core/binpreds/feature_intersects.py index d8ecfdb38..c35947826 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_intersects.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_intersects.py @@ -23,7 +23,6 @@ Point, Polygon, _false_series, - _linestrings_from_geometry, ) @@ -107,12 +106,7 @@ def _preprocess(self, lhs, rhs): class LineStringPointIntersects(IntersectsPredicateBase): def _preprocess(self, lhs, rhs): - """Convert rhs to linestrings by making a linestring that has - the same start and end point.""" - ls_rhs = _linestrings_from_geometry(rhs) - return self._compute_predicate( - lhs, ls_rhs, PreprocessorResult(lhs, ls_rhs) - ) + return _basic_intersects(lhs, rhs) class PointLineStringIntersects(LineStringPointIntersects): diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_overlaps.py b/python/cuspatial/cuspatial/core/binpreds/feature_overlaps.py index b0eab48a9..d515d92fe 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_overlaps.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_overlaps.py @@ -2,9 +2,12 @@ import cudf +from cuspatial.core.binpreds.basic_predicates import ( + _basic_contains_properly_any, +) from cuspatial.core.binpreds.binpred_interface import ( + BinPred, ImpossiblePredicate, - NotImplementedPredicate, ) from cuspatial.core.binpreds.feature_contains import ContainsPredicate from cuspatial.core.binpreds.feature_equals import EqualsPredicateBase @@ -36,6 +39,17 @@ class OverlapsPredicateBase(EqualsPredicateBase): pass +class PolygonPolygonOverlaps(BinPred): + def _preprocess(self, lhs, rhs): + contains_lhs = lhs.contains(rhs) + contains_rhs = rhs.contains(lhs) + contains_properly_lhs = _basic_contains_properly_any(lhs, rhs) + contains_properly_rhs = _basic_contains_properly_any(rhs, lhs) + return ~(contains_lhs | contains_rhs) & ( + contains_properly_lhs | contains_properly_rhs + ) + + class PolygonPointOverlaps(ContainsPredicate): def _postprocess(self, lhs, rhs, op_result): if not has_same_geometry(lhs, rhs) or len(op_result.point_result) == 0: @@ -62,19 +76,19 @@ def _postprocess(self, lhs, rhs, op_result): """Dispatch table for overlaps binary predicate.""" DispatchDict = { (Point, Point): ImpossiblePredicate, - (Point, MultiPoint): NotImplementedPredicate, - (Point, LineString): NotImplementedPredicate, + (Point, MultiPoint): ImpossiblePredicate, + (Point, LineString): ImpossiblePredicate, (Point, Polygon): OverlapsPredicateBase, - (MultiPoint, Point): NotImplementedPredicate, - (MultiPoint, MultiPoint): NotImplementedPredicate, - (MultiPoint, LineString): NotImplementedPredicate, - (MultiPoint, Polygon): NotImplementedPredicate, + (MultiPoint, Point): ImpossiblePredicate, + (MultiPoint, MultiPoint): ImpossiblePredicate, + (MultiPoint, LineString): ImpossiblePredicate, + (MultiPoint, Polygon): ImpossiblePredicate, (LineString, Point): ImpossiblePredicate, - (LineString, MultiPoint): NotImplementedPredicate, + (LineString, MultiPoint): ImpossiblePredicate, (LineString, LineString): ImpossiblePredicate, (LineString, Polygon): ImpossiblePredicate, (Polygon, Point): OverlapsPredicateBase, (Polygon, MultiPoint): OverlapsPredicateBase, (Polygon, LineString): OverlapsPredicateBase, - (Polygon, Polygon): OverlapsPredicateBase, + (Polygon, Polygon): PolygonPolygonOverlaps, } diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_touches.py b/python/cuspatial/cuspatial/core/binpreds/feature_touches.py index c6935b782..c1ddc1312 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_touches.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_touches.py @@ -1,8 +1,22 @@ # Copyright (c) 2023, NVIDIA CORPORATION. +import cupy as cp + +import cudf + +from cuspatial.core.binpreds.basic_predicates import ( + _basic_contains_count, + _basic_contains_properly_any, + _basic_equals_all, + _basic_equals_any, + _basic_equals_count, + _basic_intersects, + _basic_intersects_count, + _basic_intersects_pli, +) from cuspatial.core.binpreds.binpred_interface import ( + BinPred, ImpossiblePredicate, - NotImplementedPredicate, ) from cuspatial.core.binpreds.feature_contains import ContainsPredicate from cuspatial.utils.binpred_utils import ( @@ -10,41 +24,129 @@ MultiPoint, Point, Polygon, + _false_series, + _points_and_lines_to_multipoints, ) class TouchesPredicateBase(ContainsPredicate): - """Base class for binary predicates that use the contains predicate - to implement the touches predicate. For example, a Point-Polygon - Touches predicate is defined in terms of a Point-Polygon Contains - predicate. + """ + If any point is shared between the following geometry types, they touch: Used by: - (Point, Polygon) - (Polygon, Point) + (Point, MultiPoint) + (Point, LineString) + (MultiPoint, Point) + (MultiPoint, MultiPoint) + (MultiPoint, LineString) + (MultiPoint, Polygon) + (LineString, Point) + (LineString, MultiPoint) (Polygon, MultiPoint) - (Polygon, LineString) - (Polygon, Polygon) """ - pass + def _preprocess(self, lhs, rhs): + return _basic_equals_any(lhs, rhs) + + +class PointPolygonTouches(ContainsPredicate): + def _preprocess(self, lhs, rhs): + # Reverse argument order. + equals_all = _basic_equals_all(rhs, lhs) + touches = _basic_intersects(rhs, lhs) + return ~equals_all & touches + + +class LineStringLineStringTouches(BinPred): + def _preprocess(self, lhs, rhs): + """A and B have at least one point in common, and the common points + lie in at least one boundary""" + + # First compute pli which will contain points for line crossings and + # linestrings for overlapping segments. + pli = _basic_intersects_pli(lhs, rhs) + offsets = cudf.Series(pli[0]) + pli_geometry_count = offsets[1:].reset_index(drop=True) - offsets[ + :-1 + ].reset_index(drop=True) + indices = ( + cudf.Series(cp.arange(len(pli_geometry_count))) + .repeat(pli_geometry_count) + .reset_index(drop=True) + ) + + # In order to be a touch, all of the intersecting geometries + # for a particular row must be points. + pli_types = pli[1]._column._meta.input_types + point_intersection = _false_series(len(lhs)) + only_points_in_intersection = ( + pli_types.groupby(indices).sum().sort_index() == 0 + ) + point_intersection.iloc[ + only_points_in_intersection.index + ] = only_points_in_intersection + + # Finally, we need to check if the points in the intersection + # are equal to endpoints of either linestring. + points = _points_and_lines_to_multipoints(pli[1], pli[0]) + equals_lhs = _basic_equals_count(points, lhs) > 0 + equals_rhs = _basic_equals_count(points, rhs) > 0 + touches = point_intersection & (equals_lhs | equals_rhs) + return touches + + +class LineStringPolygonTouches(BinPred): + def _preprocess(self, lhs, rhs): + pli = _basic_intersects_pli(lhs, rhs) + if len(pli[1]) == 0: + return _false_series(len(lhs)) + intersections = _points_and_lines_to_multipoints(pli[1], pli[0]) + # A touch can only occur if the point in the intersection + # is equal to a point in the linestring: it must + # terminate in the boundary of the polygon. + equals = _basic_equals_count(intersections, lhs) > 0 + intersects = _basic_intersects_count(lhs, rhs) + intersects = (intersects == 1) | (intersects == 2) + contains = rhs.contains(lhs) + contains_any = _basic_contains_properly_any(rhs, lhs) + return equals & intersects & ~contains & ~contains_any + + +class PolygonPointTouches(BinPred): + def _preprocess(self, lhs, rhs): + intersects = _basic_intersects(lhs, rhs) + return intersects + + +class PolygonLineStringTouches(LineStringPolygonTouches): + def _preprocess(self, lhs, rhs): + return super()._preprocess(rhs, lhs) + + +class PolygonPolygonTouches(BinPred): + def _preprocess(self, lhs, rhs): + contains_lhs_none = _basic_contains_count(lhs, rhs) == 0 + contains_rhs_none = _basic_contains_count(rhs, lhs) == 0 + equals = lhs.geom_equals(rhs) + intersects = _basic_intersects_count(lhs, rhs) > 0 + return ~equals & contains_lhs_none & contains_rhs_none & intersects DispatchDict = { (Point, Point): ImpossiblePredicate, - (Point, MultiPoint): NotImplementedPredicate, - (Point, LineString): NotImplementedPredicate, - (Point, Polygon): TouchesPredicateBase, - (MultiPoint, Point): NotImplementedPredicate, - (MultiPoint, MultiPoint): NotImplementedPredicate, - (MultiPoint, LineString): NotImplementedPredicate, - (MultiPoint, Polygon): NotImplementedPredicate, - (LineString, Point): NotImplementedPredicate, - (LineString, MultiPoint): NotImplementedPredicate, - (LineString, LineString): NotImplementedPredicate, - (LineString, Polygon): NotImplementedPredicate, - (Polygon, Point): TouchesPredicateBase, + (Point, MultiPoint): TouchesPredicateBase, + (Point, LineString): TouchesPredicateBase, + (Point, Polygon): PointPolygonTouches, + (MultiPoint, Point): TouchesPredicateBase, + (MultiPoint, MultiPoint): TouchesPredicateBase, + (MultiPoint, LineString): TouchesPredicateBase, + (MultiPoint, Polygon): TouchesPredicateBase, + (LineString, Point): TouchesPredicateBase, + (LineString, MultiPoint): TouchesPredicateBase, + (LineString, LineString): LineStringLineStringTouches, + (LineString, Polygon): LineStringPolygonTouches, + (Polygon, Point): PolygonPointTouches, (Polygon, MultiPoint): TouchesPredicateBase, - (Polygon, LineString): TouchesPredicateBase, - (Polygon, Polygon): TouchesPredicateBase, + (Polygon, LineString): PolygonLineStringTouches, + (Polygon, Polygon): PolygonPolygonTouches, } diff --git a/python/cuspatial/cuspatial/core/binpreds/feature_within.py b/python/cuspatial/cuspatial/core/binpreds/feature_within.py index 043f4629e..3b6ea133d 100644 --- a/python/cuspatial/cuspatial/core/binpreds/feature_within.py +++ b/python/cuspatial/cuspatial/core/binpreds/feature_within.py @@ -1,8 +1,8 @@ # Copyright (c) 2023, NVIDIA CORPORATION. from cuspatial.core.binpreds.basic_predicates import ( - _basic_equals, _basic_equals_all, + _basic_equals_any, _basic_intersects, ) from cuspatial.core.binpreds.binpred_interface import ( @@ -26,14 +26,14 @@ def _preprocess(self, lhs, rhs): class WithinIntersectsPredicate(BinPred): def _preprocess(self, lhs, rhs): intersects = _basic_intersects(rhs, lhs) - equals = _basic_equals(rhs, lhs) + equals = _basic_equals_any(rhs, lhs) return intersects & ~equals class PointLineStringWithin(BinPred): def _preprocess(self, lhs, rhs): intersects = lhs.intersects(rhs) - equals = _basic_equals(lhs, rhs) + equals = _basic_equals_any(lhs, rhs) return intersects & ~equals @@ -44,9 +44,8 @@ def _preprocess(self, lhs, rhs): class LineStringLineStringWithin(BinPred): def _preprocess(self, lhs, rhs): - intersects = _basic_intersects(rhs, lhs) - equals = _basic_equals_all(rhs, lhs) - return intersects & equals + contains = rhs.contains(lhs) + return contains class LineStringPolygonWithin(BinPred): diff --git a/python/cuspatial/cuspatial/tests/basicpreds/test_contains_basic_predicate.py b/python/cuspatial/cuspatial/tests/basicpreds/test_contains_basic_predicate.py new file mode 100644 index 000000000..c299770cc --- /dev/null +++ b/python/cuspatial/cuspatial/tests/basicpreds/test_contains_basic_predicate.py @@ -0,0 +1,73 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. + +from shapely.geometry import LineString, Point, Polygon + +import cuspatial +from cuspatial.core.binpreds.basic_predicates import ( + _basic_contains_any, + _basic_contains_count, +) + + +def test_basic_contains_any_outside(): + lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) + rhs = cuspatial.GeoSeries([Point(2, 2)]) + got = _basic_contains_any(lhs, rhs).to_pandas() + expected = [False] + assert (got == expected).all() + + +def test_basic_contains_any_inside(): + lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) + rhs = cuspatial.GeoSeries([LineString([(0.5, 0.5), (1.5, 1.5)])]) + got = _basic_contains_any(lhs, rhs).to_pandas() + expected = [True] + assert (got == expected).all() + + +def test_basic_contains_any_point(): + lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) + rhs = cuspatial.GeoSeries([Point(0, 0)]) + got = _basic_contains_any(lhs, rhs).to_pandas() + expected = [True] + assert (got == expected).all() + + +def test_basic_contains_any_edge(): + lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) + rhs = cuspatial.GeoSeries([Point(0, 0.5)]) + got = _basic_contains_any(lhs, rhs).to_pandas() + expected = [True] + assert (got == expected).all() + + +def test_basic_contains_count_outside(): + lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) + rhs = cuspatial.GeoSeries([Point(2, 2)]) + got = _basic_contains_count(lhs, rhs).to_pandas() + expected = [0] + assert (got == expected).all() + + +def test_basic_contains_count_inside(): + lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) + rhs = cuspatial.GeoSeries([LineString([(0.5, 0.5), (1.5, 1.5)])]) + got = _basic_contains_count(lhs, rhs).to_pandas() + expected = [1] + assert (got == expected).all() + + +def test_basic_contains_count_point(): + lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) + rhs = cuspatial.GeoSeries([Point(0, 0)]) + got = _basic_contains_count(lhs, rhs).to_pandas() + expected = [0] + assert (got == expected).all() + + +def test_basic_contains_count_edge(): + lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) + rhs = cuspatial.GeoSeries([Point(0, 0.5)]) + got = _basic_contains_count(lhs, rhs).to_pandas() + expected = [0] + assert (got == expected).all() diff --git a/python/cuspatial/cuspatial/tests/basicpreds/test_equals_basic_predicate.py b/python/cuspatial/cuspatial/tests/basicpreds/test_equals_basic_predicate.py new file mode 100644 index 000000000..a164c5d0f --- /dev/null +++ b/python/cuspatial/cuspatial/tests/basicpreds/test_equals_basic_predicate.py @@ -0,0 +1,48 @@ +import pandas as pd +from pandas.testing import assert_series_equal +from shapely.geometry import Point + +import cuspatial +from cuspatial.core.binpreds.basic_predicates import _basic_equals_any + + +def test_single_true(): + p1 = cuspatial.GeoSeries([Point(0, 0)]) + p2 = cuspatial.GeoSeries([Point(0, 0)]) + result = _basic_equals_any(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([True])) + + +def test_single_false(): + p1 = cuspatial.GeoSeries([Point(0, 0)]) + p2 = cuspatial.GeoSeries([Point(1, 1)]) + result = _basic_equals_any(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([False])) + + +def test_true_false(): + p1 = cuspatial.GeoSeries([Point(0, 0), Point(1, 1)]) + p2 = cuspatial.GeoSeries([Point(0, 0), Point(2, 2)]) + result = _basic_equals_any(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([True, False])) + + +def test_false_true(): + p1 = cuspatial.GeoSeries([Point(0, 0), Point(0, 0)]) + p2 = cuspatial.GeoSeries([Point(1, 1), Point(0, 0)]) + result = _basic_equals_any(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([False, True])) + + +def test_true_false_true(): + p1 = cuspatial.GeoSeries([Point(0, 0), Point(1, 1), Point(2, 2)]) + p2 = cuspatial.GeoSeries([Point(0, 0), Point(2, 2), Point(2, 2)]) + result = _basic_equals_any(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([True, False, True])) + + +def test_false_true_false(): + p1 = cuspatial.GeoSeries([Point(0, 0), Point(0, 0), Point(0, 0)]) + p2 = cuspatial.GeoSeries([Point(1, 1), Point(0, 0), Point(2, 2)]) + result = _basic_equals_any(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([False, True, False])) diff --git a/python/cuspatial/cuspatial/tests/binops/test_equals_count.py b/python/cuspatial/cuspatial/tests/basicpreds/test_equals_count.py similarity index 100% rename from python/cuspatial/cuspatial/tests/binops/test_equals_count.py rename to python/cuspatial/cuspatial/tests/basicpreds/test_equals_count.py diff --git a/python/cuspatial/cuspatial/tests/binops/test_intersections.py b/python/cuspatial/cuspatial/tests/basicpreds/test_intersections.py similarity index 100% rename from python/cuspatial/cuspatial/tests/binops/test_intersections.py rename to python/cuspatial/cuspatial/tests/basicpreds/test_intersections.py diff --git a/python/cuspatial/cuspatial/tests/basicpreds/test_intersects_basic_predicate.py b/python/cuspatial/cuspatial/tests/basicpreds/test_intersects_basic_predicate.py new file mode 100644 index 000000000..00193c5d1 --- /dev/null +++ b/python/cuspatial/cuspatial/tests/basicpreds/test_intersects_basic_predicate.py @@ -0,0 +1,67 @@ +import pandas as pd +from pandas.testing import assert_series_equal +from shapely.geometry import LineString, Point, Polygon + +import cuspatial +from cuspatial.core.binpreds.basic_predicates import _basic_intersects + + +def test_single_true(): + p1 = cuspatial.GeoSeries([Point(0, 0)]) + p2 = cuspatial.GeoSeries([Point(0, 0)]) + result = _basic_intersects(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([True])) + + +def test_single_false(): + p1 = cuspatial.GeoSeries([Point(0, 0)]) + p2 = cuspatial.GeoSeries([Point(1, 1)]) + result = _basic_intersects(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([False])) + + +def test_true_false(): + p1 = cuspatial.GeoSeries([Point(0, 0), Point(1, 1)]) + p2 = cuspatial.GeoSeries([Point(0, 0), Point(2, 2)]) + result = _basic_intersects(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([True, False])) + + +def test_false_true(): + p1 = cuspatial.GeoSeries([Point(0, 0), Point(0, 0)]) + p2 = cuspatial.GeoSeries([Point(1, 1), Point(0, 0)]) + result = _basic_intersects(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([False, True])) + + +def test_true_false_true(): + p1 = cuspatial.GeoSeries([Point(0, 0), Point(1, 1), Point(2, 2)]) + p2 = cuspatial.GeoSeries([Point(0, 0), Point(2, 2), Point(2, 2)]) + result = _basic_intersects(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([True, False, True])) + + +def test_false_true_false(): + p1 = cuspatial.GeoSeries([Point(0, 0), Point(0, 0), Point(0, 0)]) + p2 = cuspatial.GeoSeries([Point(1, 1), Point(0, 0), Point(2, 2)]) + result = _basic_intersects(p1, p2) + assert_series_equal(result.to_pandas(), pd.Series([False, True, False])) + + +def test_linestring_polygon_within(): + lhs = cuspatial.GeoSeries( + [ + LineString([(0, 0), (1, 1)]), + LineString([(0, 0), (1, 1)]), + LineString([(0, 0), (1, 1)]), + ] + ) + rhs = cuspatial.GeoSeries( + [ + Polygon([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]), + Polygon([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]), + Polygon([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]), + ] + ) + result = _basic_intersects(lhs, rhs) + assert_series_equal(result.to_pandas(), pd.Series([True, True, True])) diff --git a/python/cuspatial/cuspatial/tests/binpreds/binpred_test_dispatch.py b/python/cuspatial/cuspatial/tests/binpreds/binpred_test_dispatch.py index 03f6e3ab0..55ceeaea3 100644 --- a/python/cuspatial/cuspatial/tests/binpreds/binpred_test_dispatch.py +++ b/python/cuspatial/cuspatial/tests/binpreds/binpred_test_dispatch.py @@ -105,6 +105,17 @@ def predicate(request): LineString([(0.0, 0.0), (1.0, 0.0)]), LineString([(0.0, 0.0), (1.0, 0.0)]), ), + "linestring-linestring-covers": ( + """ + x + x + / + x + x + """, + LineString([(0.0, 0.0), (1.0, 1.0)]), + LineString([(0.25, 0.25), (0.5, 0.5)]), + ), "linestring-linestring-touches": ( """ x @@ -138,6 +149,17 @@ def predicate(request): LineString([(0.0, 0.0), (1.0, 0.0)]), LineString([(0.5, 0.0), (0.5, 1.0)]), ), + "linestring-linestring-touch-edge-twice": ( + """ + x + x + / \\ + x---x + x + """, + LineString([(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)]), + LineString([(0.25, 0.25), (1.0, 0.0), (0.5, 0.5)]), + ), "linestring-linestring-crosses": ( """ x @@ -358,15 +380,27 @@ def predicate(request): Polygon([(0.0, 1.0), (0.0, 2.0), (1.0, 2.0)]), point_polygon, ), + "polygon-polygon-overlap-inside-edge": ( + """ + x + /| + x---x | + \\ / | + x | + / | + x-----x + """, + Polygon([(0, 0), (1, 0), (1, 1), (0, 0)]), + Polygon([(0.25, 0.25), (0.5, 0.5), (0, 0.5), (0.25, 0.25)]), + ), "polygon-polygon-point-inside": ( """ x---x | / - | / - --|/- + --|-/ + | |/| | x | | | - | | ----- """, Polygon([(0.5, 0.5), (0.5, 1.5), (1.5, 1.5)]), @@ -453,7 +487,11 @@ def predicate(request): linestring_linestring_dispatch_list = [ "linestring-linestring-disjoint", "linestring-linestring-same", + "linestring-linestring-covers", "linestring-linestring-touches", + "linestring-linestring-touch-interior", + "linestring-linestring-touch-edge", + "linestring-linestring-touch-edge-twice", "linestring-linestring-crosses", ] @@ -475,6 +513,7 @@ def predicate(request): "polygon-polygon-touch-point", "polygon-polygon-touch-edge", "polygon-polygon-overlap-edge", + "polygon-polygon-overlap-inside-edge", "polygon-polygon-point-inside", "polygon-polygon-point-outside", "polygon-polygon-in-out-point", diff --git a/python/cuspatial/cuspatial/tests/binpreds/test_binpred_internals.py b/python/cuspatial/cuspatial/tests/binpreds/test_binpred_internals.py index 7d18530ac..9b87f821f 100644 --- a/python/cuspatial/cuspatial/tests/binpreds/test_binpred_internals.py +++ b/python/cuspatial/cuspatial/tests/binpreds/test_binpred_internals.py @@ -1,10 +1,15 @@ # Copyright (c) 2020-2023, NVIDIA CORPORATION import pandas as pd -from shapely.geometry import LineString +from shapely.geometry import LineString, MultiPoint, Point, Polygon import cuspatial from cuspatial.core.binpreds.binpred_dispatch import EQUALS_DISPATCH +from cuspatial.utils.binpred_utils import ( + _linestrings_to_center_point, + _open_polygon_rings, + _points_and_lines_to_multipoints, +) def test_internal_reversed_linestrings(): @@ -74,3 +79,261 @@ def test_internal_reversed_linestrings_triple(): ).to_pandas() expected = linestring2.lines.xy.to_pandas() pd.testing.assert_series_equal(got, expected) + + +def test_open_polygon_rings(): + polygon = cuspatial.GeoSeries( + [ + Polygon([(0, 0), (1, 1), (1, 0), (0, 0)]), + ] + ) + linestring = cuspatial.GeoSeries( + [ + LineString([(0, 0), (1, 1), (1, 0)]), + ] + ) + got = _open_polygon_rings(polygon) + assert (got.lines.xy == linestring.lines.xy).all() + + +def test_open_polygon_rings_two(): + polygon = cuspatial.GeoSeries( + [ + Polygon([(0, 0), (1, 1), (1, 0), (0, 0)]), + Polygon([(0, 0), (1, 1), (1, 0), (0, 0)]), + ] + ) + linestring = cuspatial.GeoSeries( + [ + LineString([(0, 0), (1, 1), (1, 0)]), + LineString([(0, 0), (1, 1), (1, 0)]), + ] + ) + got = _open_polygon_rings(polygon) + assert (got.lines.xy == linestring.lines.xy).all() + + +def test_open_polygon_rings_three_varying_length(): + polygon = cuspatial.GeoSeries( + [ + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + Polygon([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]), + Polygon([(0, 0), (1, 1), (1, 0), (0, 0)]), + ] + ) + linestring = cuspatial.GeoSeries( + [ + LineString([(0, 0), (1, 1), (0, 1)]), + LineString([(0, 0), (0, 1), (1, 1), (1, 0)]), + LineString([(0, 0), (1, 1), (1, 0)]), + ] + ) + got = _open_polygon_rings(polygon) + assert (got.lines.xy == linestring.lines.xy).all() + + +def test_points_and_lines_to_multipoints(): + mixed = cuspatial.GeoSeries( + [ + Point(0, 0), + LineString([(1, 1), (2, 2)]), + ] + ) + expected = cuspatial.GeoSeries( + [ + MultiPoint([(0, 0)]), + MultiPoint([(1, 1), (2, 2)]), + ] + ) + offsets = [0, 1, 2] + got = _points_and_lines_to_multipoints(mixed, offsets) + assert (got.multipoints.xy == expected.multipoints.xy).all() + + +def test_points_and_lines_to_multipoints_reverse(): + mixed = cuspatial.GeoSeries( + [ + LineString([(1, 1), (2, 2)]), + Point(0, 0), + ] + ) + expected = cuspatial.GeoSeries( + [ + MultiPoint([(1, 1), (2, 2)]), + MultiPoint([(0, 0)]), + ] + ) + offsets = [0, 1, 2] + got = _points_and_lines_to_multipoints(mixed, offsets) + assert (got.multipoints.xy == expected.multipoints.xy).all() + + +def test_points_and_lines_to_multipoints_two_points_one_linestring(): + mixed = cuspatial.GeoSeries( + [ + Point(0, 0), + LineString([(1, 1), (2, 2)]), + Point(3, 3), + ] + ) + expected = cuspatial.GeoSeries( + [ + MultiPoint([(0, 0)]), + MultiPoint([(1, 1), (2, 2)]), + MultiPoint([(3, 3)]), + ] + ) + offsets = [0, 1, 2, 3] + got = _points_and_lines_to_multipoints(mixed, offsets) + assert (got.multipoints.xy == expected.multipoints.xy).all() + + +def test_points_and_lines_to_multipoints_two_linestrings_one_point(): + mixed = cuspatial.GeoSeries( + [ + LineString([(0, 0), (1, 1)]), + Point(2, 2), + LineString([(3, 3), (4, 4)]), + ] + ) + expected = cuspatial.GeoSeries( + [ + MultiPoint([(0, 0), (1, 1)]), + MultiPoint([(2, 2)]), + MultiPoint([(3, 3), (4, 4)]), + ] + ) + offsets = [0, 1, 2, 3] + got = _points_and_lines_to_multipoints(mixed, offsets) + assert (got.multipoints.xy == expected.multipoints.xy).all() + + +def test_points_and_lines_to_multipoints_complex(): + mixed = cuspatial.GeoSeries( + [ + LineString([(0, 0), (1, 1), (2, 2), (3, 3)]), + Point(4, 4), + LineString([(5, 5), (6, 6)]), + Point(7, 7), + Point(8, 8), + LineString([(9, 9), (10, 10), (11, 11)]), + LineString([(12, 12), (13, 13)]), + Point(14, 14), + ] + ) + expected = cuspatial.GeoSeries( + [ + MultiPoint([(0, 0), (1, 1), (2, 2), (3, 3)]), + MultiPoint([(4, 4)]), + MultiPoint([(5, 5), (6, 6)]), + MultiPoint([(7, 7)]), + MultiPoint([(8, 8)]), + MultiPoint([(9, 9), (10, 10), (11, 11)]), + MultiPoint([(12, 12), (13, 13)]), + MultiPoint([(14, 14)]), + ] + ) + offsets = [0, 1, 2, 3, 4, 5, 6, 7, 8] + got = _points_and_lines_to_multipoints(mixed, offsets) + assert (got.multipoints.xy == expected.multipoints.xy).all() + + +def test_points_and_lines_to_multipoints_no_points(): + mixed = cuspatial.GeoSeries( + [ + LineString([(0, 0), (1, 1), (2, 2), (3, 3)]), + LineString([(5, 5), (6, 6)]), + LineString([(9, 9), (10, 10), (11, 11)]), + LineString([(12, 12), (13, 13)]), + ] + ) + expected = cuspatial.GeoSeries( + [ + MultiPoint([(0, 0), (1, 1), (2, 2), (3, 3)]), + MultiPoint([(5, 5), (6, 6)]), + MultiPoint([(9, 9), (10, 10), (11, 11)]), + MultiPoint([(12, 12), (13, 13)]), + ] + ) + offsets = [0, 1, 2, 3, 4] + got = _points_and_lines_to_multipoints(mixed, offsets) + assert (got.multipoints.xy == expected.multipoints.xy).all() + + +def test_points_and_lines_to_multipoints_no_linestrings(): + mixed = cuspatial.GeoSeries( + [ + Point(0, 0), + Point(4, 4), + Point(7, 7), + Point(8, 8), + Point(14, 14), + ] + ) + expected = cuspatial.GeoSeries( + [ + MultiPoint([(0, 0)]), + MultiPoint([(4, 4)]), + MultiPoint([(7, 7)]), + MultiPoint([(8, 8)]), + MultiPoint([(14, 14)]), + ] + ) + offsets = [0, 1, 2, 3, 4, 5] + got = _points_and_lines_to_multipoints(mixed, offsets) + assert (got.multipoints.xy == expected.multipoints.xy).all() + + +def test_points_and_lines_to_multipoints_real_example(): + mixed = cuspatial.GeoSeries( + [ + Point(7, 7), + Point(4, 4), + LineString([(5, 5), (6, 6)]), + LineString([(9, 9), (10, 10), (11, 11)]), + LineString([(12, 12), (13, 13)]), + Point(8, 8), + Point(14, 14), + ] + ) + expected = cuspatial.GeoSeries( + [ + MultiPoint([(7, 7), (4, 4)]), + MultiPoint( + [ + (5, 5), + (6, 6), + (9, 9), + (10, 10), + (11, 11), + (12, 12), + (13, 13), + ] + ), + MultiPoint([(8, 8), (14, 14)]), + ] + ) + offsets = [0, 2, 5, 7] + got = _points_and_lines_to_multipoints(mixed, offsets) + assert (got.multipoints.xy == expected.multipoints.xy).all() + + +def test_linestrings_to_center_point(): + linestrings = cuspatial.GeoSeries( + [ + LineString([(0, 0), (10, 10)]), + LineString([(5, 5), (6, 6)]), + LineString([(10, 10), (9, 9)]), + LineString([(11, 11), (1, 1)]), + ] + ) + expected = cuspatial.GeoSeries( + [ + Point(5, 5), + Point(5.5, 5.5), + Point(9.5, 9.5), + Point(6, 6), + ] + ) + got = _linestrings_to_center_point(linestrings) + assert (got.points.xy == expected.points.xy).all() diff --git a/python/cuspatial/cuspatial/tests/binpreds/test_binpred_test_dispatch.py b/python/cuspatial/cuspatial/tests/binpreds/test_binpred_test_dispatch.py index 914e2e88e..11e5ad8f1 100644 --- a/python/cuspatial/cuspatial/tests/binpreds/test_binpred_test_dispatch.py +++ b/python/cuspatial/cuspatial/tests/binpreds/test_binpred_test_dispatch.py @@ -1,31 +1,35 @@ # Copyright (c) 2023, NVIDIA CORPORATION. -from functools import wraps +import os import pandas as pd -import pytest from binpred_test_dispatch import predicate, simple_test # noqa: F401 -"""Decorator function that xfails a test if an exception is throw -by the test function. Will be removed when all tests are passing.""" - +# In the below file, all failing tests are recorded with visualizations. +LOG_DISPATCHED_PREDICATES = os.environ.get("LOG_DISPATCHED_PREDICATES", False) +if LOG_DISPATCHED_PREDICATES: + out_file = open("test_binpred_test_dispatch.log", "w") -def xfail_on_exception(func): - @wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except Exception as e: - pytest.xfail(f"Xfailing due to an exception: {e}") - return wrapper +def execute_test(pred, lhs, rhs): + gpdlhs = lhs.to_geopandas() + gpdrhs = rhs.to_geopandas() + # Reverse + pred_fn = getattr(rhs, pred) + got = pred_fn(lhs) + gpd_pred_fn = getattr(gpdrhs, pred) + expected = gpd_pred_fn(gpdlhs) + assert (got.values_host == expected.values).all() -# In the below file, all failing tests are recorded with visualizations. -out_file = open("test_binpred_test_dispatch.log", "w") + # Forward + pred_fn = getattr(lhs, pred) + got = pred_fn(rhs) + gpd_pred_fn = getattr(gpdlhs, pred) + expected = gpd_pred_fn(gpdrhs) + assert (got.values_host == expected.values).all() -@xfail_on_exception # TODO: Remove when all tests are passing def test_simple_features( predicate, # noqa: F811 simple_test, # noqa: F811 @@ -38,10 +42,15 @@ def test_simple_features( """Parameterized test fixture that runs a binary predicate test for each combination of geometry types and binary predicates. + Enable the `LOG_DISPATCHED_PREDICATES` environment variable to + log the dispatched predicate results. + Uses four fixtures from `conftest.py` to store the number of times each binary predicate has passed and failed, and the number of times each combination of geometry types has passed and failed. These - results are saved to CSV files after each test. + results are saved to CSV files after each test. The result of the + tests can be summarized with + `tests/binpreds/summarize_binpred_test_dispatch_results.py`. Uses the @xfail_on_exception decorator to mark a test as xfailed if an exception is thrown. This is a temporary measure to allow @@ -71,75 +80,72 @@ def test_simple_features( The pytest request object. Used to print the test name in diagnostic output. """ - try: + if not LOG_DISPATCHED_PREDICATES: (lhs, rhs) = simple_test[2], simple_test[3] - gpdlhs = lhs.to_geopandas() - gpdrhs = rhs.to_geopandas() - pred_fn = getattr(lhs, predicate) - got = pred_fn(rhs) - gpd_pred_fn = getattr(gpdlhs, predicate) - expected = gpd_pred_fn(gpdrhs) - assert (got.values_host == expected.values).all() - - # The test is complete, the rest is just logging. + execute_test(predicate, lhs, rhs) + else: try: - # The test passed, store the results. - predicate_passes[predicate] = ( + execute_test(predicate, lhs, rhs) + + # The test is complete, the rest is just logging. + try: + # The test passed, store the results. + predicate_passes[predicate] = ( + 1 + if predicate not in predicate_passes + else predicate_passes[predicate] + 1 + ) + feature_passes[(lhs.column_type, rhs.column_type)] = ( + 1 + if (lhs.column_type, rhs.column_type) not in feature_passes + else feature_passes[(lhs.column_type, rhs.column_type)] + 1 + ) + passes_df = pd.DataFrame( + { + "predicate": list(predicate_passes.keys()), + "predicate_passes": list(predicate_passes.values()), + } + ) + passes_df.to_csv("predicate_passes.csv", index=False) + passes_df = pd.DataFrame( + { + "feature": list(feature_passes.keys()), + "feature_passes": list(feature_passes.values()), + } + ) + passes_df.to_csv("feature_passes.csv", index=False) + except Exception as e: + raise e + except Exception as e: + # The test failed, store the results. + out_file.write( + f"""{predicate}, + ------------ + {simple_test[0]}\n{simple_test[1]}\nfailed + test: {request.node.name}\n\n""" + ) + predicate_fails[predicate] = ( 1 - if predicate not in predicate_passes - else predicate_passes[predicate] + 1 + if predicate not in predicate_fails + else predicate_fails[predicate] + 1 ) - feature_passes[(lhs.column_type, rhs.column_type)] = ( + feature_fails[(lhs.column_type, rhs.column_type)] = ( 1 - if (lhs.column_type, rhs.column_type) not in feature_passes - else feature_passes[(lhs.column_type, rhs.column_type)] + 1 + if (lhs.column_type, rhs.column_type) not in feature_fails + else feature_fails[(lhs.column_type, rhs.column_type)] + 1 ) - passes_df = pd.DataFrame( + predicate_fails_df = pd.DataFrame( { - "predicate": list(predicate_passes.keys()), - "predicate_passes": list(predicate_passes.values()), + "predicate": list(predicate_fails.keys()), + "predicate_fails": list(predicate_fails.values()), } ) - passes_df.to_csv("predicate_passes.csv", index=False) - passes_df = pd.DataFrame( + predicate_fails_df.to_csv("predicate_fails.csv", index=False) + feature_fails_df = pd.DataFrame( { - "feature": list(feature_passes.keys()), - "feature_passes": list(feature_passes.values()), + "feature": list(feature_fails.keys()), + "feature_fails": list(feature_fails.values()), } ) - passes_df.to_csv("feature_passes.csv", index=False) - except Exception as e: - raise ValueError(e) - except Exception as e: - # The test failed, store the results. - out_file.write( - f"""{predicate}, ------------- -{simple_test[0]}\n{simple_test[1]}\nfailed -test: {request.node.name}\n\n""" - ) - predicate_fails[predicate] = ( - 1 - if predicate not in predicate_fails - else predicate_fails[predicate] + 1 - ) - feature_fails[(lhs.column_type, rhs.column_type)] = ( - 1 - if (lhs.column_type, rhs.column_type) not in feature_fails - else feature_fails[(lhs.column_type, rhs.column_type)] + 1 - ) - predicate_fails_df = pd.DataFrame( - { - "predicate": list(predicate_fails.keys()), - "predicate_fails": list(predicate_fails.values()), - } - ) - predicate_fails_df.to_csv("predicate_fails.csv", index=False) - feature_fails_df = pd.DataFrame( - { - "feature": list(feature_fails.keys()), - "feature_fails": list(feature_fails.values()), - } - ) - feature_fails_df.to_csv("feature_fails.csv", index=False) - raise e + feature_fails_df.to_csv("feature_fails.csv", index=False) + raise e diff --git a/python/cuspatial/cuspatial/tests/binpreds/test_contains.py b/python/cuspatial/cuspatial/tests/binpreds/test_contains.py index 274c96165..fe8197b37 100644 --- a/python/cuspatial/cuspatial/tests/binpreds/test_contains.py +++ b/python/cuspatial/cuspatial/tests/binpreds/test_contains.py @@ -8,6 +8,28 @@ import cuspatial +def test_manual_polygons(): + gpdlhs = gpd.GeoSeries([Polygon(((-8, -8), (-8, 8), (8, 8), (8, -8)))] * 6) + gpdrhs = gpd.GeoSeries( + [ + Polygon(((-8, -8), (-8, 8), (8, 8), (8, -8))), + Polygon(((-2, -2), (-2, 2), (2, 2), (2, -2))), + Polygon(((-10, -2), (-10, 2), (-6, 2), (-6, -2))), + Polygon(((-2, 8), (-2, 12), (2, 12), (2, 8))), + Polygon(((6, 0), (8, 2), (10, 0), (8, -2))), + Polygon(((-2, -8), (-2, -4), (2, -4), (2, -8))), + ] + ) + rhs = cuspatial.from_geopandas(gpdrhs) + lhs = cuspatial.from_geopandas(gpdlhs) + got = lhs.contains(rhs).values_host + expected = gpdlhs.contains(gpdrhs).values + assert (got == expected).all() + got = rhs.contains(lhs).values_host + expected = gpdrhs.contains(gpdlhs).values + assert (got == expected).all() + + def test_same(): lhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) rhs = cuspatial.GeoSeries([Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])]) diff --git a/python/cuspatial/cuspatial/tests/binpreds/test_equals_only_binpreds.py b/python/cuspatial/cuspatial/tests/binpreds/test_equals_only_binpreds.py index 331f3002d..47a07bee9 100644 --- a/python/cuspatial/cuspatial/tests/binpreds/test_equals_only_binpreds.py +++ b/python/cuspatial/cuspatial/tests/binpreds/test_equals_only_binpreds.py @@ -532,7 +532,10 @@ def test_pair_linestrings_different_last_two(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") +@pytest.mark.xfail( + reason="""The current implementation of .contains +conceals this special case. Unsure of the solution.""" +) def test_pair_polygons_different_ordering(): gpdpoly1 = gpd.GeoSeries( [ @@ -551,7 +554,6 @@ def test_pair_polygons_different_ordering(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") def test_pair_polygons_different_winding(): gpdpoly1 = gpd.GeoSeries( [ @@ -570,7 +572,6 @@ def test_pair_polygons_different_winding(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") def test_3_polygons_geom_equals_3_polygons_misordered_corrected_vertex(): gpdpoly1 = gpd.GeoSeries( [ @@ -593,7 +594,6 @@ def test_3_polygons_geom_equals_3_polygons_misordered_corrected_vertex(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") def test_polygon_geom_equals_polygon(): gpdpolygon1 = gpd.GeoSeries(Polygon([[0, 0], [1, 0], [1, 1], [0, 0]])) gpdpolygon2 = gpd.GeoSeries(Polygon([[0, 0], [1, 0], [1, 1], [0, 0]])) @@ -604,7 +604,6 @@ def test_polygon_geom_equals_polygon(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") def test_polygon_geom_equals_polygon_swap_inner(): gpdpolygon1 = gpd.GeoSeries(Polygon([[0, 0], [1, 0], [1, 1], [0, 0]])) gpdpolygon2 = gpd.GeoSeries(Polygon([[0, 0], [1, 1], [1, 0], [0, 0]])) @@ -615,7 +614,6 @@ def test_polygon_geom_equals_polygon_swap_inner(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") @pytest.mark.parametrize( "lhs", [ @@ -652,7 +650,6 @@ def test_3_polygons_geom_equals_3_polygons_one_equal(lhs): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") def test_100_polygons_geom_equals_100_polygons(polygon_generator): gpdpolygons1 = gpd.GeoSeries([*polygon_generator(100, 0)]) gpdpolygons2 = gpd.GeoSeries([*polygon_generator(100, 0)]) @@ -663,7 +660,6 @@ def test_100_polygons_geom_equals_100_polygons(polygon_generator): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") def test_3_polygons_geom_equals_3_polygons_different_sizes(): gpdpoly1 = gpd.GeoSeries( [ @@ -688,7 +684,6 @@ def test_3_polygons_geom_equals_3_polygons_different_sizes(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip(reason="NotImplemented: Depends on .contains") def test_3_polygons_geom_equals_3_polygons_misordered(): gpdpoly1 = gpd.GeoSeries( [ diff --git a/python/cuspatial/cuspatial/tests/binpreds/test_intersects_only_binpreds.py b/python/cuspatial/cuspatial/tests/binpreds/test_intersects_only_binpreds.py index 69a99b6c6..46e11f8a4 100644 --- a/python/cuspatial/cuspatial/tests/binpreds/test_intersects_only_binpreds.py +++ b/python/cuspatial/cuspatial/tests/binpreds/test_intersects_only_binpreds.py @@ -261,9 +261,7 @@ def test_linestring_intersects_multipoint_cross_intersection(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="NotImplemented. Depends on allpairs_multipoint_equals_count" -) +@pytest.mark.xfail(reason="Multipoints not supported yet.") def test_linestring_intersects_multipoint_implicit_cross_intersection(): g1 = cuspatial.GeoSeries([LineString([(0.0, 0.0), (1.0, 1.0)])]) g2 = cuspatial.GeoSeries([MultiPoint([(0.0, 1.0), (1.0, 0.0)])]) @@ -274,9 +272,7 @@ def test_linestring_intersects_multipoint_implicit_cross_intersection(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="NotImplemented. Depends on allpairs_multipoint_equals_count" -) +@pytest.mark.xfail(reason="Multipoints not supported yet.") def test_100_linestrings_intersects_100_multipoints( linestring_generator, multipoint_generator ): @@ -569,10 +565,6 @@ def test_multilinestring_intersects_linestring(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="""NotImplemented. Depends on a a combination -of intersects and contains.""" -) def test_linestring_intersects_polygon(): g1 = cuspatial.GeoSeries( [ @@ -593,10 +585,6 @@ def test_linestring_intersects_polygon(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="""NotImplemented. Depends on a a combination -of intersects and contains.""" -) def test_polygon_intersects_linestring(): g1 = cuspatial.GeoSeries( [ @@ -617,10 +605,6 @@ def test_polygon_intersects_linestring(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="""NotImplemented. Depends on a a combination -of intersects and contains.""" -) def test_multipolygon_intersects_linestring(): g1 = cuspatial.GeoSeries( [ @@ -651,10 +635,6 @@ def test_multipolygon_intersects_linestring(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="""NotImplemented. Depends on a a combination -of intersects and contains.""" -) def test_linestring_intersects_multipolygon(): g1 = cuspatial.GeoSeries( [ @@ -685,10 +665,6 @@ def test_linestring_intersects_multipolygon(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="""NotImplemented. Depends on a a combination -of intersects and contains.""" -) def test_polygon_intersects_multipolygon(): g1 = cuspatial.GeoSeries( [ @@ -719,10 +695,6 @@ def test_polygon_intersects_multipolygon(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="""NotImplemented. Depends on a a combination -of intersects and contains.""" -) def test_multipolygon_intersects_polygon(): g1 = cuspatial.GeoSeries( [ @@ -753,10 +725,6 @@ def test_multipolygon_intersects_polygon(): pd.testing.assert_series_equal(expected, got.to_pandas()) -@pytest.mark.skip( - reason="""NotImplemented. Depends on a a combination -of intersects and contains.""" -) def test_multipolygon_intersects_multipolygon(): g1 = cuspatial.GeoSeries( [ diff --git a/python/cuspatial/cuspatial/utils/binpred_utils.py b/python/cuspatial/cuspatial/utils/binpred_utils.py index 7229df632..22b495513 100644 --- a/python/cuspatial/cuspatial/utils/binpred_utils.py +++ b/python/cuspatial/cuspatial/utils/binpred_utils.py @@ -305,7 +305,49 @@ def _open_polygon_rings(geoseries): def _points_and_lines_to_multipoints(geoseries, offsets): """Converts a geoseries of points and lines into a geoseries of - multipoints.""" + multipoints. + + Given a geoseries of points and lines, this function will return a + geoseries of multipoints. The multipoints will contain the points + and lines in the same order as the original geoseries. The offsets + parameter groups the points and lines into multipoints. The offsets + parameter must be a list of integers that contains the offsets of + the multipoints in the original geoseries. A group of four points + and lines can be arranged into four sets of multipoints depending + on the offset used: + + >>> import cuspatial + >>> from cuspatial.utils.binpred_utils import ( + ... _points_and_lines_to_multipoints + ... ) + >>> from shapely.geometry import Point, LineString + >>> mixed = cuspatial.GeoSeries([ + ... Point(0, 0), + ... LineString([(1, 1), (2, 2)]), + ... Point(3, 3), + ... LineString([(4, 4), (5, 5)]), + ... ]) + >>> offsets = [0, 4] + >>> # Place all of the points and linestrings into a single + >>> # multipoint + >>> _points_and_lines_to_multipoints(mixed, offsets) + 0 MULTIPOINT (0.00000 0.00000, 1.00000, 1.0000, ... + dtype: geometry + >>> offsets = [0, 1, 2, 3, 4] + >>> # Place each point and linestring into its own multipoint + >>> _points_and_lines_to_multipoints(mixed, offsets) + 0 MULTIPOINT (0.00000 0.00000) + 1 MULTIPOINT (1.00000, 1.00000, 2.00000, 2.00000) + 2 MULTIPOINT (3.00000 3.00000) + 3 MULTIPOINT (4.00000, 4.00000, 5.00000, 5.00000) + dtype: geometry + >>> offsets = [0, 2, 4] + >>> # Split the points and linestrings into two multipoints + >>> _points_and_lines_to_multipoints(mixed, offsets) + 0 MULTIPOINT (0.00000 0.00000, 1.00000, 1.0000, ... + 1 MULTIPOINT (3.00000 3.00000, 4.00000, 4.0000, ... + dtype: geometry + """ points_mask = geoseries.type == "Point" lines_mask = geoseries.type == "Linestring" if (points_mask + lines_mask).sum() != len(geoseries): @@ -375,8 +417,3 @@ def _multipoints_is_degenerate(geoseries): ) & (y1.reset_index(drop=True) == y2.reset_index(drop=True)) result[sizes_mask] = is_degenerate.reset_index(drop=True) return result - - -def _linestrings_is_degenerate(geoseries): - multipoints = _multipoints_from_geometry(geoseries) - return _multipoints_is_degenerate(multipoints)