Repository: geopandas/geopandas

Issue:
ENH: explore(): skip if fields/index are Timestamp
#### Is your feature request related to a problem?

Running `gdf.explore` and getting `TypeError: Object of type Timestamp is not JSON serializable` when a dataframe has a dtype datetime64

#### Describe the solution you'd like

Raise a warning and have output behave like explore without the datetime fields.

skip_datetime_fields arg in `explore`?

#### API breaking implications

None

#### Describe alternatives you've considered

This one liner may fix it:

```
import geopandas as gpd
from datetime import datetime

gdf = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres"))
gdf["date"] = datetime(2022, 1, 1)
gdf.explore() # Traceback below

no_datetime_cols = list(set(gdf.columns) - set(gdf.select_dtypes(include="datetime").columns))
gdf[no_datetime_cols].explore()
```

#### Additional context

Goes into folium/ipython traceback so may be worth upstreaming the issue (or finding the issue if it exists)

Traceback:

```
TypeError                                 Traceback (most recent call last)
File ~/miniconda3/envs/main/lib/python3.9/site-packages/IPython/core/formatters.py:343, in BaseFormatter.__call__(self, obj)
    341     method = get_real_method(obj, self.print_method)
    342     if method is not None:
--> 343         return method()
    344     return None
    345 else:

File ~/miniconda3/envs/main/lib/python3.9/site-packages/folium/folium.py:299, in Map._repr_html_(self, **kwargs)
    297     self._parent = None
    298 else:
--> 299     out = self._parent._repr_html_(**kwargs)
    300 return out

File ~/miniconda3/envs/main/lib/python3.9/site-packages/branca/element.py:331, in Figure._repr_html_(self, **kwargs)
    322 def _repr_html_(self, **kwargs):
    323     """Displays the Figure in a Jupyter notebook.
    324 
    325     Percent-encoded HTML is stored in data-html attribute, which is used to populate
   (...)
    329 
    330     """
--> 331     html = urllib.parse.quote(self.render(**kwargs))
    332     onload = (
    333         'this.contentDocument.open();'
    334         'this.contentDocument.write('
   (...)
    337         'this.contentDocument.close();'
    338     )
    340     if self.height is None:

File ~/miniconda3/envs/main/lib/python3.9/site-packages/branca/element.py:319, in Figure.render(self, **kwargs)
    317 """Renders the HTML representation of the element."""
    318 for name, child in self._children.items():
--> 319     child.render(**kwargs)
    320 return self._template.render(this=self, kwargs=kwargs)

File ~/miniconda3/envs/main/lib/python3.9/site-packages/folium/folium.py:368, in Map.render(self, **kwargs)
    349 figure.header.add_child(Element(
    350     '<style>html, body {'
    351     'width: 100%;'
   (...)
    355     '}'
    356     '</style>'), name='css_style')
    358 figure.header.add_child(Element(
    359     '<style>#map {'
    360     'position:absolute;'
   (...)
    365     '}'
    366     '</style>'), name='map_style')
--> 368 super(Map, self).render(**kwargs)

File ~/miniconda3/envs/main/lib/python3.9/site-packages/folium/elements.py:21, in JSCSSMixin.render(self, **kwargs)
     18 for name, url in self.default_css:
     19     figure.header.add_child(CssLink(url), name=name)
---> 21 super().render(**kwargs)

File ~/miniconda3/envs/main/lib/python3.9/site-packages/branca/element.py:643, in MacroElement.render(self, **kwargs)
    639     figure.script.add_child(Element(script(self, kwargs)),
    640                             name=self.get_name())
    642 for name, element in self._children.items():
--> 643     element.render(**kwargs)

File ~/miniconda3/envs/main/lib/python3.9/site-packages/folium/features.py:626, in GeoJson.render(self, **kwargs)
    623     if self.highlight:
    624         self.highlight_map = mapper.get_highlight_map(
    625             self.highlight_function)
--> 626 super(GeoJson, self).render()

File ~/miniconda3/envs/main/lib/python3.9/site-packages/branca/element.py:639, in MacroElement.render(self, **kwargs)
    637 script = self._template.module.__dict__.get('script', None)
    638 if script is not None:
--> 639     figure.script.add_child(Element(script(self, kwargs)),
    640                             name=self.get_name())
    642 for name, element in self._children.items():
    643     element.render(**kwargs)

File ~/miniconda3/envs/main/lib/python3.9/site-packages/jinja2/runtime.py:814, in Macro.__call__(self, *args, **kwargs)
    808 elif len(args) > self._argument_count:
    809     raise TypeError(
    810         f"macro {self.name!r} takes not more than"
    811         f" {len(self.arguments)} argument(s)"
    812     )
--> 814 return self._invoke(arguments, autoescape)

File ~/miniconda3/envs/main/lib/python3.9/site-packages/jinja2/runtime.py:828, in Macro._invoke(self, arguments, autoescape)
    825 if self._environment.is_async:
    826     return self._async_invoke(arguments, autoescape)  # type: ignore
--> 828 rv = self._func(*arguments)
    830 if autoescape:
    831     rv = Markup(rv)

File <template>:238, in macro(l_1_this, l_1_kwargs)

File ~/miniconda3/envs/main/lib/python3.9/site-packages/jinja2/filters.py:1673, in do_tojson(eval_ctx, value, indent)
   1670     kwargs = kwargs.copy()
   1671     kwargs["indent"] = indent
-> 1673 return htmlsafe_json_dumps(value, dumps=dumps, **kwargs)

File ~/miniconda3/envs/main/lib/python3.9/site-packages/jinja2/utils.py:736, in htmlsafe_json_dumps(obj, dumps, **kwargs)
    732 if dumps is None:
    733     dumps = json.dumps
    735 return markupsafe.Markup(
--> 736     dumps(obj, **kwargs)
    737     .replace("<", "\\u003c")
    738     .replace(">", "\\u003e")
    739     .replace("&", "\\u0026")
    740     .replace("'", "\\u0027")
    741 )

File ~/miniconda3/envs/main/lib/python3.9/json/__init__.py:234, in dumps(obj, skipkeys, ensure_ascii, check_circular, allow_nan, cls, indent, separators, default, sort_keys, **kw)
    232 if cls is None:
    233     cls = JSONEncoder
--> 234 return cls(
    235     skipkeys=skipkeys, ensure_ascii=ensure_ascii,
    236     check_circular=check_circular, allow_nan=allow_nan, indent=indent,
    237     separators=separators, default=default, sort_keys=sort_keys,
    238     **kw).encode(obj)

File ~/miniconda3/envs/main/lib/python3.9/json/encoder.py:199, in JSONEncoder.encode(self, o)
    195         return encode_basestring(o)
    196 # This doesn't pass the iterator directly to ''.join() because the
    197 # exceptions aren't as detailed.  The list call should be roughly
    198 # equivalent to the PySequence_Fast that ''.join() would do.
--> 199 chunks = self.iterencode(o, _one_shot=True)
    200 if not isinstance(chunks, (list, tuple)):
    201     chunks = list(chunks)

File ~/miniconda3/envs/main/lib/python3.9/json/encoder.py:257, in JSONEncoder.iterencode(self, o, _one_shot)
    252 else:
    253     _iterencode = _make_iterencode(
    254         markers, self.default, _encoder, self.indent, floatstr,
    255         self.key_separator, self.item_separator, self.sort_keys,
    256         self.skipkeys, _one_shot)
--> 257 return _iterencode(o, 0)

File ~/miniconda3/envs/main/lib/python3.9/json/encoder.py:179, in JSONEncoder.default(self, o)
    160 def default(self, o):
    161     """Implement this method in a subclass such that it returns
    162     a serializable object for ``o``, or calls the base implementation
    163     (to raise a ``TypeError``).
   (...)
    177 
    178     """
--> 179     raise TypeError(f'Object of type {o.__class__.__name__} '
    180                     f'is not JSON serializable')

TypeError: Object of type Timestamp is not JSON serializable
```



