Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions python/sedona/spark/geopandas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,52 @@ def scale(self, xfact=1.0, yfact=1.0, zfact=1.0, origin="center"):
"""
return _delegate_to_geometry_column("scale", self, xfact, yfact, zfact, origin)

def skew(self, xs=0.0, ys=0.0, origin="center", use_radians=False):
"""Return a ``GeoSeries`` with skewed geometries.

Each geometry is sheared independently along its x and y dimensions.
Negative angles shear in the opposite direction.

Parameters
----------
xs : float, default 0.0
Shear angle for the x dimension, in degrees by default.
ys : float, default 0.0
Shear angle for the y dimension, in degrees by default.
origin : {"center", "centroid"}, Point, or tuple, default "center"
The skew origin. ``"center"`` uses each geometry's bounding-box
center and ``"centroid"`` uses each geometry's centroid. A 2D or
3D Shapely Point or coordinate tuple may also be supplied. Skew is
a 2D operation, so an explicit origin's z coordinate is ignored.
use_radians : bool, default False
If True, interpret ``xs`` and ``ys`` as radians instead of degrees.

Returns
-------
GeoSeries
The skewed geometries.

Notes
-----
Existing z coordinates are preserved. Results for mixed 2D/3D
``GeometryCollection`` objects, M or ZM ordinates, NaN z coordinates,
non-finite angles or origin coordinates, and angles near 90 degrees
may differ from GeoPandas because Sedona uses JTS while GeoPandas uses
Shapely. This method applies Sedona's distributed semantics and does
not materialize geometries locally to emulate Shapely.

Examples
--------
>>> from shapely.geometry import Point
>>> from sedona.spark.geopandas import GeoSeries
>>> s = GeoSeries([Point(1, 2), Point(-1, -2)])
>>> s.skew(xs=45, origin=(0, 0))
0 POINT (3 2)
1 POINT (-3 -2)
dtype: geometry
"""
return _delegate_to_geometry_column("skew", self, xs, ys, origin, use_radians)

def force_2d(self):
"""Force the dimensionality of a geometry to 2D.

Expand Down
191 changes: 109 additions & 82 deletions python/sedona/spark/geopandas/geoseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,70 @@
}


def _normalize_affine_scalar(value, error_message: str) -> float:
"""Normalize an operation-wide affine parameter to a Python float."""
if (
value is None
or isinstance(value, (str, bytes, bytearray))
or not np.isscalar(value)
):
raise TypeError(error_message)
try:
return float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise TypeError(error_message) from exc


def _interpret_origin(geometry: PySparkColumn, origin, with_z: bool):
"""Resolve a local affinity origin to distributed coordinate columns."""
if isinstance(origin, str):
if origin == "center":
origin_geometry = stf.ST_Centroid(stf.ST_Envelope(geometry))
elif origin == "centroid":
origin_geometry = stf.ST_Centroid(geometry)
else:
raise ValueError(
"origin must be 'center', 'centroid', a Point, or a "
f"two- or three-element tuple, got {origin!r}"
)
origin_columns = (
stf.ST_X(origin_geometry),
stf.ST_Y(origin_geometry),
F.lit(0.0),
)
elif isinstance(origin, tuple):
if len(origin) not in (2, 3):
raise ValueError("'origin' tuple must contain two or three coordinates")
coordinates = [
_normalize_affine_scalar(
coordinate,
"'origin' tuple must contain only numeric coordinates",
)
for coordinate in origin
]
if len(coordinates) == 2:
coordinates.append(0.0)
origin_columns = tuple(F.lit(value) for value in coordinates)
elif isinstance(origin, shapely.geometry.Point):
if origin.is_empty:
raise ValueError("'origin' Point must be a non-empty 2D or 3D Point")
coordinates = tuple(origin.coords[0])
if len(coordinates) not in (2, 3) or (
len(coordinates) == 3 and not origin.has_z
):
raise ValueError("'origin' Point must be a non-empty 2D or 3D Point")
if len(coordinates) == 2:
coordinates += (0.0,)
origin_columns = tuple(F.lit(float(value)) for value in coordinates)
else:
raise TypeError(
"origin must be 'center', 'centroid', a Point, or a "
"two- or three-element tuple"
)

return origin_columns if with_z else origin_columns[:2]


