Skip to content

Commit 9cddeab

Browse files
authored
[GH-2230] Add GeoSeries.shortest_line and GeoSeries.offset_curve (#2828)
1 parent fbf447f commit 9cddeab

4 files changed

Lines changed: 227 additions & 6 deletions

File tree

python/sedona/spark/geopandas/base.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -994,8 +994,50 @@ def extract_unique_points(self):
994994
"""
995995
return _delegate_to_geometry_column("extract_unique_points", self)
996996

997-
# def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
998-
# raise NotImplementedError("This method is not implemented yet.")
997+
def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
998+
"""Returns a line at a given offset distance from each linear geometry.
999+
1000+
Positive distance offsets to the left, negative to the right.
1001+
1002+
Parameters
1003+
----------
1004+
distance : float
1005+
The offset distance. Positive offsets to the left, negative to the
1006+
right.
1007+
quad_segs : int, default 8
1008+
Number of segments to approximate a quarter circle.
1009+
join_style : str, default "round"
1010+
Accepted values are "round", "mitre", and "bevel".
1011+
1012+
.. note::
1013+
``join_style`` and ``mitre_limit`` are accepted for API
1014+
compatibility but are currently ignored by Sedona's
1015+
``ST_OffsetCurve``.
1016+
1017+
mitre_limit : float, default 5.0
1018+
Limit on the mitre ratio.
1019+
1020+
Returns
1021+
-------
1022+
GeoSeries
1023+
1024+
Examples
1025+
--------
1026+
>>> from sedona.spark.geopandas import GeoSeries
1027+
>>> from shapely.geometry import LineString
1028+
>>> s = GeoSeries(
1029+
... [
1030+
... LineString([(0, 0), (10, 0)]),
1031+
... ]
1032+
... )
1033+
>>> s.offset_curve(1.0)
1034+
0 LINESTRING (0 1, 10 1)
1035+
dtype: geometry
1036+
1037+
"""
1038+
return _delegate_to_geometry_column(
1039+
"offset_curve", self, distance, quad_segs, join_style, mitre_limit
1040+
)
9991041

10001042
# @property
10011043
# def interiors(self):
@@ -2822,6 +2864,51 @@ def intersection(self, other, align=None):
28222864
"""
28232865
return _delegate_to_geometry_column("intersection", self, other, align)
28242866

2867+
def shortest_line(self, other, align=None):
2868+
"""Returns the shortest line between each geometry in the ``GeoSeries``
2869+
and `other`.
2870+
2871+
The resulting line starts on this geometry and ends on `other`.
2872+
2873+
The operation works on a 1-to-1 row-wise manner:
2874+
2875+
Parameters
2876+
----------
2877+
other : GeoSeries or geometric object
2878+
The GeoSeries (elementwise) or geometric object to find the
2879+
shortest line to.
2880+
align : bool | None (default None)
2881+
If True, automatically aligns GeoSeries based on their indices. None defaults to True.
2882+
If False, the order of elements is preserved.
2883+
2884+
Returns
2885+
-------
2886+
GeoSeries
2887+
2888+
Examples
2889+
--------
2890+
>>> from sedona.spark.geopandas import GeoSeries
2891+
>>> from shapely.geometry import Point, LineString
2892+
>>> s1 = GeoSeries(
2893+
... [
2894+
... Point(0, 0),
2895+
... LineString([(0, 0), (1, 0)]),
2896+
... ]
2897+
... )
2898+
>>> s2 = GeoSeries(
2899+
... [
2900+
... Point(1, 1),
2901+
... Point(0, 1),
2902+
... ]
2903+
... )
2904+
>>> s1.shortest_line(s2, align=False)
2905+
0 LINESTRING (0 0, 1 1)
2906+
1 LINESTRING (0 0, 0 1)
2907+
dtype: geometry
2908+
2909+
"""
2910+
return _delegate_to_geometry_column("shortest_line", self, other, align)
2911+
28252912
def snap(self, other, tolerance, align=None):
28262913
"""Snap the vertices and segments of the geometry to vertices of the reference.
28272914

python/sedona/spark/geopandas/geoseries.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,8 +1058,17 @@ def extract_unique_points(self):
10581058
)
10591059

10601060
def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
1061-
# Implementation of the abstract method.
1062-
raise NotImplementedError("This method is not implemented yet.")
1061+
# ST_OffsetCurve returns null for empty geometries, but GeoPandas returns LINESTRING EMPTY.
1062+
# Preserve the input's SRID on the empty fallback so CRS is not silently dropped.
1063+
empty_line = stf.ST_SetSRID(
1064+
stc.ST_GeomFromText(F.lit("LINESTRING EMPTY")),
1065+
stf.ST_SRID(self.spark.column),
1066+
)
1067+
spark_col = F.when(
1068+
stf.ST_IsEmpty(self.spark.column),
1069+
empty_line,
1070+
).otherwise(stf.ST_OffsetCurve(self.spark.column, distance, quad_segs))
1071+
return self._query_geometry_column(spark_col, returns_geom=True)
10631072

10641073
@property
10651074
def interiors(self):
@@ -1528,6 +1537,18 @@ def intersection(
15281537
)
15291538
return result
15301539

1540+
def shortest_line(self, other, align=None) -> "GeoSeries":
1541+
other_series, extended = self._make_series_of_val(other)
1542+
align = False if extended else align
1543+
1544+
spark_expr = stf.ST_ShortestLine(F.col("L"), F.col("R"))
1545+
return self._row_wise_operation(
1546+
spark_expr,
1547+
other_series,
1548+
align=align,
1549+
returns_geom=True,
1550+
)
1551+
15311552
def snap(self, other, tolerance, align=None) -> "GeoSeries":
15321553
if not isinstance(tolerance, (float, int)):
15331554
raise NotImplementedError(

python/tests/geopandas/test_geoseries.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1544,7 +1544,41 @@ def test_extract_unique_points(self):
15441544
self.check_sgpd_equals_gpd(df_result, expected)
15451545

15461546
def test_offset_curve(self):
1547-
pass
1547+
s = GeoSeries(
1548+
[
1549+
LineString([(0, 0), (0, 1), (1, 1)]),
1550+
LineString([(0, 0), (10, 0)]),
1551+
]
1552+
)
1553+
1554+
result = s.offset_curve(1.0)
1555+
expected = gpd.GeoSeries(
1556+
[
1557+
LineString([(0, 0), (0, 1), (1, 1)]),
1558+
LineString([(0, 0), (10, 0)]),
1559+
]
1560+
).offset_curve(1.0)
1561+
self.check_sgpd_equals_gpd(result, expected)
1562+
1563+
# Negative distance (right side)
1564+
result = s.offset_curve(-1.0)
1565+
expected = gpd.GeoSeries(
1566+
[
1567+
LineString([(0, 0), (0, 1), (1, 1)]),
1568+
LineString([(0, 0), (10, 0)]),
1569+
]
1570+
).offset_curve(-1.0)
1571+
self.check_sgpd_equals_gpd(result, expected)
1572+
1573+
# Check that GeoDataFrame works too
1574+
df_result = s.to_geoframe().offset_curve(1.0)
1575+
expected = gpd.GeoSeries(
1576+
[
1577+
LineString([(0, 0), (0, 1), (1, 1)]),
1578+
LineString([(0, 0), (10, 0)]),
1579+
]
1580+
).offset_curve(1.0)
1581+
self.check_sgpd_equals_gpd(df_result, expected)
15481582

15491583
def test_interiors(self):
15501584
pass
@@ -2599,6 +2633,54 @@ def test_snap(self):
25992633
df_result = s.to_geoframe().snap(s2, tolerance=1, align=False)
26002634
self.check_sgpd_equals_gpd(df_result, expected)
26012635

2636+
def test_shortest_line(self):
2637+
s1 = GeoSeries(
2638+
[
2639+
Point(0, 0),
2640+
LineString([(0, 0), (1, 0)]),
2641+
Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
2642+
]
2643+
)
2644+
s2 = GeoSeries(
2645+
[
2646+
Point(1, 1),
2647+
Point(0, 1),
2648+
Point(2, 2),
2649+
]
2650+
)
2651+
2652+
result = s1.shortest_line(s2, align=False)
2653+
expected = gpd.GeoSeries(
2654+
[
2655+
LineString([(0, 0), (1, 1)]),
2656+
LineString([(0, 0), (0, 1)]),
2657+
LineString([(1, 1), (2, 2)]),
2658+
]
2659+
)
2660+
self.check_sgpd_equals_gpd(result, expected)
2661+
2662+
# Test with single geometry
2663+
result = s1.shortest_line(Point(1, 1))
2664+
expected = gpd.GeoSeries(
2665+
[
2666+
LineString([(0, 0), (1, 1)]),
2667+
LineString([(1, 0), (1, 1)]),
2668+
LineString([(1, 1), (1, 1)]),
2669+
]
2670+
)
2671+
self.check_sgpd_equals_gpd(result, expected)
2672+
2673+
# Test that GeoDataFrame works too
2674+
df_result = s1.to_geoframe().shortest_line(s2, align=False)
2675+
expected = gpd.GeoSeries(
2676+
[
2677+
LineString([(0, 0), (1, 1)]),
2678+
LineString([(0, 0), (0, 1)]),
2679+
LineString([(1, 1), (2, 2)]),
2680+
]
2681+
)
2682+
self.check_sgpd_equals_gpd(df_result, expected)
2683+
26022684
def test_intersection_all(self):
26032685
s = GeoSeries([box(0, 0, 2, 2), box(1, 1, 3, 3)])
26042686
result = s.intersection_all()

python/tests/geopandas/test_match_geopandas_series.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,21 @@ def test_extract_unique_points(self):
832832
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
833833

834834
def test_offset_curve(self):
835-
pass
835+
for geom in self.geoms:
836+
# offset_curve only works on linear geometries
837+
if not all(
838+
isinstance(g, (LineString, LinearRing, MultiLineString))
839+
for g in geom
840+
if not g.is_empty
841+
):
842+
continue
843+
sgpd_result = GeoSeries(geom).offset_curve(1.0)
844+
gpd_result = gpd.GeoSeries(geom).offset_curve(1.0)
845+
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
846+
847+
sgpd_result = GeoSeries(geom).offset_curve(-0.5)
848+
gpd_result = gpd.GeoSeries(geom).offset_curve(-0.5)
849+
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
836850

837851
def test_interiors(self):
838852
pass
@@ -1188,6 +1202,23 @@ def test_snap(self):
11881202
)
11891203
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
11901204

1205+
def test_shortest_line(self):
1206+
for geom, geom2 in self.pairs:
1207+
if self.contains_any_geom_collection(geom, geom2):
1208+
continue
1209+
sgpd_result = GeoSeries(geom).shortest_line(GeoSeries(geom2))
1210+
gpd_result = gpd.GeoSeries(geom).shortest_line(gpd.GeoSeries(geom2))
1211+
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
1212+
1213+
if len(geom) == len(geom2):
1214+
sgpd_result = GeoSeries(geom).shortest_line(
1215+
GeoSeries(geom2), align=False
1216+
)
1217+
gpd_result = gpd.GeoSeries(geom).shortest_line(
1218+
gpd.GeoSeries(geom2), align=False
1219+
)
1220+
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
1221+
11911222
def test_intersection_all(self):
11921223
pass
11931224

0 commit comments

Comments
 (0)