From 4db3b1e0c80211362db19f7401d0b3a7700a1ea6 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Tue, 14 May 2019 12:46:38 +0200 Subject: [PATCH] REF: move element-wise geo ops to array class (#993) --- geopandas/array.py | 382 +++++++++++++++++++++++++++ geopandas/base.py | 241 ++++++----------- geopandas/geoseries.py | 15 +- geopandas/tests/test_geom_methods.py | 15 +- 4 files changed, 473 insertions(+), 180 deletions(-) create mode 100644 geopandas/array.py diff --git a/geopandas/array.py b/geopandas/array.py new file mode 100644 index 0000000..07d9ef2 --- /dev/null +++ b/geopandas/array.py @@ -0,0 +1,382 @@ +import warnings + +import numpy as np + +from shapely.geometry.base import BaseGeometry +from shapely.ops import unary_union + +import shapely.affinity + + +def _binary_geo(op, left, right): + # type: (str, GeometryArray, [GeometryArray/BaseGeometry]) -> GeometryArray + """ Apply geometry-valued operation + + Supports: + + - difference + - symmetric_difference + - intersection + - union + + Parameters + ---------- + op: string + right: GeometryArray or single shapely BaseGeoemtry + """ + if isinstance(right, BaseGeometry): + # intersection can return empty GeometryCollections, and if the + # result are only those, numpy will coerce it to empty 2D array + data = np.empty(len(left), dtype=object) + data[:] = [getattr(s, op)(right) for s in left.data] + return GeometryArray(data) + elif isinstance(right, GeometryArray): + if len(left) != len(right): + msg = ( + "Lengths of inputs to not match. " + "Left: {0}, Right: {1}".format(len(left), len(right))) + raise ValueError(msg) + data = np.empty(len(left), dtype=object) + data[:] = [getattr(this_elem, op)(other_elem) + for this_elem, other_elem in zip(left.data, right.data)] + return GeometryArray(data) + else: + raise TypeError( + "Type not known: {0} vs {1}".format(type(left), type(right))) + + +def _binary_op(op, left, right, *args, **kwargs): + # type: (str, GeometryArray, GeometryArray/BaseGeometry, args/kwargs) + # -> array + """Binary operation on GeometryArray that returns a ndarray""" + if op in ['distance', 'project']: + null_value = np.nan + elif op == 'relate': + null_value = None + else: + null_value = False + if op in ['distance', 'project']: + dtype = float + elif op == 'relate': + dtype = object + else: + dtype = bool + + if isinstance(right, BaseGeometry): + data = [ + getattr(s, op)(right, *args, **kwargs) if s else null_value + for s in left.data] + return np.array(data, dtype=dtype) + elif isinstance(right, GeometryArray): + if len(left) != len(right): + msg = ( + "Lengths of inputs to not match. " + "Left: {0}, Right: {1}".format(len(left), len(right))) + raise ValueError(msg) + data = [ + getattr(this_elem, op)(other_elem, *args, **kwargs) + if not this_elem.is_empty | other_elem.is_empty else null_value + for this_elem, other_elem in zip(left.data, right.data)] + return np.array(data, dtype=dtype) + else: + raise TypeError( + "Type not known: {0} vs {1}".format(type(left), type(right))) + + +def _unary_geo(op, left, *args, **kwargs): + # type: (str, GeometryArray) -> GeometryArray + """Unary operation that returns new geometries""" + # ensure 1D output, see note above + data = np.empty(len(left), dtype=object) + data[:] = [getattr(geom, op) for geom in left.data] + return GeometryArray(data) + + +def _unary_op(op, left, null_value=False): + # type: (str, GeometryArray, Any) -> array + """Unary operation that returns a Series""" + data = [getattr(geom, op, null_value) for geom in left.data] + return np.array(data, dtype=np.dtype(type(null_value))) + + +def _affinity_method(op, left, *args, **kwargs): + # type: (str, GeometryArray, ...) -> GeometryArray + data = [getattr(shapely.affinity, op)(s, *args, **kwargs) + for s in left.data] + return GeometryArray(np.array(data, dtype=object)) + + +class GeometryArray: + """ + Class wrapping a numpy array of Shapely objects and + holding the array-based implementations. + """ + + def __init__(self, data): + if isinstance(data, self.__class__): + data = data.data + elif not isinstance(data, np.ndarray): + raise ValueError( + "'data' should be array of geometry objects. Use from_shapely," + " from_wkb, from_wkt functions to construct a GeometryArray.") + elif not data.ndim == 1: + raise ValueError( + "'data' should be a 1-dimensional array of geometry objects.") + self.data = data + + def __len__(self): + return len(self.data) + + # ------------------------------------------------------------------------- + # Geometry related methods + # ------------------------------------------------------------------------- + + @property + def is_valid(self): + return _unary_op('is_valid', self, null_value=False) + + @property + def is_empty(self): + return _unary_op('is_empty', self, null_value=False) + + @property + def is_simple(self): + return _unary_op('is_simple', self, null_value=False) + + @property + def is_ring(self): + # operates on the exterior, so can't use _unary_op() + return np.array( + [geom.exterior.is_ring for geom in self.data], dtype=bool) + + @property + def is_closed(self): + return _unary_op('is_closed', self, null_value=False) + + @property + def has_z(self): + return _unary_op('has_z', self, null_value=False) + + @property + def geom_type(self): + return _unary_op('geom_type', self, null_value=None) + + @property + def area(self): + return _unary_op('area', self, null_value=np.nan) + + @property + def length(self): + return _unary_op('length', self, null_value=np.nan) + + # + # Unary operations that return new geometries + # + + @property + def boundary(self): + return _unary_geo('boundary', self) + + @property + def centroid(self): + return _unary_geo('centroid', self) + + @property + def convex_hull(self): + return _unary_geo('convex_hull', self) + + @property + def envelope(self): + return _unary_geo('envelope', self) + + @property + def exterior(self): + return _unary_geo('exterior', self) + + @property + def interiors(self): + has_non_poly = False + inner_rings = [] + for geom in self.data: + interior_ring_seq = getattr(geom, 'interiors', None) + # polygon case + if interior_ring_seq is not None: + inner_rings.append(list(interior_ring_seq)) + # non-polygon case + else: + has_non_poly = True + inner_rings.append(None) + if has_non_poly: + warnings.warn( + "Only Polygon objects have interior rings. For other " + "geometry types, None is returned.") + + return np.array(inner_rings, dtype=object) + + def representative_point(self): + # method and not a property -> can't use _unary_geo + data = np.empty(len(self), dtype=object) + data[:] = [geom.representative_point() for geom in self.data] + return GeometryArray(data) + + # + # Binary predicates + # + + def covers(self, other): + return _binary_op('covers', self, other) + + def contains(self, other): + return _binary_op('contains', self, other) + + def crosses(self, other): + return _binary_op('crosses', self, other) + + def disjoint(self, other): + return _binary_op('disjoint', self, other) + + def equals(self, other): + return _binary_op('equals', self, other) + + def intersects(self, other): + return _binary_op('intersects', self, other) + + def overlaps(self, other): + return _binary_op('overlaps', self, other) + + def touches(self, other): + return _binary_op('touches', self, other) + + def within(self, other): + return _binary_op('within', self, other) + + def equals_exact(self, other, tolerance): + return _binary_op('equals_exact', self, other, tolerance=tolerance) + + def almost_equals(self, other, decimal): + return _binary_op('almost_equals', self, other, decimal=decimal) + + # + # Binary operations that return new geometries + # + + def difference(self, other): + return _binary_geo('difference', self, other) + + def intersection(self, other): + return _binary_geo('intersection', self, other) + + def symmetric_difference(self, other): + return _binary_geo('symmetric_difference', self, other) + + def union(self, other): + return _binary_geo('union', self, other) + + # + # Other operations + # + + def distance(self, other): + return _binary_op('distance', self, other) + + def buffer(self, distance, resolution=16, **kwargs): + if isinstance(distance, np.ndarray): + if len(distance) != len(self): + raise ValueError("Length of distance sequence does not match " + "length of the GeoSeries") + data = [ + geom.buffer(dist, resolution, **kwargs) + for geom, dist in zip(self.data, distance)] + return GeometryArray(np.array(data, dtype=object)) + + data = [geom.buffer(distance, resolution, **kwargs) + for geom in self.data] + return GeometryArray(np.array(data, dtype=object)) + + def interpolate(self, distance, normalized=False): + if isinstance(distance, np.ndarray): + if len(distance) != len(self): + raise ValueError("Length of distance sequence does not match " + "length of the GeoSeries") + data = [ + geom.interpolate(dist, normalized=normalized) + for geom, dist in zip(self.data, distance)] + return GeometryArray(np.array(data, dtype=object)) + + data = [geom.interpolate(distance, normalized=normalized) + for geom in self.data] + return GeometryArray(np.array(data, dtype=object)) + + def simplify(self, *args, **kwargs): + # method and not a property -> can't use _unary_geo + data = np.empty(len(self), dtype=object) + data[:] = [geom.simplify(*args, **kwargs) for geom in self.data] + return GeometryArray(data) + + def project(self, other, normalized=False): + return _binary_op('project', self, other, normalized=normalized) + + def relate(self, other): + return _binary_op('relate', self, other) + + # + # Reduction operations that return a Shapely geometry + # + + def unary_union(self): + return unary_union(self.data) + + # + # Affinity operations + # + + def translate(self, xoff=0.0, yoff=0.0, zoff=0.0): + return _affinity_method('translate', self, xoff, yoff, zoff) + + def rotate(self, angle, origin='center', use_radians=False): + return _affinity_method('rotate', self, angle, origin=origin, + use_radians=use_radians) + + def scale(self, xfact=1.0, yfact=1.0, zfact=1.0, origin='center'): + return _affinity_method( + 'scale', self, xfact, yfact, zfact, origin=origin) + + def skew(self, xs=0.0, ys=0.0, origin='center', use_radians=False): + return _affinity_method('skew', self, xs, ys, origin=origin, + use_radians=use_radians) + + # + # Coordinate related properties + # + + @property + def x(self): + """Return the x location of point geometries in a GeoSeries""" + if (self.geom_type == "Point").all(): + return _unary_op('x', self, null_value=np.nan) + else: + message = "x attribute access only provided for Point geometries" + raise ValueError(message) + + @property + def y(self): + """Return the y location of point geometries in a GeoSeries""" + if (self.geom_type == "Point").all(): + return _unary_op('y', self, null_value=np.nan) + else: + message = "y attribute access only provided for Point geometries" + raise ValueError(message) + + @property + def bounds(self): + # TODO fix for empty / missing geometries + bounds = np.array([geom.bounds for geom in self.data]) + return bounds + + @property + def total_bounds(self): + b = self.bounds + return np.array((b[:, 0].min(), # minx + b[:, 1].min(), # miny + b[:, 2].max(), # maxx + b[:, 3].max())) # maxy diff --git a/geopandas/base.py b/geopandas/base.py index 8c511be..93d241d 100644 --- a/geopandas/base.py +++ b/geopandas/base.py @@ -12,6 +12,8 @@ import shapely.affinity as affinity import geopandas as gpd +from .array import GeometryArray + try: from rtree.core import RTreeError @@ -22,91 +24,62 @@ except ImportError: HAS_SINDEX = False -def _binary_geo(op, left, right): +def _delegate_binary_method(op, this, other, *args, **kwargs): + # type: (str, GeoSeries, GeoSeries) -> GeoSeries/Series + this = this.geometry + if isinstance(other, GeoPandasBase): + this, other = this.align(other.geometry) + + # TODO reenable for all operations once we use pyproj > 2 + # if this.crs != other.crs: + # warn('GeoSeries crs mismatch: {0} and {1}'.format(this.crs, + # other.crs)) + a_this = GeometryArray(this.values) + other = GeometryArray(other.values) + elif isinstance(other, BaseGeometry): + a_this = GeometryArray(this.values) + else: + raise TypeError(type(this), type(other)) + + data = getattr(a_this, op)(other, *args, **kwargs) + return data, this.index + + +def _binary_geo(op, this, other): # type: (str, GeoSeries, GeoSeries) -> GeoSeries """Binary operation on GeoSeries objects that returns a GeoSeries""" from .geoseries import GeoSeries - if isinstance(right, GeoPandasBase): - left = left.geometry - left, right = left.align(right.geometry) - - if left.crs != right.crs: - warn('GeoSeries crs mismatch: {0} and {1}'.format(left.crs, - right.crs)) - - # intersection can return empty GeometryCollections, and if the result - # are only those, numpy will coerce it to empty 2D array - data = np.empty(len(left), dtype=object) - data[:] = [getattr(this_elem, op)(other_elem) - for this_elem, other_elem in zip(left, right)] - - return GeoSeries(data, index=left.index, crs=left.crs) - - elif isinstance(right, BaseGeometry): - # ensure 1D output, see note above - data = np.empty(len(left), dtype=object) - data[:] = [getattr(s, op)(right) for s in left.geometry] - return GeoSeries(data, index=left.index, crs=left.crs) - else: - raise TypeError(type(left), type(right)) + geoms, index = _delegate_binary_method(op, this, other) + return GeoSeries(geoms.data, index=index, crs=this.crs) def _binary_op(op, this, other, *args, **kwargs): # type: (str, GeoSeries, GeoSeries, args/kwargs) -> Series[bool] """Binary operation on GeoSeries objects that returns a Series""" - if op in ['distance', 'project']: - null_value = np.nan - elif op == 'relate': - null_value = None + data, index = _delegate_binary_method(op, this, other, *args, **kwargs) + return Series(data, index=index) + + +def _delegate_property(op, this): + # type: (str, GeoSeries) -> GeoSeries/Series + a_this = GeometryArray(this.geometry.values) + data = getattr(a_this, op) + if isinstance(data, GeometryArray): + from .geoseries import GeoSeries + return GeoSeries(data.data, index=this.index, crs=this.crs) else: - null_value = False - if op in ['distance', 'project']: - dtype = float - elif op == 'relate': - dtype = object - else: - dtype = bool - - if isinstance(other, GeoPandasBase): - - this = this.geometry - this, other = this.align(other.geometry) - - data = np.array( - [getattr(this_elem, op)(other_elem, *args, **kwargs) - if not this_elem.is_empty | other_elem.is_empty else null_value - for this_elem, other_elem in zip(this, other)], - dtype=dtype) - return Series(data, index=this.index) - elif isinstance(other, BaseGeometry): - data = np.array( - [getattr(s, op)(other, *args, **kwargs) if s else null_value - for s in this.geometry], - dtype=dtype) - return Series(data, index=this.index) - else: - raise TypeError(type(this), type(other)) - -def _unary_geo(op, this): +def _delegate_geo_method(op, this, *args, **kwargs): # type: (str, GeoSeries) -> GeoSeries """Unary operation that returns a GeoSeries""" from .geoseries import GeoSeries - # ensure 1D output, see note above - data = np.empty(len(this), dtype=object) - data[:] = [getattr(geom, op) for geom in this.geometry] + a_this = GeometryArray(this.geometry.values) + data = getattr(a_this, op)(*args, **kwargs).data return GeoSeries(data, index=this.index, crs=this.crs) -def _unary_op(op, this, null_value=False): - # type: (str, GeoSeries, Any) -> Series - """Unary operation that returns a Series""" - return Series([getattr(geom, op, null_value) for geom in this.geometry], - index=this.index, dtype=np.dtype(type(null_value))) - - class GeoPandasBase(object): _sindex = None _sindex_generated = False @@ -141,13 +114,13 @@ class GeoPandasBase(object): def area(self): """Returns a ``Series`` containing the area of each geometry in the ``GeoSeries``.""" - return _unary_op('area', self, null_value=np.nan) + return _delegate_property('area', self) @property def geom_type(self): """Returns a ``Series`` of strings specifying the `Geometry Type` of each object.""" - return _unary_op('geom_type', self, null_value=None) + return _delegate_property('geom_type', self) @property def type(self): @@ -157,19 +130,19 @@ class GeoPandasBase(object): @property def length(self): """Returns a ``Series`` containing the length of each geometry.""" - return _unary_op('length', self, null_value=np.nan) + return _delegate_property('length', self) @property def is_valid(self): """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for geometries that are valid.""" - return _unary_op('is_valid', self, null_value=False) + return _delegate_property('is_valid', self) @property def is_empty(self): """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for empty geometries.""" - return _unary_op('is_empty', self, null_value=False) + return _delegate_property('is_empty', self) @property def is_simple(self): @@ -178,21 +151,19 @@ class GeoPandasBase(object): This is meaningful only for `LineStrings` and `LinearRings`. """ - return _unary_op('is_simple', self, null_value=False) + return _delegate_property('is_simple', self) @property def is_ring(self): """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for features that are closed.""" - # operates on the exterior, so can't use _unary_op() - return Series([geom.exterior.is_ring for geom in self.geometry], - index=self.index) + return _delegate_property('is_ring', self) @property def has_z(self): """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for features that have a z-component.""" - return _unary_op('has_z', self, null_value=False) + return _delegate_property('has_z', self) # # Unary operations that return a GeoSeries @@ -202,13 +173,13 @@ class GeoPandasBase(object): def boundary(self): """Returns a ``GeoSeries`` of lower dimensional objects representing each geometries's set-theoretic `boundary`.""" - return _unary_geo('boundary', self) + return _delegate_property('boundary', self) @property def centroid(self): """Returns a ``GeoSeries`` of points representing the centroid of each geometry.""" - return _unary_geo('centroid', self) + return _delegate_property('centroid', self) @property def convex_hull(self): @@ -219,7 +190,7 @@ class GeoPandasBase(object): containing all the points in each geometry, unless the number of points in the geometric object is less than three. For two points, the convex hull collapses to a `LineString`; for 1, a `Point`.""" - return _unary_geo('convex_hull', self) + return _delegate_property('convex_hull', self) @property def envelope(self): @@ -229,7 +200,7 @@ class GeoPandasBase(object): The envelope of a geometry is the bounding rectangle. That is, the point or smallest rectangular polygon (with sides parallel to the coordinate axes) that contains the geometry.""" - return _unary_geo('envelope', self) + return _delegate_property('envelope', self) @property def exterior(self): @@ -239,7 +210,7 @@ class GeoPandasBase(object): Applies to GeoSeries containing only Polygons. """ # TODO: return empty geometry for non-polygons - return _unary_geo('exterior', self) + return _delegate_property('exterior', self) @property def interiors(self): @@ -253,34 +224,13 @@ class GeoPandasBase(object): inner_rings: Series of List Inner rings of each polygon in the GeoSeries. """ - - has_non_poly = False - inner_rings = [] - for geom in self.geometry: - interior_ring_seq = getattr(geom, 'interiors', None) - # polygon case - if interior_ring_seq is not None: - inner_rings.append(list(interior_ring_seq)) - # non-polygon case - else: - has_non_poly = True - inner_rings.append(None) - if has_non_poly: - warn("Only Polygon objects have interior rings. For other " - "geometry types, None is returned.") - - # _unary_op couldn't be used in order to warning to non-polygon and - # conversion to list. - return Series(inner_rings, - index=self.index, dtype=object) + return _delegate_property('interiors', self) def representative_point(self): """Returns a ``GeoSeries`` of (cheaply computed) points that are guaranteed to be within each geometry. """ - return gpd.GeoSeries([geom.representative_point() - for geom in self.geometry], - index=self.index) + return _delegate_geo_method('representative_point', self) # # Reduction operations that return a Shapely geometry @@ -521,7 +471,7 @@ class GeoPandasBase(object): See ``GeoSeries.total_bounds`` for the limits of the entire series. """ - bounds = np.array([geom.bounds for geom in self.geometry]) + bounds = GeometryArray(self.geometry.values).bounds return DataFrame(bounds, columns=['minx', 'miny', 'maxx', 'maxy'], index=self.index) @@ -534,11 +484,7 @@ class GeoPandasBase(object): See ``GeoSeries.bounds`` for the bounds of the geometries contained in the series. """ - b = self.bounds - return np.array((b['minx'].min(), - b['miny'].min(), - b['maxx'].max(), - b['maxy'].max())) + return GeometryArray(self.geometry.values).total_bounds @property def sindex(self): @@ -561,22 +507,14 @@ class GeoPandasBase(object): resolution: int Optional, the resolution of the buffer around each vertex. """ - if isinstance(distance, (np.ndarray, pd.Series)): - if len(distance) != len(self.index): - raise ValueError("Length of distance sequence does not match " - "length of the GeoSeries") - if isinstance(distance, pd.Series): - if not self.index.equals(distance.index): - raise ValueError("Index values of distance sequence does " - "not match index values of the GeoSeries") - return gpd.GeoSeries( - [geom.buffer(dist, resolution, **kwargs) - for geom, dist in zip(self.geometry, distance)], - index=self.index, crs=self.crs) + if isinstance(distance, pd.Series): + if not self.index.equals(distance.index): + raise ValueError("Index values of distance sequence does " + "not match index values of the GeoSeries") + distance = np.asarray(distance) - return gpd.GeoSeries([geom.buffer(distance, resolution, **kwargs) - for geom in self.geometry], - index=self.index, crs=self.crs) + return _delegate_geo_method('buffer', self, distance, + resolution=resolution, **kwargs) def simplify(self, *args, **kwargs): """Returns a ``GeoSeries`` containing a simplified representation of @@ -594,9 +532,7 @@ class GeoPandasBase(object): False uses a quicker algorithm, but may produce self-intersecting or otherwise invalid geometries. """ - return gpd.GeoSeries( - [geom.simplify(*args, **kwargs) for geom in self.geometry], - index=self.index, crs=self.crs) + return _delegate_geo_method('simplify', self, *args, **kwargs) def relate(self, other): """ @@ -630,7 +566,6 @@ class GeoPandasBase(object): The project method is the inverse of interpolate. """ - return _binary_op('project', self, other, normalized=normalized) def interpolate(self, distance, normalized=False): @@ -647,22 +582,13 @@ class GeoPandasBase(object): If normalized is True, distance will be interpreted as a fraction of the geometric object's length. """ - if isinstance(distance, (np.ndarray, pd.Series)): - if len(distance) != len(self.index): - raise ValueError("Length of distance sequence does not match " - "length of the GeoSeries") - if isinstance(distance, pd.Series): - if not self.index.equals(distance.index): - raise ValueError("Index values of distance sequence does " - "not match index values of the GeoSeries") - return gpd.GeoSeries( - [s.interpolate(dist, normalized=normalized) - for (s, dist) in zip(self.geometry, distance)], - index=self.index, crs=self.crs) - - return gpd.GeoSeries([s.interpolate(distance, normalized=normalized) - for s in self.geometry], - index=self.index, crs=self.crs) + if isinstance(distance, pd.Series): + if not self.index.equals(distance.index): + raise ValueError("Index values of distance sequence does " + "not match index values of the GeoSeries") + distance = np.asarray(distance) + return _delegate_geo_method('interpolate', self, distance, + normalized=normalized) def translate(self, xoff=0.0, yoff=0.0, zoff=0.0): """Returns a ``GeoSeries`` with translated geometries. @@ -677,9 +603,7 @@ class GeoPandasBase(object): xoff, yoff, and zoff for translation along the x, y, and z dimensions respectively. """ - return gpd.GeoSeries([affinity.translate(s, xoff, yoff, zoff) - for s in self.geometry], - index=self.index, crs=self.crs) + return _delegate_geo_method('translate', self, xoff, yoff, zoff) def rotate(self, angle, origin='center', use_radians=False): """Returns a ``GeoSeries`` with rotated geometries. @@ -700,11 +624,8 @@ class GeoPandasBase(object): use_radians : boolean Whether to interpret the angle of rotation as degrees or radians """ - return gpd.GeoSeries( - [affinity.rotate(s, angle, origin=origin, - use_radians=use_radians) - for s in self.geometry], - index=self.index, crs=self.crs) + return _delegate_geo_method('rotate', self, angle, origin=origin, + use_radians=use_radians) def scale(self, xfact=1.0, yfact=1.0, zfact=1.0, origin='center'): """Returns a ``GeoSeries`` with scaled geometries. @@ -724,10 +645,8 @@ class GeoPandasBase(object): box center (default), 'centroid' for the geometry's 2D centroid, a Point object or a coordinate tuple (x, y, z). """ - return gpd.GeoSeries( - [affinity.scale(s, xfact, yfact, zfact, origin=origin) - for s in self.geometry], - index=self.index, crs=self.crs) + return _delegate_geo_method('scale', self, xfact, yfact, zfact, + origin=origin) def skew(self, xs=0.0, ys=0.0, origin='center', use_radians=False): """Returns a ``GeoSeries`` with skewed geometries. @@ -750,10 +669,8 @@ class GeoPandasBase(object): use_radians : boolean Whether to interpret the shear angle(s) as degrees or radians """ - return gpd.GeoSeries( - [affinity.skew(s, xs, ys, origin=origin, use_radians=use_radians) - for s in self.geometry], - index=self.index, crs=self.crs) + return _delegate_geo_method('skew', self, xs, ys, origin=origin, + use_radians=use_radians) def explode(self): """ diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py index 4212412..f29e757 100644 --- a/geopandas/geoseries.py +++ b/geopandas/geoseries.py @@ -10,7 +10,8 @@ from shapely.geometry.base import BaseGeometry from shapely.ops import transform from geopandas.plotting import plot_series -from geopandas.base import GeoPandasBase, _unary_op, _CoordinateIndexer +from geopandas.base import ( + GeoPandasBase, _delegate_property, _CoordinateIndexer) _PYPROJ2 = LooseVersion(pyproj.__version__) >= LooseVersion('2.1.0') @@ -58,20 +59,12 @@ class GeoSeries(GeoPandasBase, Series): @property def x(self): """Return the x location of point geometries in a GeoSeries""" - if (self.geom_type == "Point").all(): - return _unary_op('x', self, null_value=np.nan) - else: - message = "x attribute access only provided for Point geometries" - raise ValueError(message) + return _delegate_property('x', self) @property def y(self): """Return the y location of point geometries in a GeoSeries""" - if (self.geom_type == "Point").all(): - return _unary_op('y', self, null_value=np.nan) - else: - message = "y attribute access only provided for Point geometries" - raise ValueError(message) + return _delegate_property('y', self) @classmethod def from_file(cls, filename, **kwargs): diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py index f7581eb..03dfb2b 100644 --- a/geopandas/tests/test_geom_methods.py +++ b/geopandas/tests/test_geom_methods.py @@ -198,13 +198,14 @@ class TestGeomMethods: result = getattr(gdf, op) fcmp(result, expected) - def test_crs_warning(self): - # operations on geometries should warn for different CRS - no_crs_g3 = self.g3.copy() - no_crs_g3.crs = None - with pytest.warns(UserWarning): - self._test_binary_topological('intersection', self.g3, - self.g3, no_crs_g3) + # TODO reenable for all operations once we use pyproj > 2 + # def test_crs_warning(self): + # # operations on geometries should warn for different CRS + # no_crs_g3 = self.g3.copy() + # no_crs_g3.crs = None + # with pytest.warns(UserWarning): + # self._test_binary_topological('intersection', self.g3, + # self.g3, no_crs_g3) def test_intersection(self): self._test_binary_topological('intersection', self.t1,