This is an automated email from the ASF dual-hosted git repository.

jiayu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git


The following commit(s) were added to refs/heads/master by this push:
     new 883811b696 [GH-2731] Implement GeoSeries: frechet_distance, 
hausdorff_distance, geom_equals, interpolate, project (#2732)
883811b696 is described below

commit 883811b6966feeb7396b0e969017ed85d3063743
Author: Jia Yu <[email protected]>
AuthorDate: Sun Mar 15 21:27:34 2026 -0700

    [GH-2731] Implement GeoSeries: frechet_distance, hausdorff_distance, 
geom_equals, interpolate, project (#2732)
---
 .gitignore                                         |   1 +
 .../java/org/apache/sedona/common/Functions.java   |   4 +-
 .../java/org/apache/sedona/common/Predicates.java  |   3 +
 .../org/apache/sedona/common/FunctionsTest.java    |  34 +++
 .../org/apache/sedona/common/PredicatesTest.java   |  17 ++
 python/sedona/spark/geopandas/base.py              | 257 +++++++++++++++++++++
 python/sedona/spark/geopandas/geoseries.py         |  85 +++++++
 python/tests/geopandas/test_geoseries.py           | 171 ++++++++++++++
 .../tests/geopandas/test_match_geopandas_series.py |  77 ++++++
 .../org/apache/sedona/snowflake/snowsql/UDFs.java  |   2 +-
 .../apache/sedona/snowflake/snowsql/UDFsV2.java    |   2 +-
 .../org/apache/sedona/sql/functionTestScala.scala  |  24 ++
 12 files changed, 674 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index cd609e393b..82c2646a07 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@
 /.scala-build
 /.settings/
 /.venv/
+/.venv-test/
 /.vscode/
 /bin/
 /conf/
diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java 
b/common/src/main/java/org/apache/sedona/common/Functions.java
index 3760c33115..0c7f487b65 100644
--- a/common/src/main/java/org/apache/sedona/common/Functions.java
+++ b/common/src/main/java/org/apache/sedona/common/Functions.java
@@ -1340,6 +1340,7 @@ public class Functions {
   }
 
   public static Geometry lineInterpolatePoint(Geometry geom, double fraction) {
+    if (geom.isEmpty()) return geom.getFactory().createPoint();
     double length = geom.getLength();
     LengthIndexedLine indexedLine = new LengthIndexedLine(geom);
     Coordinate interPoint = indexedLine.extractPoint(length * fraction);
@@ -1485,7 +1486,8 @@ public class Functions {
     return ConstrainedDelaunayTriangulator.triangulate(geom);
   }
 
-  public static double lineLocatePoint(Geometry geom, Geometry point) {
+  public static Double lineLocatePoint(Geometry geom, Geometry point) {
+    if (geom.isEmpty() || point.isEmpty()) return null;
     double length = geom.getLength();
     LengthIndexedLine indexedLine = new LengthIndexedLine(geom);
     return indexedLine.indexOf(point.getCoordinate()) / length;
diff --git a/common/src/main/java/org/apache/sedona/common/Predicates.java 
b/common/src/main/java/org/apache/sedona/common/Predicates.java
index 21fd11cff9..9a030748e5 100644
--- a/common/src/main/java/org/apache/sedona/common/Predicates.java
+++ b/common/src/main/java/org/apache/sedona/common/Predicates.java
@@ -56,6 +56,9 @@ public class Predicates {
   }
 
   public static boolean equals(Geometry leftGeometry, Geometry rightGeometry) {
+    if (leftGeometry.isEmpty() && rightGeometry.isEmpty()) {
+      return true;
+    }
     return leftGeometry.equalsTopo(rightGeometry);
   }
 
diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java 
b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
index 4e9e6cd0a2..882a18de82 100644
--- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
+++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
@@ -4407,6 +4407,40 @@ public class FunctionsTest extends TestBase {
     assertEquals(expectedResult3, actual3, FP_TOLERANCE);
   }
 
+  @Test
+  public void lineLocatePointEmpty() {
+    LineString emptyLine = GEOMETRY_FACTORY.createLineString();
+    Geometry point = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1));
+    assertNull(Functions.lineLocatePoint(emptyLine, point));
+  }
+
+  @Test
+  public void lineLocatePointEmptyPoint() {
+    LineString line =
+        GEOMETRY_FACTORY.createLineString(
+            new Coordinate[] {new Coordinate(0, 0), new Coordinate(1, 1)});
+    Geometry emptyPoint = GEOMETRY_FACTORY.createPoint();
+    assertNull(Functions.lineLocatePoint(line, emptyPoint));
+  }
+
+  @Test
+  public void lineLocatePointZeroLength() {
+    LineString zeroLen =
+        GEOMETRY_FACTORY.createLineString(
+            new Coordinate[] {new Coordinate(1, 1), new Coordinate(1, 1)});
+    Geometry point = GEOMETRY_FACTORY.createPoint(new Coordinate(2, 2));
+    Double result = Functions.lineLocatePoint(zeroLen, point);
+    assertTrue(Double.isNaN(result));
+  }
+
+  @Test
+  public void lineInterpolatePointEmpty() {
+    LineString emptyLine = GEOMETRY_FACTORY.createLineString();
+    Geometry actual = Functions.lineInterpolatePoint(emptyLine, 0.5);
+    assertTrue(actual.isEmpty());
+    assertEquals("Point", actual.getGeometryType());
+  }
+
   @Test
   public void locateAlong() throws ParseException {
     Geometry geom =
diff --git a/common/src/test/java/org/apache/sedona/common/PredicatesTest.java 
b/common/src/test/java/org/apache/sedona/common/PredicatesTest.java
index 7e8616c976..8d48ae1402 100644
--- a/common/src/test/java/org/apache/sedona/common/PredicatesTest.java
+++ b/common/src/test/java/org/apache/sedona/common/PredicatesTest.java
@@ -102,6 +102,23 @@ public class PredicatesTest extends TestBase {
     assertFalse(Predicates.equals(gc1, gc3));
   }
 
+  @Test
+  public void testEqualsEmptyGeometries() throws ParseException {
+    // Empty geometries of different types should be considered equal
+    Geometry pointEmpty = geomFromEWKT("POINT EMPTY");
+    Geometry multipolygonEmpty = geomFromEWKT("MULTIPOLYGON EMPTY");
+    assertTrue(Predicates.equals(pointEmpty, multipolygonEmpty));
+
+    // Empty geometries of the same type should be equal
+    Geometry linestringEmpty = geomFromEWKT("LINESTRING EMPTY");
+    Geometry linestringEmpty2 = geomFromEWKT("LINESTRING EMPTY");
+    assertTrue(Predicates.equals(linestringEmpty, linestringEmpty2));
+
+    // An empty geometry and a non-empty geometry should not be equal
+    Geometry point = geomFromEWKT("POINT(0 0)");
+    assertFalse(Predicates.equals(pointEmpty, point));
+  }
+
   @Test
   public void testRelateBoolean() throws ParseException {
     Geometry geom1 = geomFromEWKT("POINT(1 2)");
diff --git a/python/sedona/spark/geopandas/base.py 
b/python/sedona/spark/geopandas/base.py
index ac6827e097..858bd6e72f 100644
--- a/python/sedona/spark/geopandas/base.py
+++ b/python/sedona/spark/geopandas/base.py
@@ -2363,6 +2363,263 @@ class GeoFrame(metaclass=ABCMeta):
         """
         return _delegate_to_geometry_column("distance", self, other, align)
 
+    def frechet_distance(self, other, align=None, densify=None):
+        """Returns a ``Series`` containing the discrete Fréchet distance to 
aligned `other`.
+
+        The Fréchet distance is a measure of similarity: it is the greatest 
distance
+        between any point in A and the closest point in B. The discrete 
distance is an
+        approximation of this metric: only vertices are considered. The 
parameter
+        ``densify`` makes this approximation less coarse by splitting the line 
segments
+        between vertices before computing the distance.
+
+        The operation works on a 1-to-1 row-wise manner.
+
+        Parameters
+        ----------
+        other : GeoSeries or geometric object
+            The GeoSeries (elementwise) or geometric object to find the
+            distance to.
+        align : bool | None (default None)
+            If True, automatically aligns GeoSeries based on their indices. 
None defaults to True.
+            If False, the order of elements is preserved.
+        densify : float, optional
+            The densify parameter is not supported by Sedona.
+            Passing a value will raise a ``NotImplementedError``.
+
+        Returns
+        -------
+        Series (float)
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import LineString
+        >>> s1 = GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (1, 0), (2, 0)]),
+        ...         LineString([(0, 0), (1, 1)]),
+        ...     ]
+        ... )
+        >>> s2 = GeoSeries(
+        ...     [
+        ...         LineString([(0, 1), (1, 2), (2, 1)]),
+        ...         LineString([(1, 0), (2, 1)]),
+        ...     ]
+        ... )
+
+        >>> s1.frechet_distance(s2)
+        0    2.0
+        1    1.0
+        dtype: float64
+
+        See also
+        --------
+        GeoSeries.hausdorff_distance
+        """
+        return _delegate_to_geometry_column(
+            "frechet_distance", self, other, align, densify
+        )
+
+    def hausdorff_distance(self, other, align=None, densify=None):
+        """Returns a ``Series`` containing the Hausdorff distance to aligned 
`other`.
+
+        The Hausdorff distance is the largest distance consisting of any point 
in `self`
+        with the nearest point in `other`.
+
+        The operation works on a 1-to-1 row-wise manner.
+
+        Parameters
+        ----------
+        other : GeoSeries or geometric object
+            The GeoSeries (elementwise) or geometric object to find the
+            distance to.
+        align : bool | None (default None)
+            If True, automatically aligns GeoSeries based on their indices. 
None defaults to True.
+            If False, the order of elements is preserved.
+        densify : float, optional
+            The fraction by which to densify each segment. Each segment will be
+            split into a number of equal-length subsegments whose fraction of
+            the segment length is closest to the given fraction.
+
+        Returns
+        -------
+        Series (float)
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import LineString
+        >>> s1 = GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (1, 0), (2, 0)]),
+        ...         LineString([(0, 0), (1, 1)]),
+        ...     ]
+        ... )
+        >>> s2 = GeoSeries(
+        ...     [
+        ...         LineString([(0, 1), (1, 2), (2, 1)]),
+        ...         LineString([(1, 0), (2, 1)]),
+        ...     ]
+        ... )
+
+        >>> s1.hausdorff_distance(s2)
+        0    2.0
+        1    1.0
+        dtype: float64
+
+        See also
+        --------
+        GeoSeries.frechet_distance
+        """
+        return _delegate_to_geometry_column(
+            "hausdorff_distance", self, other, align, densify
+        )
+
+    def geom_equals(self, other, align=None):
+        """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for
+        each aligned geometry equal to `other`.
+
+        An object is said to be equal to `other` if its set-theoretic
+        boundary, interior, and exterior coincides with those of the other.
+
+        The operation works on a 1-to-1 row-wise manner.
+
+        Parameters
+        ----------
+        other : GeoSeries or geometric object
+            The GeoSeries (elementwise) or geometric object to test for
+            equality.
+        align : bool | None (default None)
+            If True, automatically aligns GeoSeries based on their indices. 
None defaults to True.
+            If False, the order of elements is preserved.
+
+        Returns
+        -------
+        Series (bool)
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import Point
+        >>> s1 = GeoSeries(
+        ...     [
+        ...         Point(0, 0),
+        ...         Point(1, 1),
+        ...         Point(2, 2),
+        ...     ]
+        ... )
+        >>> s2 = GeoSeries(
+        ...     [
+        ...         Point(0, 0),
+        ...         Point(2, 2),
+        ...         Point(2, 2),
+        ...     ]
+        ... )
+
+        >>> s1.geom_equals(s2)
+        0     True
+        1    False
+        2     True
+        dtype: bool
+
+        See also
+        --------
+        GeoSeries.geom_equals_exact
+        """
+        return _delegate_to_geometry_column("geom_equals", self, other, align)
+
+    def interpolate(self, distance, normalized=False):
+        """Return a point at the specified distance along each geometry.
+
+        Parameters
+        ----------
+        distance : float or Series of floats
+            Distance(s) along the geometries at which a point should be
+            returned. If np.array or pd.Series are used then it must have
+            same length as the GeoSeries.
+        normalized : bool (default False)
+            If True, ``distance`` will be interpreted as a fraction
+            of the geometric object's length.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import LineString
+        >>> s = GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (2, 0), (0, 2)]),
+        ...         LineString([(0, 0), (2, 2)]),
+        ...         LineString([(2, 0), (0, 2)]),
+        ...     ],
+        ... )
+
+        >>> s.interpolate(1)
+        0                POINT (1 0)
+        1    POINT (0.70711 0.70711)
+        2    POINT (1.29289 0.70711)
+        dtype: geometry
+
+        See also
+        --------
+        GeoSeries.project
+        """
+        return _delegate_to_geometry_column("interpolate", self, distance, 
normalized)
+
+    def project(self, other, normalized=False, align=None):
+        """Return the distance along each geometry nearest to `other`.
+
+        The operation works on a 1-to-1 row-wise manner.
+
+        The project method is the inverse of interpolate.
+
+        Parameters
+        ----------
+        other : GeoSeries or geometric object
+            The *other* geometry to compute the projected point from.
+        normalized : bool (default False)
+            If True, return the distance normalized to the length of the 
object.
+        align : bool | None (default None)
+            If True, automatically aligns GeoSeries based on their indices. 
None defaults to True.
+            If False, the order of elements is preserved.
+
+        Returns
+        -------
+        Series (float)
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import LineString, Point
+        >>> s = GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (2, 0), (0, 2)]),
+        ...         LineString([(0, 0), (2, 2)]),
+        ...         LineString([(2, 0), (0, 2)]),
+        ...     ],
+        ... )
+
+        >>> s.project(Point(1, 0))
+        0    1.000000
+        1    0.707107
+        2    0.707107
+        dtype: float64
+
+        >>> s.project(Point(1, 0), normalized=True)
+        0    0.207107
+        1    0.250000
+        2    0.250000
+        dtype: float64
+
+        See also
+        --------
+        GeoSeries.interpolate
+        """
+        return _delegate_to_geometry_column("project", self, other, 
normalized, align)
+
     def intersection(self, other, align=None):
         """Returns a ``GeoSeries`` of the intersection of points in each
         aligned geometry with `other`.
