Repository: geopandas/geopandas
Pull request #3027 (2023-09-25 03:54:59)

ENH: Add is_ccw from shapely
adding is_ccw from shapely


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f0c63be..3ca2e642 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,10 +2,6 @@
 
 ## Development version
 
-New methods:
-
-- Added ``count_coordinates`` method from shapely to GeoSeries/GeoDataframe (#3026).
-
 Notes on dependencies:
 
 - GeoPandas 1.0 drops support for shapely<2 and PyGEOS. The only geometry engine that is
@@ -13,8 +9,9 @@ Notes on dependencies:
   rtree package has also been removed. (#3035)
 
 New methods:
-
-- Added ``minimum_clearance`` method from shapely to GeoSeries/GeoDataframe (#2989).
+- Added `count_coordinates` method from shapely to GeoSeries/GeoDataframe (#3026).
+- Added `minimum_clearance` method from shapely to GeoSeries/GeoDataframe (#2989).
+- Added `is_ccw` method from shapely to GeoSeries/GeoDataframe (#3027).
 
 Potentially breaking changes:
 - reading a data source that does not have a geometry field using ``read_file``
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index 8b8075be..97212aa7 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -47,6 +47,7 @@ Unary predicates
    GeoSeries.is_simple
    GeoSeries.is_valid
    GeoSeries.has_z
+   GeoSeries.is_ccw
 
 
 Binary predicates
diff --git a/geopandas/array.py b/geopandas/array.py
index c83ff5e0..225be925 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -499,6 +499,10 @@ class GeometryArray(ExtensionArray):
     def is_closed(self):
         return shapely.is_closed(self._data)
 
+    @property
+    def is_ccw(self):
+        return shapely.is_ccw(self._data)
+
     @property
     def has_z(self):
         return shapely.has_z(self._data)
diff --git a/geopandas/base.py b/geopandas/base.py
index 77f275f4..0d666a42 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -434,6 +434,47 @@ GeometryCollection
         """
         return _delegate_property("is_ring", self)
 
+    @property
+    def is_ccw(self):
+        """Returns a ``Series`` of ``dtype('bool')`` with value ``True``
+        if a LineString or LinearRing is counterclockwise.
+
+        Note that there are no checks on whether lines are actually
+        closed and not self-intersecting, while this is a requirement
+        for ``is_ccw``. The recommended usage of this property for
+        LineStrings is ``GeoSeries.is_ccw & GeoSeries.is_simple`` and for
+        LinearRings ``GeoSeries.is_ccw & GeoSeries.is_valid``.
+
+        This property will return False for non-linear geometries and for
+        lines with fewer than 4 points (including the closing point).
+
+        Examples
+        --------
+        >>> from shapely.geometry import LineString, LinearRing, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         LinearRing([(0, 0), (0, 1), (1, 1), (0, 0)]),
+        ...         LinearRing([(0, 0), (1, 1), (0, 1), (0, 0)]),
+        ...         LineString([(0, 0), (1, 1), (0, 1)]),
+        ...         Point(3, 3)
+        ...     ]
+        ... )
+        >>> s
+        0    LINEARRING (0.00000 0.00000, 0.00000 1.00000, ...
+        1    LINEARRING (0.00000 0.00000, 1.00000 1.00000, ...
+        2    LINESTRING (0.00000 0.00000, 1.00000 1.00000, ...
+        3                              POINT (3.00000 3.00000)
+        dtype: geometry
+
+        >>> s.is_ccw
+        0    False
+        1     True
+        2    False
+        3    False
+        dtype: bool
+        """
+        return _delegate_property("is_ccw", self)
+
     @property
     def has_z(self):
         """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 44f3c355..5520e53e 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -782,6 +782,10 @@ class TestGeomMethods:
         expected = Series(np.array([True] * len(self.g1)), self.g1.index)
         self._test_unary_real("is_simple", expected, self.g1)
 
+    def test_is_ccw(self):
+        expected = Series(np.array([False] * len(self.g1)), self.g1.index)
+        self._test_unary_real("is_ccw", expected, self.g1)
+
     def test_has_z(self):
         expected = Series([False, True], self.g_3d.index)
         self._test_unary_real("has_z", expected, self.g_3d)