def _not_implemented_error(method_name: str, additional_info: str = "") -> str:
"""
Generate a standardized NotImplementedError message.
Expand Down Expand Up @@ -1191,20 +1255,12 @@ def affine_transform(self, matrix) -> "GeoSeries":
if len(matrix) not in (6, 12):
raise ValueError("'matrix' expects either 6 or 12 coefficients")

coefficients = []
for coefficient in matrix:
if (
coefficient is None
or isinstance(coefficient, (str, bytes, bytearray))
or not np.isscalar(coefficient)
):
raise TypeError("'matrix' must contain only numeric coefficients")
try:
coefficients.append(float(coefficient))
except (TypeError, ValueError, OverflowError) as exc:
raise TypeError(
"'matrix' must contain only numeric coefficients"
) from exc
coefficients = [
_normalize_affine_scalar(
coefficient, "'matrix' must contain only numeric coefficients"
)
for coefficient in matrix
]

if len(coefficients) == 6:
a, b, d, e, xoff, yoff = coefficients
Expand Down Expand Up @@ -1266,76 +1322,12 @@ def rotate(self, angle, origin="center", use_radians=False) -> "GeoSeries":
return self._query_geometry_column(spark_expr, returns_geom=True)

def scale(self, xfact=1.0, yfact=1.0, zfact=1.0, origin="center") -> "GeoSeries":
def normalize_factor(value, name):
if (
value is None
or isinstance(value, (str, bytes, bytearray))
or not np.isscalar(value)
):
raise TypeError(f"'{name}' must be a numeric scalar")
try:
return float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise TypeError(f"'{name}' must be a numeric scalar") from exc

xfact = normalize_factor(xfact, "xfact")
yfact = normalize_factor(yfact, "yfact")
zfact = normalize_factor(zfact, "zfact")
xfact = _normalize_affine_scalar(xfact, "'xfact' must be a numeric scalar")
yfact = _normalize_affine_scalar(yfact, "'yfact' must be a numeric scalar")
zfact = _normalize_affine_scalar(zfact, "'zfact' must be a numeric scalar")

geometry = self.spark.column
if isinstance(origin, str):
if origin == "center":
origin_geometry = stf.ST_Centroid(stf.ST_Envelope(geometry))
elif origin == "centroid":
origin_geometry = stf.ST_Centroid(geometry)
else:
raise ValueError(
"origin must be 'center', 'centroid', a Point, or a "
f"two- or three-element tuple, got {origin!r}"
)
origin_x = stf.ST_X(origin_geometry)
origin_y = stf.ST_Y(origin_geometry)
origin_z = F.lit(0.0)
elif isinstance(origin, tuple):
if len(origin) not in (2, 3):
raise ValueError("'origin' tuple must contain two or three coordinates")
coordinates = []
for coordinate in origin:
if (
coordinate is None
or isinstance(coordinate, (str, bytes, bytearray))
or not np.isscalar(coordinate)
):
raise TypeError(
"'origin' tuple must contain only numeric coordinates"
)
try:
coordinates.append(float(coordinate))
except (TypeError, ValueError, OverflowError) as exc:
raise TypeError(
"'origin' tuple must contain only numeric coordinates"
) from exc
if len(coordinates) == 2:
coordinates.append(0.0)
origin_x, origin_y, origin_z = (F.lit(value) for value in coordinates)
elif isinstance(origin, shapely.geometry.Point):
if origin.is_empty:
raise ValueError("'origin' Point must be a non-empty 2D or 3D Point")
coordinates = tuple(origin.coords[0])
if len(coordinates) not in (2, 3) or (
len(coordinates) == 3 and not origin.has_z
):
raise ValueError("'origin' Point must be a non-empty 2D or 3D Point")
if len(coordinates) == 2:
coordinates += (0.0,)
origin_x, origin_y, origin_z = (
F.lit(float(value)) for value in coordinates
)
else:
raise TypeError(
"origin must be 'center', 'centroid', a Point, or a "
"two- or three-element tuple"
)
origin_x, origin_y, origin_z = _interpret_origin(geometry, origin, with_z=True)

xoff = origin_x - origin_x * F.lit(xfact)
yoff = origin_y - origin_y * F.lit(yfact)
Expand All @@ -1362,6 +1354,41 @@ def normalize_factor(value, name):
spark_expr = F.when(stf.ST_IsEmpty(geometry), geometry).otherwise(scaled)
return self._query_geometry_column(spark_expr, returns_geom=True)

def skew(self, xs=0.0, ys=0.0, origin="center", use_radians=False) -> "GeoSeries":
import math

xs = _normalize_affine_scalar(xs, "'xs' must be a numeric scalar")
ys = _normalize_affine_scalar(ys, "'ys' must be a numeric scalar")
if not isinstance(use_radians, (bool, np.bool_)):
raise TypeError("'use_radians' must be a boolean")

if not use_radians:
xs = xs * math.pi / 180.0
ys = ys * math.pi / 180.0
tan_x = math.tan(xs)
tan_y = math.tan(ys)
if abs(tan_x) < 2.5e-16:
tan_x = 0.0
if abs(tan_y) < 2.5e-16:
tan_y = 0.0

geometry = self.spark.column
origin_x, origin_y = _interpret_origin(geometry, origin, with_z=False)

xoff = -origin_y * F.lit(tan_x)
yoff = -origin_x * F.lit(tan_y)
skewed = stf.ST_Affine(
geometry,
1.0,
tan_x,
tan_y,
1.0,
xoff,
yoff,
)
spark_expr = F.when(stf.ST_IsEmpty(geometry), geometry).otherwise(skewed)
return self._query_geometry_column(spark_expr, returns_geom=True)

def force_2d(self) -> "GeoSeries":
spark_expr = stf.ST_Force_2D(self.spark.column)
return self._query_geometry_column(spark_expr, returns_geom=True)
Expand Down
Loading
Loading