diff --git a/python/sedona/spark/geopandas/geoseries.py 
b/python/sedona/spark/geopandas/geoseries.py
index 96631f3679..f3d96dba30 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -1330,6 +1330,91 @@ class GeoSeries(GeoFrame, pspd.Series):
         )
         return result
 
+    def frechet_distance(self, other, align=None, densify=None) -> pspd.Series:
+        if densify is not None:
+            raise NotImplementedError(
+                "Sedona does not support the densify parameter for 
frechet_distance."
+            )
+
+        other_series, extended = self._make_series_of_val(other)
+        align = False if extended else align
+
+        spark_expr = stf.ST_FrechetDistance(F.col("L"), F.col("R"))
+        result = self._row_wise_operation(
+            spark_expr,
+            other_series,
+            align,
+            default_val=None,
+        )
+        return result
+
+    def hausdorff_distance(self, other, align=None, densify=None) -> 
pspd.Series:
+        other_series, extended = self._make_series_of_val(other)
+        align = False if extended else align
+
+        if densify is not None:
+            spark_expr = stf.ST_HausdorffDistance(F.col("L"), F.col("R"), 
densify)
+        else:
+            spark_expr = stf.ST_HausdorffDistance(F.col("L"), F.col("R"))
+        result = self._row_wise_operation(
+            spark_expr,
+            other_series,
+            align,
+            default_val=None,
+        )
+        return result
+
+    def geom_equals(self, other, align=None) -> pspd.Series:
+        other_series, extended = self._make_series_of_val(other)
+        align = False if extended else align
+
+        spark_expr = stp.ST_Equals(F.col("L"), F.col("R"))
+        result = self._row_wise_operation(
+            spark_expr,
+            other_series,
+            align,
+            returns_geom=False,
+            default_val=False,
+        )
+        return _to_bool(result)
+
+    def interpolate(self, distance, normalized=False) -> "GeoSeries":
+        other_series, extended = self._make_series_of_val(distance)
+        align = not extended
+
+        if normalized:
+            spark_expr = stf.ST_LineInterpolatePoint(F.col("L"), F.col("R"))
+        else:
+            length = stf.ST_Length(F.col("L"))
+            fraction = F.when(length == 0, F.lit(0.0)).otherwise(F.col("R") / 
length)
+            spark_expr = stf.ST_LineInterpolatePoint(F.col("L"), fraction)
+        return self._row_wise_operation(
+            spark_expr,
+            other_series,
+            align=align,
+            returns_geom=True,
+        )
+
+    def project(self, other, normalized=False, align=None) -> pspd.Series:
+        other_series, extended = self._make_series_of_val(other)
+        align = False if extended else align
+
+        if normalized:
+            spark_expr = stf.ST_LineLocatePoint(F.col("L"), F.col("R"))
+        else:
+            locate = stf.ST_LineLocatePoint(F.col("L"), F.col("R"))
+            length = stf.ST_Length(F.col("L"))
+            spark_expr = F.when(locate.isNull(), F.lit(None)).otherwise(
+                F.when(length == 0, F.lit(0.0)).otherwise(locate * length)
+            )
+        result = self._row_wise_operation(
+            spark_expr,
+            other_series,
+            align,
+            default_val=None,
+        )
+        return result
+
     def intersection(
         self, other: Union["GeoSeries", BaseGeometry], align: Union[bool, 
None] = None
     ) -> "GeoSeries":