Discussion:
martinfleis: I would do it exactly as you did in here. Automatically cast to strings, with no keywords. The explore API is already a bit large and if you really need to do something else, you can do it before calling explore. I would also not be worried about the performance hit, as explore is unusable for larger dataframes anyway.

> There is also the option we could preserve additional geometry columns as WKT, rather than just drop them as we currently do.

That would make sense only for points as anything else would flood the tooltip with a long unreadable string. I'd hold on and do that only if someone feels it is needed.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cf293cb5..caaebe88 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,6 +10,6 @@ repos:
           - id: black
             language_version: python3
     - repo: https://github.com/astral-sh/ruff-pre-commit
-      rev: "v0.1.13"
+      rev: "v0.4.4"
       hooks:
         - id: ruff
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba783dd7..cc25e552 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,9 @@ New methods:
 - Added `dwithin` method to check for a "distance within" predicate on
   GeoSeries/GeoDataFrame (#3153).
 - Added `to_geo_dict` method to generate GeoJSON-like dictionary from a GeoDataFrame (#3132).
+- Added `polygonize` method exposing both `polygonize` and `polygonize_full` from
+  shapely to GeoSeries/GeoDataframe (#2963).
+- Added `is_valid_reason` method from shapely to GeoSeries/GeoDataframe (#3176).
 
 New features and improvements:
 
@@ -63,6 +66,8 @@ New features and improvements:
   preservation of the original order of observations. (#3233)
 - Added `show_bbox`, `drop_id` and `to_wgs84` arguments to allow further customization of
   `GeoSeries.to_json` (#3226)
+- `explore` now supports `GeoDataFrame`s with additional columns containing datetimes, uuids and 
+  other non JSON serializable objects (#3261).
 - The `GeoSeries.fillna` method now supports the `limit` keyword (#3290).
 
 Backwards incompatible API changes:
@@ -74,6 +79,10 @@ Backwards incompatible API changes:
   the previous active geometry column name. This means that if the new and old names are
   different, then both columns will be preserved in the GeoDataFrame. To replicate the previous
   behaviour, you can instead call `gdf.set_geometry(ser.rename(gdf.active_geometry_name))` (#3237).
+- `delaunay_triangles` now considers all geometries together when creating the Delaunay trianguation
+  instead of performing the operation element-wise. If you want to generate Delaunay
+  triangles for each geometry separately, use ``shapely.delaunay_triangles`` instead. (#3273)
+
 
 Potentially breaking changes:
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index a0698808..e09388ad 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -52,6 +52,7 @@ Unary predicates
    GeoSeries.is_ring
    GeoSeries.is_simple
    GeoSeries.is_valid
+   GeoSeries.is_valid_reason
    GeoSeries.has_z
    GeoSeries.is_ccw
 
@@ -114,6 +115,7 @@ Constructive methods and attributes
    GeoSeries.minimum_clearance
    GeoSeries.minimum_rotated_rectangle
    GeoSeries.normalize
+   GeoSeries.polygonize
    GeoSeries.remove_repeated_points
    GeoSeries.reverse
    GeoSeries.sample_points
diff --git a/geopandas/array.py b/geopandas/array.py
index 10f4fd5c..93ed9861 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -497,6 +497,9 @@ class GeometryArray(ExtensionArray):
     def is_valid(self):
         return shapely.is_valid(self._data)
 
+    def is_valid_reason(self):
+        return shapely.is_valid_reason(self._data)
+
     @property
     def is_empty(self):
         return shapely.is_empty(self._data)
@@ -571,12 +574,6 @@ class GeometryArray(ExtensionArray):
     def convex_hull(self):
         return GeometryArray(shapely.convex_hull(self._data), crs=self.crs)
 
-    def delaunay_triangles(self, tolerance, only_edges):
-        return GeometryArray(
-            shapely.delaunay_triangles(self._data, tolerance, only_edges),
-            crs=self.crs,
-        )
-
     @property
     def envelope(self):
         return GeometryArray(shapely.envelope(self._data), crs=self.crs)
diff --git a/geopandas/base.py b/geopandas/base.py
index 5feb1b2c..87408c59 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -324,9 +324,52 @@ GeometryCollection
         3    False
         dtype: bool
 
+        See also
+        --------
+        GeoSeries.is_valid_reason : reason for invalidity
         """
         return _delegate_property("is_valid", self)
 
+    def is_valid_reason(self):
+        """Returns a ``Series`` of strings with the reason for invalidity of
+        each geometry.
+
+        Examples
+        --------
+
+        An example with one invalid polygon (a bowtie geometry crossing itself)
+        and one missing geometry:
+
+        >>> from shapely.geometry import Polygon
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1)]),
+        ...         Polygon([(0,0), (1, 1), (1, 0), (0, 1)]),  # bowtie geometry
+        ...         Polygon([(0, 0), (2, 2), (2, 0)]),
+        ...         None
+        ...     ]
+        ... )
+        >>> s
+        0         POLYGON ((0 0, 1 1, 0 1, 0 0))
+        1    POLYGON ((0 0, 1 1, 1 0, 0 1, 0 0))
+        2         POLYGON ((0 0, 2 2, 2 0, 0 0))
+        3                                   None
+        dtype: geometry
+
+        >>> s.is_valid_reason()
+        0    Valid Geometry
+        1    Self-intersection[0.5 0.5]
+        2    Valid Geometry
+        3    None
+        dtype: object
+
+        See also
+        --------
+        GeoSeries.is_valid : detect invalid geometries
+        GeoSeries.make_valid : fix invalid geometries
+        """
+        return Series(self.geometry.values.is_valid_reason(), index=self.index)
+
     @property
     def is_empty(self):
         """
@@ -954,59 +997,103 @@ GeometryCollection
 
     def delaunay_triangles(self, tolerance=0.0, only_edges=False):
         """Returns a ``GeoSeries`` consisting of objects representing
-        the computed Delaunay triangulation around the vertices of
+        the computed Delaunay triangulation between the vertices of
         an input geometry.
 
-        The output is a ``GeometryCollection`` containing polygons
-        (default) or linestrings (see only_edges).
+        All geometries within the GeoSeries are considered together within a single
+        Delaunay triangulation. The resulting geometries therefore do not map 1:1
+        to input geometries. Note that each vertex of a geometry is considered a site
+        for the triangulation, so the triangles will be constructed between the vertices
+        of each geometry.
 
-        Returns an empty GeometryCollection if an input geometry
-        contains less than 3 vertices.
+        Notes
+        -----
+        If you want to generate Delaunay triangles for each geometry separately, use
+        :func:`shapely.delaunay_triangles` instead.
 
         Parameters
         ----------
-        tolerance : float | array-like, default 0.0
+        tolerance : float, default 0.0
             Snap input vertices together if their distance is less than this value.
-        only_edges : bool | array_like, (optional, default False)
-            If set to True, the triangulation will return a collection of
-            linestrings instead of polygons.
+        only_edges : bool (optional, default False)
+            If set to True, the triangulation will return linestrings instead of
+            polygons.
 
         Examples
         --------
 
-        >>> from shapely import LineString, MultiPoint, Polygon
+        >>> from shapely import LineString, MultiPoint, Point, Polygon
         >>> s = geopandas.GeoSeries(
         ...     [
-        ...         MultiPoint([(5, 3), (6, 3), (10, 10)]),
-        ...         Polygon([(5, 3), (6, 3), (10, 10), (5, 3)]),
-        ...         LineString([(5, 3), (6, 3), (10, 10)]),
+        ...         Point(1, 1),
+        ...         Point(2, 2),
+        ...         Point(1, 3),
+        ...         Point(0, 2),
         ...     ]
         ... )
         >>> s
-        0    MULTIPOINT ((5 3), (6 3), (10 10))
-        1      POLYGON ((5 3, 6 3, 10 10, 5 3))
-        2          LINESTRING (5 3, 6 3, 10 10)
+        0    POINT (1 1)
+        1    POINT (2 2)
+        2    POINT (1 3)
+        3    POINT (0 2)
         dtype: geometry
 
         >>> s.delaunay_triangles()
-        0    GEOMETRYCOLLECTION (POLYGON ((10 10, 5 3, 6 3,...
-        1    GEOMETRYCOLLECTION (POLYGON ((10 10, 5 3, 6 3,...
-        2    GEOMETRYCOLLECTION (POLYGON ((10 10, 5 3, 6 3,...
+        0    POLYGON ((0 2, 1 1, 1 3, 0 2))
+        1    POLYGON ((1 3, 1 1, 2 2, 1 3))
         dtype: geometry
 
         >>> s.delaunay_triangles(only_edges=True)
-        0    MULTILINESTRING ((5 3, 10 10), (5 3, 6 3), (6 ...
-        1    MULTILINESTRING ((5 3, 10 10), (5 3, 6 3), (6 ...
-        2    MULTILINESTRING ((5 3, 10 10), (5 3, 6 3), (6 ...
+        0    LINESTRING (1 3, 2 2)
+        1    LINESTRING (0 2, 1 3)
+        2    LINESTRING (0 2, 1 1)
+        3    LINESTRING (1 1, 2 2)
+        4    LINESTRING (1 1, 1 3)
+        dtype: geometry
+
+        The method supports any geometry type but keep in mind that the underlying
+        algorithm is based on the vertices of the input geometries only and does not
+        consider edge segments between vertices.
+
+        >>> s2 = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1)]),
+        ...         LineString([(1, 0), (2, 1), (1, 2)]),
+        ...         MultiPoint([(2, 3), (2, 0), (3, 1)]),
+        ...     ]
+        ... )
+        >>> s2
+        0      POLYGON ((0 0, 1 1, 0 1, 0 0))
+        1          LINESTRING (1 0, 2 1, 1 2)
+        2    MULTIPOINT ((2 3), (2 0), (3 1))
+        dtype: geometry
+
+        >>> s2.delaunay_triangles()
+        0    POLYGON ((0 1, 0 0, 1 0, 0 1))
+        1    POLYGON ((0 1, 1 0, 1 1, 0 1))
+        2    POLYGON ((0 1, 1 1, 1 2, 0 1))
+        3    POLYGON ((1 2, 1 1, 2 1, 1 2))
+        4    POLYGON ((1 2, 2 1, 2 3, 1 2))
+        5    POLYGON ((2 3, 2 1, 3 1, 2 3))
+        6    POLYGON ((3 1, 2 1, 2 0, 3 1))
+        7    POLYGON ((2 0, 2 1, 1 1, 2 0))
+        8    POLYGON ((2 0, 1 1, 1 0, 2 0))
         dtype: geometry
 
         See also
         --------
         GeoSeries.voronoi_polygons : Voronoi diagram around vertices
         """
-        return _delegate_geo_method(
-            "delaunay_triangles", self, tolerance=tolerance, only_edges=only_edges
+        from .geoseries import GeoSeries
+
+        geometry_input = shapely.geometrycollections(self.geometry.values._data)
+
+        delaunay = shapely.delaunay_triangles(
+            geometry_input,
+            tolerance=tolerance,
+            only_edges=only_edges,
         )
+        return GeoSeries(delaunay, crs=self.crs).explode(ignore_index=True)
 
     def voronoi_polygons(self, tolerance=0.0, extend_to=None, only_edges=False):
         """Returns a ``GeoSeries`` consisting of objects representing
@@ -2096,7 +2183,7 @@ GeometryCollection
 
         An object is said to contain `other` if at least one point of `other` lies in
         the interior and no points of `other` lie in the exterior of the object.
-        (Therefore, any given polygon does not contain its own boundary – there is not
+        (Therefore, any given polygon does not contain its own boundary - there is not
         any point that lies in the interior.)
         If either object is empty, this operation returns ``False``.
 
@@ -4540,7 +4627,7 @@ GeometryCollection
         return _binary_geo("shortest_line", self, other, align)
 
     def snap(self, other, tolerance, align=None):
-        """Snaps an input geometry to reference geometry’s vertices.
+        """Snaps an input geometry to reference geometry's vertices.
 
         Vertices of the first geometry are snapped to vertices of the second. geometry,
         returning a new geometry; the input geometries are not modified. The result
@@ -4872,7 +4959,7 @@ GeometryCollection
         each geometry.
 
         The algorithm (Douglas-Peucker) recursively splits the original line
-        into smaller parts and connects these parts’ endpoints
+        into smaller parts and connects these parts' endpoints
         by a straight line. Then, it removes all points whose distance
         to the straight line is smaller than `tolerance`. It does not
         move any points and it always preserves endpoints of
@@ -5837,6 +5924,93 @@ GeometryCollection
             ignore_index=True
         )
 
+    def polygonize(self, node=True, full=False):
+        """Creates polygons formed from the linework of a GeoSeries.
+
+        Polygonizes the GeoSeries that contain linework which represents the
+        edges of a planar graph. Any geometry type may be provided as input; only the
+        constituent lines and rings will be used to create the output polygons.
+
+        Lines or rings that when combined do not completely close a polygon will be
+        ignored. Duplicate segments are ignored.
+
+        Unless you know that the input GeoSeries represents a planar graph with a clean
+        topology (e.g. there is a node on both lines where they intersect), it is
+        recommended to use ``node=True`` which performs noding prior to polygonization.
+        Using ``node=False`` will provide performance benefits but may result in
+        incorrect polygons if the input is not of the proper topology.
+
+        When ``full=True``, the return value is a 4-tuple containing output polygons,
+        along with lines which could not be converted to polygons. The return value
+        consists of 4 elements or varying lenghts:
+
+        - GeoSeries of the valid polygons (same as with ``full=False``)
+        - GeoSeries of cut edges: edges connected on both ends but not part of
+          polygonal output
+        - GeoSeries of dangles: edges connected on one end but not part of polygonal
+          output
+        - GeoSeries of invalid rings: polygons that are formed but are not valid
+          (bowties, etc)
+
+        Parameters
+        ----------
+        node : bool, default True
+            Perform noding prior to polygonization, by default True.
+        full : bool, default False
+            Return the full output composed of a tuple of GeoSeries, by default False.
+
+        Returns
+        -------
+        GeoSeries | tuple(GeoSeries, GeoSeries, GeoSeries, GeoSeries)
+            GeoSeries with the polygons or a tuple of four GeoSeries as
+            ``(polygons, cuts, dangles, invalid)``
+
+        Examples
+        --------
+        >>> from shapely.geometry import LineString
+        >>> s = geopandas.GeoSeries([
+        ...     LineString([(0, 0), (1, 1)]),
+        ...     LineString([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]),
+        ...     LineString([(0.5, 0.2), (0.5, 0.8)]),
+        ... ])
+        >>> s.polygonize()
+        0        POLYGON ((0 0, 0.5 0.5, 1 1, 1 0, 0 0))
+        1    POLYGON ((0.5 0.5, 0 0, 0 1, 1 1, 0.5 0.5))
+        Name: polygons, dtype: geometry
+
+        >>> polygons, cuts, dangles, invalid = s.polygonize(full=True)
+
+        """
+        from .geoseries import GeoSeries
+
+        if node:
+            geometry_input = [self.geometry.union_all()]
+        else:
+            geometry_input = self.geometry.values
+
+        if full:
+            polygons, cuts, dangles, invalid = shapely.polygonize_full(geometry_input)
+
+            cuts = GeoSeries(cuts, crs=self.crs, name="cut_edges").explode(
+                ignore_index=True
+            )
+            dangles = GeoSeries(dangles, crs=self.crs, name="dangles").explode(
+                ignore_index=True
+            )
+            invalid = GeoSeries(invalid, crs=self.crs, name="invalid_rings").explode(
+                ignore_index=True
+            )
+            polygons = GeoSeries(polygons, crs=self.crs, name="polygons").explode(
+                ignore_index=True
+            )
+
+            return (polygons, cuts, dangles, invalid)
+
+        polygons = shapely.polygonize(geometry_input)
+        return GeoSeries(polygons, crs=self.crs, name="polygons").explode(
+            ignore_index=True
+        )
+
 
 def _get_index_for_parts(orig_idx, outer_idx, ignore_index, index_parts):
     """Helper to handle index when geometries get exploded to parts.
diff --git a/geopandas/explore.py b/geopandas/explore.py
index 9457dce2..9970df44 100644
--- a/geopandas/explore.py
+++ b/geopandas/explore.py
@@ -7,6 +7,8 @@ import pandas as pd
 
 from packaging.version import Version
 
+from pandas.api.types import is_datetime64_any_dtype
+
 _MAP_KWARGS = [
     "location",
     "prefer_canvas",
@@ -316,6 +318,8 @@ def _explore(
         gdf.geometry[rings_mask] = gdf.geometry[rings_mask].apply(
             lambda g: LineString(g)
         )
+    if isinstance(gdf, geopandas.GeoSeries):
+        gdf = gdf.to_frame()
 
     if gdf.crs is None:
         kwargs["crs"] = "Simple"
@@ -323,6 +327,14 @@ def _explore(
     elif not gdf.crs.equals(4326):
         gdf = gdf.to_crs(4326)
 
+    # Fields which are not JSON serializable are coerced to strings
+    json_not_supported_cols = gdf.columns[
+        [is_datetime64_any_dtype(gdf[c]) for c in gdf.columns]
+    ].union(gdf.columns[gdf.dtypes == "object"])
+
+    if len(json_not_supported_cols) > 0:
+        gdf = gdf.astype({c: "string" for c in json_not_supported_cols})
+
     # create folium.Map object
     if m is None:
         # Get bounds to specify location and map extent
diff --git a/geopandas/plotting.py b/geopandas/plotting.py
index 678abaa4..eab1c0a3 100644
--- a/geopandas/plotting.py
+++ b/geopandas/plotting.py
@@ -855,7 +855,7 @@ def plot_dataframe(
             if scheme is not None:
                 categories = labels
             patches = []
-            for value, cat in enumerate(categories):
+            for i in range(len(categories)):
                 patches.append(
                     Line2D(
                         [0],
@@ -864,7 +864,7 @@ def plot_dataframe(
                         marker="o",
                         alpha=style_kwds.get("alpha", 1),
                         markersize=10,
-                        markerfacecolor=n_cmap.to_rgba(value),
+                        markerfacecolor=n_cmap.to_rgba(i),
                         markeredgewidth=0,
                     )
                 )
diff --git a/geopandas/sindex.py b/geopandas/sindex.py
index b006f61d..7b718545 100644
--- a/geopandas/sindex.py
+++ b/geopandas/sindex.py
@@ -52,7 +52,9 @@ class SpatialIndex:
         """
         return PREDICATES
 
-    def query(self, geometry, predicate=None, sort=False, distance=None):
+    def query(
+        self, geometry, predicate=None, sort=False, distance=None, output_format="tuple"
+    ):
         """
         Return the integer indices of all combinations of each input geometry
         and tree geometries where the bounding box of each input geometry
@@ -208,16 +210,36 @@ class SpatialIndex:
 
         indices = self._tree.query(geometry, predicate=predicate, **kwargs)
 
+        if output_format != "tuple":
+            sort = True
+
         if sort:
             if indices.ndim == 1:
-                return np.sort(indices)
+                indices = np.sort(indices)
             else:
                 # sort by first array (geometry) and then second (tree)
                 geo_idx, tree_idx = indices
                 sort_indexer = np.lexsort((tree_idx, geo_idx))
-                return np.vstack((geo_idx[sort_indexer], tree_idx[sort_indexer]))
+                indices = np.vstack((geo_idx[sort_indexer], tree_idx[sort_indexer]))
+
+        if output_format == "sparse":
+            from scipy.sparse import coo_array
+
+            return coo_array(
+                (np.ones(len(indices[0]), dtype=np.bool_), indices),
+                shape=(len(self.geometries), len(geometry)),
+                dtype=np.bool_,
+            )
+
+        if output_format == "dense":
+            dense = np.zeros((len(self.geometries), len(geometry)), dtype=bool)
+            dense[indices] = True
+            return dense
+
+        if output_format == "tuple":
+            return indices
 
-        return indices
+        raise ValueError("Invalid output_format: {}".format(output_format))
 
     @staticmethod
     def _as_geometry_array(geometry):
diff --git a/pyproject.toml b/pyproject.toml
index 2cd6d620..49603022 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -100,22 +100,23 @@ line-length = 88
 
 [tool.ruff]
 line-length = 88
+extend-exclude = ["doc/*", "benchmarks/*", "versioneer.py", "geopandas/_version.py"]
+
+[tool.ruff.lint]
 select = [
     # pyflakes
     "F",
     # pycodestyle
     "E",
     "W",
-    # flake8-2020
-    "YTT",
+    # pyupgrade
+    # "UP",
     # flake8-bugbear
     "B",
-    # flake8-quotes
-    "Q",
     # flake8-debugger
     "T10",
-    # flake8-gettext
-    "INT",
+    # flake8-simplify
+    # "SIM",
     # pylint
     "PLC",
     "PLE",
@@ -123,54 +124,30 @@ select = [
     "PLW",
     # misc lints
     "PIE",
-    # flake8-pyi
-    "PYI",
-    # tidy imports
-    "TID",
     # implicit string concatenation
     "ISC",
     # type-checking imports
     "TCH",
     # comprehensions
     "C4",
-    # pygrep-hooks
-    "PGH",
     # Ruff-specific rules
     "RUF",
+    # isort
+    # "I",
 ]
-target-version = "py39"
-ignore = [ # space before : (needed for how black formats slicing)
-    # "E203",  # not yet implemented
+
+ignore = [
+    ### Intentionally disabled
     # module level import not at top of file
     "E402",
     # do not assign a lambda expression, use a def
     "E731",
-    # line break before binary operator
-    # "W503",  # not yet implemented
-    # line break after binary operator
-    # "W504",  # not yet implemented
-    # controversial
+    # mutable-argument-default
     "B006",
-    # controversial
+    # unused-loop-control-variable
     "B007",
-    # controversial
-    "B008",
-    # setattr is used to side-step mypy
+    # get-attr-with-constant
     "B009",
-    # getattr is used to side-step mypy
-    "B010",
-    # tests use assert False
-    "B011",
-    # tests use comparisons but not their returned value
-    "B015",
-    # false positives
-    "B019",
-    # Loop control variable overrides iterable it iterates
-    "B020",
-    # Function definition does not bind loop variable
-    "B023",
-    # Functions defined inside a loop must not use variables redefined in the loop
-    # "B301",  # not yet implemented
     # Only works with python >=3.10
     "B905",
     # dict literals
@@ -183,50 +160,27 @@ ignore = [ # space before : (needed for how black formats slicing)
     "PLR0912",
     # Too many statements
     "PLR0915",
+    # Magic number
+    "PLR2004",
     # Redefined loop name
     "PLW2901",
     # Global statements are discouraged
     "PLW0603",
-    # Docstrings should not be included in stubs
-    "PYI021",
-    # Use `typing.NamedTuple` instead of `collections.namedtuple`
-    "PYI024",
-    # No builtin `eval()` allowed
-    "PGH001",
     # compare-to-empty-string
     "PLC1901",
-    # Use typing_extensions.TypeAlias for type aliases
-    # "PYI026",  # not yet implemented
-    # Use "collections.abc.*" instead of "typing.*" (PEP 585 syntax)
-    # "PYI027",  # not yet implemented
-    # while int | float can be shortened to float, the former is more explicit
-    # "PYI041",  # not yet implemented
-
-    # Additional checks that don't pass yet
+
+    ### Additional checks that don't pass yet
     # Useless statement
     "B018",
     # Within an except clause, raise exceptions with ...
     "B904",
-    # Magic number
-    "PLR2004",
     # Consider `elif` instead of `else` then `if` to remove indentation level
     "PLR5501",
-    # ambiguous-unicode-character-string
-    "RUF001",
-    # ambiguous-unicode-character-docstring
-    "RUF002",
-    # ambiguous-unicode-character-comment
-    "RUF003",
     # collection-literal-concatenation
     "RUF005",
-    # pairwise-over-zipped (>=PY310 only)
-    "RUF007",
-    # explicit-f-string-type-conversion
-    "RUF010",
     # Mutable class attributes should be annotated with `typing.ClassVar`,
     "RUF012"
 ]
-extend-exclude = ["doc/*", "benchmarks/*", "versioneer.py", "geopandas/_version.py"]
 
-[tool.ruff.per-file-ignores]
+[tool.ruff.lint.per-file-ignores]
 "geopandas/__init__.py" = ["F401"]


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 5b29b66a..83303400 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -70,7 +70,6 @@ jobs:
           - env: ci/envs/311-dev.yaml
             os: ubuntu-latest
             dev: true
-            pandas_copy_on_write: "1"
 
     steps:
       - uses: actions/checkout@v4
@@ -88,15 +87,6 @@ jobs:
           micromamba list
 
       - name: Test
-        env:
-          PANDAS_COPY_ON_WRITE: ${{ matrix.pandas_copy_on_write || '0' }}
-        run: |
-          pytest -v -r fEs -n auto --color=yes --cov=geopandas --cov-append --cov-report term-missing --cov-report xml geopandas/
-
-      - name: Test with Copy-on-Write
-        if: ${{ always() && matrix.dev }}
-        env:
-          PANDAS_COPY_ON_WRITE: '1'
         run: |
           pytest -v -r fEs -n auto --color=yes --cov=geopandas --cov-append --cov-report term-missing --cov-report xml geopandas/
 
diff --git a/geopandas/tests/test_array.py b/geopandas/tests/test_array.py
index d5b88bd7..9e551024 100644
--- a/geopandas/tests/test_array.py
+++ b/geopandas/tests/test_array.py
@@ -820,7 +820,7 @@ def test_setitem(item):
 
 def test_equality_ops():
     with pytest.raises(ValueError):
-        P[:5] == P[:7]
+        _ = P[:5] == P[:7]
 
     a1 = from_shapely([points[1], points[2], points[3]])
     a2 = from_shapely([points[1], points[0], points[3]])
diff --git a/geopandas/tests/test_decorator.py b/geopandas/tests/test_decorator.py
index efc3d37e..94b1dbee 100644
--- a/geopandas/tests/test_decorator.py
+++ b/geopandas/tests/test_decorator.py
@@ -10,7 +10,6 @@ def cumsum(whatever):
 
     It computes the cumulative {operation}.
     """
-    ...
 
 
 @doc(
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index 0c24b5f6..7806f7ac 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -1,3 +1,5 @@
+import uuid
+
 import geopandas as gpd
 import numpy as np
 import pandas as pd
@@ -298,6 +300,34 @@ class TestExplore:
         assert '"__folium_color":"#9edae5","bool":true' in out2_str
         assert '"__folium_color":"#1f77b4","bool":false' in out2_str
 
+    def test_datetime(self):
+        df = self.nybb.copy().head(2)
+        date1 = pd.Timestamp(2022, 1, 1, 1, 22, 0, 0)
+        date2 = pd.Timestamp(2025, 1, 1, 1, 22, 0, 0)
+        df["datetime"] = [date1, date2]
+        m1 = df.explore("datetime")
+
+        out1_str = self._fetch_map_string(m1)
+        assert '"__folium_color":"#9edae5","datetime":"2025-01-0101:22:00"' in out1_str
+        assert '"__folium_color":"#1f77b4","datetime":"2022-01-0101:22:00"' in out1_str
+
+    def test_non_json_serialisable(self):
+        df = self.nybb.copy().head(2)
+        uuid1 = uuid.UUID("12345678123456781234567812345678")
+        uuid2 = uuid.UUID("12345678123456781234567812345679")
+        df["object"] = [uuid1, uuid2]
+        m1 = df.explore("object")
+
+        out1_str = self._fetch_map_string(m1)
+        assert (
+            '"__folium_color":"#9edae5","object":"12345678-1234-5678-1234-567812345679"}'
+            in out1_str
+        )
+        assert (
+            '"__folium_color":"#1f77b4","object":"12345678-1234-5678-1234-567812345678"'
+            in out1_str
+        )
+
     def test_string(self):
         df = self.nybb.copy()
         df["string"] = pd.array([1, 2, 3, 4, 5], dtype="string")
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index a60f7857..79d99ac9 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -1300,7 +1300,7 @@ class TestConstructor:
             geometry="geometry",
         )
         check_geodataframe(gdf)
-        gdf.columns == ["geometry", "a"]
+        assert list(gdf.columns) == ["geometry", "a"]
 
         # with non-default index
         gdf = GeoDataFrame(
@@ -1310,7 +1310,7 @@ class TestConstructor:
             geometry="geometry",
         )
         check_geodataframe(gdf)
-        gdf.columns == ["geometry", "a"]
+        assert list(gdf.columns) == ["geometry", "a"]
 
     def test_preserve_series_name(self):
         geoms = [Point(1, 1), Point(2, 2), Point(3, 3)]
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index f99235f2..f06e9bd9 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -174,6 +174,20 @@ class TestGeomMethods:
         self.l6 = LineString([(5, 5), (5, 100), (100, 5)])
         self.g12 = GeoSeries([self.l5])
         self.g13 = GeoSeries([self.l6])
+        self.lines = GeoSeries(
+            [
+                LineString([(0, 0), (1, 1)]),
+                LineString([(0, 0), (0, 1)]),
+                LineString([(0, 1), (1, 1)]),
+                LineString([(1, 1), (1, 0)]),
+                LineString([(1, 0), (0, 0)]),
+                LineString([(5, 5), (6, 6)]),
+                LineString([(0.5, -1), (0.5, 2)]),
+                Point(0, 0),
+            ],
+            crs=4326,
+            index=range(2, 10),
+        )
 
         self.l5 = LineString([(100, 0), (0, 0), (0, 100)])
         self.l6 = LineString([(5, 5), (5, 100), (100, 5)])
@@ -868,6 +882,20 @@ class TestGeomMethods:
         expected = Series(np.array([True] * len(self.g1)), self.g1.index)
         self._test_unary_real("is_valid", expected, self.g1)
 
+    def test_is_valid_reason(self):
+        expected = Series(np.array(["Valid Geometry"] * len(self.g1)), self.g1.index)
+        assert_series_equal(self.g1.is_valid_reason(), expected)
+
+        s = GeoSeries(
+            [
+                Polygon([(0, 0), (1, 1), (1, 0), (0, 1)]),  # bowtie geometry
+                Polygon([(0, 0), (1, 1), (1, 1), (0, 1)]),
+                None,
+            ]
+        )
+        expected = Series(["Self-intersection[0.5 0.5]", "Valid Geometry", None])
+        assert_series_equal(s.is_valid_reason(), expected)
+
     def test_is_empty(self):
         expected = Series(np.array([False] * len(self.g1)), self.g1.index)
         self._test_unary_real("is_empty", expected, self.g1)
@@ -1108,37 +1136,26 @@ class TestGeomMethods:
     def test_delaunay_triangles(self):
         expected = GeoSeries(
             [
-                GeometryCollection([Polygon([(0, 0), (1, 0), (1, 1), (0, 0)])]),
-                GeometryCollection([Polygon([(0, 1), (0, 0), (1, 1), (0, 1)])]),
-            ],
-            crs=self.g3.crs,
+                Polygon([(0, 1), (0, 0), (1, 0), (0, 1)]),
+                Polygon([(0, 1), (1, 0), (1, 1), (0, 1)]),
+            ]
         )
-        dlt = self.g3.delaunay_triangles()
+        dlt = self.g5.delaunay_triangles()
         assert_geoseries_equal(expected, dlt)
 
     def test_delaunay_triangles_pass_kwargs(self):
         expected = GeoSeries(
             [
-                MultiLineString([[(0, 0), (1, 1)], [(0, 0), (1, 0)], [(1, 0), (1, 1)]]),
-                MultiLineString([[(0, 1), (1, 1)], [(0, 0), (0, 1)], [(0, 0), (1, 1)]]),
+                LineString([(0, 1), (1, 1)]),
+                LineString([(0, 0), (0, 1)]),
+                LineString([(0, 0), (1, 0)]),
+                LineString([(1, 0), (1, 1)]),
+                LineString([(0, 1), (1, 0)]),
             ],
-            crs=self.g3.crs,
         )
-        dlt = self.g3.delaunay_triangles(only_edges=True)
+        dlt = self.g5.delaunay_triangles(only_edges=True)
         assert_geoseries_equal(expected, dlt)
 
-    def test_delaunay_triangles_wrong_index(self):
-        with pytest.raises(
-            ValueError,
-            match="Index of the Series passed as 'only_edges' does not match",
-        ):
-            self.g3.delaunay_triangles(only_edges=Series([True, False], index=[99, 98]))
-
-        with pytest.raises(
-            ValueError, match="Index of the Series passed as 'tolerance' does not match"
-        ):
-            self.g3.delaunay_triangles(tolerance=Series([0.1, 0.2], index=[99, 98]))
-
     def test_voronoi_polygons(self):
         expected = GeoSeries.from_wkt(
             [
@@ -1914,6 +1931,61 @@ class TestGeomMethods:
         ):
             GeoSeries([self.l1]).offset_curve(Series([1], index=[99]))
 
+    def test_polygonize(self):
+        expected = GeoSeries.from_wkt(
+            [
+                "POLYGON ((0 0, 0.5 0.5, 0.5 0, 0 0))",
+                "POLYGON ((0.5 0.5, 0 0, 0 1, 0.5 1, 0.5 0.5))",
+                "POLYGON ((0.5 0.5, 1 1, 1 0, 0.5 0, 0.5 0.5))",
+                "POLYGON ((1 1, 0.5 0.5, 0.5 1, 1 1))",
+            ],
+            name="polygons",
+            crs=4326,
+        )
+
+        result = self.lines.polygonize()
+        assert_geoseries_equal(expected, result)
+        assert_index_equal(self.lines.index, Index(range(2, 10)))
+
+    def test_polygonize_no_node(self):
+        expected = GeoSeries.from_wkt(
+            ["POLYGON ((0 0, 1 1, 1 0, 0 0))", "POLYGON ((1 1, 0 0, 0 1, 1 1))"],
+            name="polygons",
+            crs=4326,
+        )
+        result = self.lines.polygonize(node=False)
+        assert_geoseries_equal(expected, result)
+        assert_index_equal(self.lines.index, Index(range(2, 10)))
+
+    def test_polygonize_full(self):
+        expected_poly = GeoSeries.from_wkt(
+            [
+                "POLYGON ((0 0, 0.5 0.5, 0.5 0, 0 0))",
+                "POLYGON ((0.5 0.5, 0 0, 0 1, 0.5 1, 0.5 0.5))",
+                "POLYGON ((0.5 0.5, 1 1, 1 0, 0.5 0, 0.5 0.5))",
+                "POLYGON ((1 1, 0.5 0.5, 0.5 1, 1 1))",
+            ],
+            name="polygons",
+            crs=4326,
+        )
+        expected_cuts = GeoSeries([], name="cut edges", crs=4326)
+        expected_dangles = GeoSeries.from_wkt(
+            [
+                "LINESTRING (5 5, 6 6)",
+                "LINESTRING (0.5 1, 0.5 2)",
+                "LINESTRING (0.5 -1, 0.5 0)",
+            ],
+            name="dangles",
+            crs=4326,
+        )
+        expected_invalid = GeoSeries([], name="invalid ring lines", crs=4326)
+        result = self.lines.polygonize(full=True)
+        assert_geoseries_equal(expected_poly, result[0])
+        assert_geoseries_equal(expected_cuts, result[1])
+        assert_geoseries_equal(expected_dangles, result[2])
+        assert_geoseries_equal(expected_invalid, result[3])
+        assert_index_equal(self.lines.index, Index(range(2, 10)))
+
     @pytest.mark.skipif(shapely.geos_version < (3, 11, 0), reason="requires GEOS>=3.11")
     @pytest.mark.parametrize(
         "geom,expected",
diff --git a/geopandas/tests/test_sindex.py b/geopandas/tests/test_sindex.py
index 43be87e2..9b365d5f 100644
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -1,7 +1,6 @@
 from math import sqrt
 
 import numpy as np
-import pandas as pd
 import pytest
 import shapely
 from numpy.testing import assert_array_equal
@@ -164,15 +163,15 @@ class TestFrameSindex:
         """Selecting a subset of columns preserves the index."""
         original_index = self.df.sindex
         # Selecting a subset of columns preserves the index for pandas < 2.0
-        # with pandas 2.0, the column is now copied, losing the index (although
-        # with Copy-on-Write, this will again be preserved)
+        # with pandas 2.0, the column is now copied, losing the index. But
+        # with pandas >= 3.0 and Copy-on-Write this is preserved again
         subset1 = self.df[["geom", "A"]]
-        if compat.PANDAS_GE_20 and not pd.options.mode.copy_on_write:
+        if compat.PANDAS_GE_20 and not compat.PANDAS_GE_30:
             assert subset1.sindex is not original_index
         else:
             assert subset1.sindex is original_index
         subset2 = self.df[["A", "geom"]]
-        if compat.PANDAS_GE_20 and not pd.options.mode.copy_on_write:
+        if compat.PANDAS_GE_20 and not compat.PANDAS_GE_30:
             assert subset2.sindex is not original_index
         else:
             assert subset2.sindex is original_index