diff --git a/python/tests/geopandas/test_geoseries.py 
b/python/tests/geopandas/test_geoseries.py
index 9bc572a151..e929e3e43d 100644
--- a/python/tests/geopandas/test_geoseries.py
+++ b/python/tests/geopandas/test_geoseries.py
@@ -2635,6 +2635,177 @@ e": "Feature", "properties": {}, "geometry": {"type": 
"Point", "coordinates": [3
         expected = pd.Series(["FF2F11212", "212101212"])
         self.check_pd_series_equal(result, expected)
 
+    def test_frechet_distance(self):
+        s1 = GeoSeries(
+            [
+                LineString([(0, 0), (1, 0), (2, 0)]),
+                LineString([(0, 0), (1, 1)]),
+            ]
+        )
+        s2 = GeoSeries(
+            [
+                LineString([(0, 1), (1, 2), (2, 1)]),
+                LineString([(1, 0), (2, 1)]),
+            ]
+        )
+
+        result = s1.frechet_distance(s2, align=False)
+        expected = pd.Series([2.0, 1.0])
+        self.check_pd_series_equal(result, expected)
+
+        # Test with single geometry
+        line = LineString([(0, 1), (1, 2), (2, 1)])
+        result = s1.frechet_distance(line)
+        expected = pd.Series([2.0, 1.0])
+        self.check_pd_series_equal(result, expected)
+
+        # Test that GeoDataFrame works too
+        df_result = s1.to_geoframe().frechet_distance(s2, align=False)
+        expected = pd.Series([2.0, 1.0])
+        self.check_pd_series_equal(df_result, expected)
+
+        # Test that densify raises NotImplementedError
+        with pytest.raises(NotImplementedError):
+            s1.frechet_distance(s2, densify=0.5)
+
+    def test_hausdorff_distance(self):
+        s1 = GeoSeries(
+            [
+                LineString([(0, 0), (1, 0), (2, 0)]),
+                LineString([(0, 0), (1, 1)]),
+            ]
+        )
+        s2 = GeoSeries(
+            [
+                LineString([(0, 1), (1, 2), (2, 1)]),
+                LineString([(1, 0), (2, 1)]),
+            ]
+        )
+
+        result = s1.hausdorff_distance(s2, align=False)
+        expected = pd.Series([2.0, 1.0])
+        self.check_pd_series_equal(result, expected)
+
+        # Test with single geometry
+        line = LineString([(0, 1), (1, 2), (2, 1)])
+        result = s1.hausdorff_distance(line)
+        expected = pd.Series([2.0, 1.0])
+        self.check_pd_series_equal(result, expected)
+
+        # Test that GeoDataFrame works too
+        df_result = s1.to_geoframe().hausdorff_distance(s2, align=False)
+        expected = pd.Series([2.0, 1.0])
+        self.check_pd_series_equal(df_result, expected)
+
+        # Test with densify parameter
+        result = s1.hausdorff_distance(s2, densify=0.5, align=False)
+        expected = pd.Series([2.0, 1.0])
+        self.check_pd_series_equal(result, expected)
+
+    def test_geom_equals(self):
+        s1 = GeoSeries(
+            [
+                Point(0, 0),
+                Point(1, 1),
+                Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
+            ]
+        )
+        s2 = GeoSeries(
+            [
+                Point(0, 0),
+                Point(2, 2),
+                Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
+            ]
+        )
+
+        result = s1.geom_equals(s2, align=False)
+        expected = pd.Series([True, False, True])
+        self.check_pd_series_equal(result, expected)
+
+        # Test with single geometry
+        result = s1.geom_equals(Point(0, 0))
+        expected = pd.Series([True, False, False])
+        self.check_pd_series_equal(result, expected)
+
+        # Test that GeoDataFrame works too
+        df_result = s1.to_geoframe().geom_equals(s2, align=False)
+        expected = pd.Series([True, False, True])
+        self.check_pd_series_equal(df_result, expected)
+
+    def test_interpolate(self):
+        s = GeoSeries(
+            [
+                LineString([(0, 0), (2, 0), (0, 2)]),
+                LineString([(0, 0), (2, 2)]),
+                LineString([(2, 0), (0, 2)]),
+            ]
+        )
+
+        # Test with absolute distance
+        result = s.interpolate(1)
+        expected = gpd.GeoSeries(
+            [
+                Point(1, 0),
+                Point(0.7071067811865476, 0.7071067811865476),
+                Point(1.2928932188134524, 0.7071067811865476),
+            ]
+        )
+        self.check_sgpd_equals_gpd(result, expected)
+
+        # Test with normalized distance
+        result = s.interpolate(0.5, normalized=True)
+        expected = gpd.GeoSeries(s.to_geopandas().interpolate(0.5, 
normalized=True))
+        self.check_sgpd_equals_gpd(result, expected)
+
+        # Test that GeoDataFrame works too
+        df_result = s.to_geoframe().interpolate(1)
+        expected = gpd.GeoSeries(
+            [
+                Point(1, 0),
+                Point(0.7071067811865476, 0.7071067811865476),
+                Point(1.2928932188134524, 0.7071067811865476),
+            ]
+        )
+        self.check_sgpd_equals_gpd(df_result, expected)
+
+    def test_project(self):
+        s = GeoSeries(
+            [
+                LineString([(0, 0), (2, 0), (0, 2)]),
+                LineString([(0, 0), (2, 2)]),
+                LineString([(2, 0), (0, 2)]),
+            ]
+        )
+
+        # Test with a single point
+        result = s.project(Point(1, 0))
+        expected = pd.Series([1.0, 0.7071067811865476, 0.7071067811865476])
+        self.check_pd_series_equal(result, expected)
+
+        # Test with normalized=True
+        result = s.project(Point(1, 0), normalized=True)
+        expected = pd.Series(s.to_geopandas().project(Point(1, 0), 
normalized=True))
+        self.check_pd_series_equal(result, expected)
+
+        # Test with two GeoSeries
+        s2 = GeoSeries(
+            [
+                Point(1, 0),
+                Point(1, 0),
+                Point(2, 1),
+            ]
+        )
+        result = s.project(s2, align=False)
+        expected = pd.Series(
+            s.to_geopandas().project(gpd.GeoSeries(s2.to_geopandas()), 
align=False)
+        )
+        self.check_pd_series_equal(result, expected)
+
+        # Test that GeoDataFrame works too
+        df_result = s.to_geoframe().project(Point(1, 0))
+        expected = pd.Series([1.0, 0.7071067811865476, 0.7071067811865476])
+        self.check_pd_series_equal(df_result, expected)
+
     def test_set_crs(self):
         geo_series = sgpd.GeoSeries([Point(0, 0), Point(1, 1)], 
name="geometry")
         assert geo_series.crs == None
diff --git a/python/tests/geopandas/test_match_geopandas_series.py 
b/python/tests/geopandas/test_match_geopandas_series.py
index 6dd5af9dff..4c1af9eab5 100644
--- a/python/tests/geopandas/test_match_geopandas_series.py
+++ b/python/tests/geopandas/test_match_geopandas_series.py
@@ -1285,6 +1285,83 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
                 )
                 self.check_pd_series_equal(sgpd_result, gpd_result)
 
+    def test_frechet_distance(self):
+        line_pairs = [
+            (self.linestrings, self.linestrings),
+            (self.linearrings, self.linearrings),
+            (self.linestrings, self.linearrings),
+        ]
+        for geom, geom2 in line_pairs:
+            sgpd_result = GeoSeries(geom).frechet_distance(GeoSeries(geom2), 
align=True)
+            gpd_result = gpd.GeoSeries(geom).frechet_distance(
+                gpd.GeoSeries(geom2), align=True
+            )
+            self.check_pd_series_equal(sgpd_result, gpd_result)
+
+            if len(geom) == len(geom2):
+                sgpd_result = GeoSeries(geom).frechet_distance(
+                    GeoSeries(geom2), align=False
+                )
+                gpd_result = gpd.GeoSeries(geom).frechet_distance(
+                    gpd.GeoSeries(geom2), align=False
+                )
+                self.check_pd_series_equal(sgpd_result, gpd_result)
+
+    def test_hausdorff_distance(self):
+        for geom, geom2 in self.pairs:
+            sgpd_result = GeoSeries(geom).hausdorff_distance(
+                GeoSeries(geom2), align=True
+            )
+            gpd_result = gpd.GeoSeries(geom).hausdorff_distance(
+                gpd.GeoSeries(geom2), align=True
+            )
+            self.check_pd_series_equal(sgpd_result, gpd_result)
+
+            if len(geom) == len(geom2):
+                sgpd_result = GeoSeries(geom).hausdorff_distance(
+                    GeoSeries(geom2), align=False
+                )
+                gpd_result = gpd.GeoSeries(geom).hausdorff_distance(
+                    gpd.GeoSeries(geom2), align=False
+                )
+                self.check_pd_series_equal(sgpd_result, gpd_result)
+
+    def test_geom_equals(self):
+        for geom, geom2 in self.pairs:
+            sgpd_result = GeoSeries(geom).geom_equals(GeoSeries(geom2), 
align=True)
+            gpd_result = gpd.GeoSeries(geom).geom_equals(
+                gpd.GeoSeries(geom2), align=True
+            )
+            self.check_pd_series_equal(sgpd_result, gpd_result)
+
+            if len(geom) == len(geom2):
+                sgpd_result = GeoSeries(geom).geom_equals(GeoSeries(geom2), 
align=False)
+                gpd_result = gpd.GeoSeries(geom).geom_equals(
+                    gpd.GeoSeries(geom2), align=False
+                )
+                self.check_pd_series_equal(sgpd_result, gpd_result)
+
+    def test_interpolate(self):
+        for geom in [self.linestrings, self.linearrings]:
+            sgpd_result = GeoSeries(geom).interpolate(1.0)
+            gpd_result = gpd.GeoSeries(geom).interpolate(1.0)
+            self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
+            sgpd_result = GeoSeries(geom).interpolate(0.5, normalized=True)
+            gpd_result = gpd.GeoSeries(geom).interpolate(0.5, normalized=True)
+            self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
+    def test_project(self):
+        for geom in [self.linestrings, self.linearrings]:
+            point = Point(1, 1)
+            sgpd_result = GeoSeries(geom).project(point)
+            gpd_result = gpd.GeoSeries(geom).project(point)
+            self.check_pd_series_equal(sgpd_result, gpd_result)
+
+            sgpd_result = GeoSeries(geom).project(point, normalized=True)
+            gpd_result = gpd.GeoSeries(geom).project(point, normalized=True)
+            self.check_pd_series_equal(sgpd_result, gpd_result)
+
     def test_set_crs(self):
         for geom in self.geoms:
             if isinstance(geom[0], Polygon) and geom[0] == Polygon():
diff --git 
a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java 
b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
index f9f56101a9..dafdc8fc97 100644
--- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
+++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
@@ -638,7 +638,7 @@ public class UDFs {
   }
 
   @UDFAnnotations.ParamMeta(argNames = {"geom", "point"})
-  public static double ST_LineLocatePoint(byte[] geom, byte[] point) {
+  public static Double ST_LineLocatePoint(byte[] geom, byte[] point) {
     return Functions.lineLocatePoint(
         GeometrySerde.deserialize(geom), GeometrySerde.deserialize(point));
   }
diff --git 
a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java 
b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
index 4861fce4ce..80fbb34d07 100644
--- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
+++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
@@ -812,7 +812,7 @@ public class UDFsV2 {
   @UDFAnnotations.ParamMeta(
       argNames = {"geom", "point"},
       argTypes = {"Geometry", "Geometry"})
-  public static double ST_LineLocatePoint(String geom, String point) {
+  public static Double ST_LineLocatePoint(String geom, String point) {
     return Functions.lineLocatePoint(
         GeometrySerde.deserGeoJson(geom), GeometrySerde.deserGeoJson(point));
   }
diff --git 
a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala 
b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala
index 4c83046cec..377e598b48 100644
--- a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala
+++ b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala
@@ -2497,6 +2497,26 @@ class functionTestScala
     assert(result(3).get(0).asInstanceOf[Double] == 1.0)
   }
 
+  it("Should return null for ST_LineLocatePoint with empty geometry") {
+    val df = sparkSession.sql(
+      "SELECT ST_LineLocatePoint(ST_GeomFromWKT('LINESTRING EMPTY'), 
ST_GeomFromWKT('POINT(1 1)')) AS loc")
+    assert(df.take(1)(0).isNullAt(0))
+  }
+
+  it("Should return null for ST_LineLocatePoint with empty point") {
+    val df = sparkSession.sql(
+      "SELECT ST_LineLocatePoint(ST_GeomFromWKT('LINESTRING(0 0, 1 1)'), 
ST_GeomFromWKT('POINT EMPTY')) AS loc")
+    assert(df.take(1)(0).isNullAt(0))
+  }
+
+  it("Should return POINT EMPTY for ST_LineInterpolatePoint with empty 
geometry") {
+    val df = sparkSession.sql(
+      "SELECT ST_LineInterpolatePoint(ST_GeomFromWKT('LINESTRING EMPTY'), 0.5) 
AS pt")
+    val geom = df.take(1)(0).get(0).asInstanceOf[Geometry]
+    assert(geom.isEmpty)
+    assert(geom.getGeometryType == "Point")
+  }
+
   it("Should pass ST_Multi") {
     val df = sparkSession.sql("select ST_Astext(ST_Multi(ST_Point(1.0,1.0)))")
     val result = df.collect()
@@ -3284,6 +3304,10 @@ class functionTestScala
       val expected = expectedResult
       assertEquals(expected, actual, 1e-9)
     }
+    // Empty geometries should return null
+    val dfEmpty = sparkSession.sql(
+      "SELECT ST_FrechetDistance(ST_GeomFromWKT('LINESTRING (0 0, 1 0)'), 
ST_GeomFromWKT('POINT EMPTY'))")
+    assert(dfEmpty.take(1)(0).isNullAt(0))
   }
 
   it("should pass ST_Affine") {

Reply via email to