Repository: geopandas/geopandas

Issue:
De-couple exact column name from definition of geometry with a set_geometry() method on GeoDataFrame.
Is the assumption that there is always one and only one `'geometry'` column that defines the geometry on the object? For more flexibility on defining the internal representation of geometries, and more clarity on what's going on `set_geometry(col_name_or_series)` (same/similar to set_index())  and have a `.geometry` property.

For now, that could just set the `'geometry'` column, but this could be helpful when showing examples for GeoDataFrame.  Also means you could potentially have two columns with different geo data (though that'd be weird) and more easily switch between them.



Discussion:
I think we want to have a single defined column acting as "the geometry". Your suggestion is definitely a lot cleaner.

I could see some scenarios where you might want multiple geometries in a row with the ability to easily switch. For example you could store different simplification levels in different columns and switch which to use under different conditions.

I'll try this out.

@jtratner I like this suggestion.  It seems to me the "pandas way" to handle this, using a property that has special behavior rather than a specially named column.  Of course you can always store geometries in any column, but there's no easy way to know which ones (they're just `dtype` object).  It seems right that there should only be one "geometry" of a given row at a time, even if other "geometries" are actually stored in the same row.

Another advantage of making the geometry a property is that normal reduction methods won't have to worry about treating the geometry column differently.

@jwass Your PR looks like a good first step.  It gives us part of that API.

Okay. @jwass - I was (implicitly I guess) offering  to do it, but if you'd like to, go ahead :)

Sorry Jeff, didn't mean to step on your toes there. I think I just read it as a suggestion based on your experience with Pandas. Obviously feel free to rip apart my commit with what you had in mind.

I suppose the next steps for this would be to store the geometry as a member and remove any special treatment of a `geometry` column. Maybe the GeoDataFrame's constructor would look for a column named `geometry` by default?

@jwass Already merged #47, as it fit with our current API.  Now that I think about it, though, pandas columns are already accessible as properties, so the property there was redundant.  The `set_geometry` method makes sense, though.

@jtratner I like the idea of geometry as a real property, feel free to try it out and send a PR.

This discussion is an exciting one. I actually work with multiple geometry columns in databases all the time (origin and destination locations), so the ability to switch between them is ideal. The more I follow this discussion, the more I like the idea of `set_geometry` acting like default pandas `set_index`. In fact, the whole concept of geometries acting as the 'index' of a data frame is a nice one, and avoids lots of issues (as @kjordahl suggested re: reduction methods). This also sounds a lot like the 'pandas way' of doing things... turn a column into an index, which has its own internal 'index' to speed up retrieval etc. For now, we can avoid this internal index at the expense of some speed, but when we do eventually get around to working that out (via RTree?), the API shouldn't change much/at all. Cool stuff!

Happy that you put something through already - just wanted to make it clear
I'd do it if not. :)

 I don't think you need to change the idea of treating the geo column
specially by default, I think we should change places that do
`self['geometry']` to be  `self.geometry` or
`self[[self._geo_column_name]]`, etc.  For example, if I do this:

gf = GeoDataFrame(zip([1, 2, 3],  [4, 5, 6], [geo1, geo2, geo3], [geo1,
geo2, geo3]), columns = ["A", "B", "geometry", "geometry"])

Looking up on geometry now produces a (Geo)DataFrame (oh no!).  So you
already have a potential breakage.

I suggest the following:
1. Have `'geometry'` be the default for now.
2. Override the column property to validate that there aren't multiple
names for whatever the geo column is on setting.
3. Store the geo column name as metadata on the frame (so it'll get carried
forward).

1-3 will help avoid issues with dup cols for geometry and avoid
encountering issues with reordering.

Side note - how will GeoPandas handle hierarchical columns? Unless you use
integer column locations (which create their own issues with reordering),
it's not totally clear to me. Maybe warn for now or something?

After re-reading the discussion here, I'm not sure everyone is on the same page about how to approach this. I'll try to summarize my interpretation. If I misrepresent your position, let me know.

@jtratner is recommending that GeoDataFrame have a property indicating the column to return when accessing the `geometry` property. We can have several columns containing geometries and calls to `set_geometry` simply switch the name of the column to use. However the geometry column(s) would still be ordinary columns accessible in the usual way. For example:

```
gdf = GeoDataFrame(...)  # Uses  'geometry' column by default
shape = gdf.geometry.iloc[0]
shape = gdf['geometry'].iloc[0]  # Returns the same as above because 'geometry' is just a column

gdf['other_geometry'] = GeoSeries(...) # (or list of geometries, etc.)
a = gdf.area
a = gdf.geometry.area
a = gdf['geometry'].area  # All three are the same

gdf.set_geometry('other_geometry', inplace=True)
a2 = gdf.area
a2 = gdf.geometry.area
a2 = gdf['other_geometry'].area  # All three are the same
```

@kjordahl is recommending that the geometry would not really be a column in the DataFrame but would be a separate property that we always do the right thing with. It would serve the similar function above, but you couldn't access it using the normal DataFrame operations. In this approach:

```
gdf = GeoDataFrame(...)
shape = gdf.geometry.iloc[0]  # The only way to get the geometry
shape = gdf['geometry'].iloc[0]  # Raises KeyError because there's no column named geometry
```

I think the major problem with this approach is that we'd need to override all the row indexing operations to grab out the proper rows in our geometry property...? `gdf[:5]` would have to separately grab those elements in our geometry property. If we keep the geometry as a column, the usual indexing takes care of that for us.

I tried implementing the basic idea of the first suggestion: (https://github.com/jwass/geopandas/commits/set_geometry). It too raises a bunch of questions.
- If I get a column containing geometries but is not the defined geometry, would it return a GeoSeries? (i.e. what does `gdf['other_geometry']` return when `other_geometry` is not the geometry column?). I think it would be powerful to return a GeoSeries. Would the GeoBlock mentioned in Issue #51 help with this, so we don't have to check all the elements whenever a column is retrieved?
- What type is returned here: `gdf2 = gdf[['prop1', 'prop2', 'other_geometry']]` when `other_geometry` is not the geometry?
- Would we allow different columns to have different crs?

I think both have their pros and cons but want to make sure we're talking about the same thing moving forward. I'm leaning toward the first approach where they the geometry is an ordinary column...

I'm on board with @kjordahl suggestion (and it's really just a further
extension of making geometry a property). But the downside is that you have
to reapply all the slicing whenever you do things to the other columns...

Some background on pandas internals that have bearing on this: internal
representation of DataFrame (and Series) is a block manager which holds
individual dtypes in separate blocks. (not sure if object dtypes are put in
the same block). Series uses a SingleBlockManager that does something
similar (only in 0.13 and above). When you ask for a column, part/all of
the block is converted to a Series, and the Series is cached on the parent
(so if you query twice you get the same thing) and Series updates or
invalidates its cache as necessary.

If you always want to return Geo Series from geocolumns, would need to
intercept all the indexers (loc, iloc, ix, xs, etc) OR you could monkey
patch DataFrames' constructor_sliced to be GeoSeries and then return
regular Series if it's not Geo.

One possibility that came to mind would be to bind all the geo methods to
Series and DataFrame (or put them in a geo namespace like StringMethods)
and then you could swap out the class at that point.


Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1,6 +1,7 @@
 from collections import OrderedDict
 import json
 import os
+import sys
 
 import fiona
 import numpy as np
@@ -12,18 +13,34 @@
 import geopandas.io
 
 
+DEFAULT_GEO_COLUMN_NAME = 'geometry'
+PY3 = sys.version[0] == 3
+
+
 class GeoDataFrame(DataFrame):
     """
     A GeoDataFrame object is a pandas.DataFrame that has a column
-    named 'geometry' which is a GeoSeries.
+    with geometry. In addition to the standard DataFrame constructor arguments,
+    GeoDataFrame also accepts the following keyword arguments:
+
+    Keyword Arguments
+    -----------------
+    crs : str (optional)
+        Coordinate system
+    geometry : str or array (optional)
+        If str, column to use as geometry. If array, will be set as 'geometry'
+        column on GeoDataFrame.
     """
     _metadata = ['crs', '_geometry_column_name']
-    _geometry_column_name = 'geometry'
+    _geometry_column_name = DEFAULT_GEO_COLUMN_NAME
 
     def __init__(self, *args, **kwargs):
         crs = kwargs.pop('crs', None)
+        geometry = kwargs.pop('geometry', None)
         super(GeoDataFrame, self).__init__(*args, **kwargs)
         self.crs = crs
+        if geometry is not None:
+            self.set_geometry(geometry, inplace=True)
 
     def __setattr__(self, attr, val):
         # have to special case geometry b/c pandas tries to use as column...
@@ -49,9 +66,9 @@ def _set_geometry(self, col):
     geometry = property(fget=_get_geometry, fset=_set_geometry,
                         doc="Geometry data for GeoDataFrame")
 
-    def set_geometry(self, col, drop=True, inplace=False):
+    def set_geometry(self, col, drop=False, inplace=False, crs=None):
         """
-        Set the GeoDataFrame geometry using either an existing column or 
+        Set the GeoDataFrame geometry using either an existing column or
         the specified input. By default yields a new object.
 
         The original geometry column is replaced with the input.
@@ -63,6 +80,10 @@ def set_geometry(self, col, drop=True, inplace=False):
             Delete column to be used as the new geometry
         inplace : boolean, default False
             Modify the GeoDataFrame in place (do not create a new object)
+        crs : str/result of fion.get_crs (optional)
+            Coordinate system to use. If passed, overrides both DataFrame and
+            col's crs. Otherwise, tries to get crs from passed col values or
+            DataFrame.
 
         Examples
         --------
@@ -79,7 +100,11 @@ def set_geometry(self, col, drop=True, inplace=False):
         else:
             frame = self.copy()
 
+        if not crs:
+            crs = getattr(col, 'crs', self.crs)
+
         to_remove = None
+        geo_column_name = DEFAULT_GEO_COLUMN_NAME
         if isinstance(col, Series):
             level = col.values
         elif isinstance(col, (list, np.ndarray)):
@@ -92,15 +117,24 @@ def set_geometry(self, col, drop=True, inplace=False):
             except KeyError:
                 raise ValueError("Unknown column %s" % col)
             except:
-                print col
                 raise
             if drop:
                 to_remove = col
+                geo_column_name = DEFAULT_GEO_COLUMN_NAME
+            else:
+                geo_column_name = col
 
         if to_remove:
             del frame[to_remove]
 
-        frame['geometry'] = level
+        if isinstance(level, GeoSeries) and level.crs != crs:
+            # avoids caching issues/crs sharing issues
+            level = level.copy()
+            level.crs = crs
+
+        frame[geo_column_name] = level
+        frame._geometry_column_name = geo_column_name
+        frame.crs = crs
 
         if not inplace:
             return frame
@@ -261,13 +295,15 @@ def __getitem__(self, key):
         GeoDataFrame.
         """
         result = super(GeoDataFrame, self).__getitem__(key)
-        if isinstance(key, basestring) and key == 'geometry':
+        geo_col = self._geometry_column_name
+        if isinstance(key, basestring) and key == geo_col:
             result.__class__ = GeoSeries
             result.crs = self.crs
-        elif isinstance(result, DataFrame) and 'geometry' in result:
+        elif isinstance(result, DataFrame) and geo_col in result:
             result.__class__ = GeoDataFrame
             result.crs = self.crs
-        elif isinstance(result, DataFrame) and 'geometry' not in result:
+            result._geometry_column_name = geo_col
+        elif isinstance(result, DataFrame) and geo_col not in result:
             result.__class__ = DataFrame
             result.crs = self.crs
         return result
@@ -308,3 +344,18 @@ def copy(self, deep=True):
 
     def plot(self, *args, **kwargs):
         return plot_dataframe(self, *args, **kwargs)
+
+def _dataframe_set_geometry(self, col, drop=False, inplace=False, crs=None):
+    if inplace:
+        raise ValueError("Can't do inplace setting when converting from"
+                         " DataFrame to GeoDataFrame")
+    gf = GeoDataFrame(self)
+    # this will copy so that BlockManager gets copied
+    return gf.set_geometry(col, drop=drop, inplace=False, crs=crs)
+
+if PY3:
+    DataFrame.set_geometry = _dataframe_set_geometry
+else:
+    import types
+    DataFrame.set_geometry = types.MethodType(_dataframe_set_geometry, None,
+                                              DataFrame)
diff --git a/geopandas/io/sql.py b/geopandas/io/sql.py
--- a/geopandas/io/sql.py
+++ b/geopandas/io/sql.py
@@ -21,7 +21,7 @@ def read_postgis(sql, con, geom_col='geom', crs=None, index_col=None,
     geom_col: string, default 'geom'
         column name to convert to shapely geometries
     crs: optional
-        CRS to use for the returned GeoDataFrame      
+        CRS to use for the returned GeoDataFrame
 
     See the documentation for pandas.read_sql for further explanation 
     of the following parameters:
@@ -37,7 +37,6 @@ def read_postgis(sql, con, geom_col='geom', crs=None, index_col=None,
 
     s = wkb_geoms.apply(lambda x: shapely.wkb.loads(x.decode('hex')))
 
-    df = df.drop(geom_col, axis=1)
-    df['geometry'] = GeoSeries(s)
+    df[geom_col] = GeoSeries(s)
 
-    return GeoDataFrame(df, crs=crs)
+    return GeoDataFrame(df, crs=crs, geometry=geom_col)


Test patch:
diff --git a/tests/test_geodataframe.py b/tests/test_geodataframe.py
--- a/tests/test_geodataframe.py
+++ b/tests/test_geodataframe.py
@@ -5,6 +5,7 @@
 import shutil
 
 import numpy as np
+import pandas as pd
 from shapely.geometry import Point, Polygon
 
 
@@ -35,6 +36,30 @@ def test_df_init(self):
         self.assertTrue(type(self.df2) is GeoDataFrame)
         self.assertTrue(self.df2.crs == self.crs)
 
+    def test_different_geo_colname(self):
+        data = {"A": range(5), "B": range(-5, 0),
+                "location": [Point(x, y) for x, y in zip(range(5), range(5))]}
+        df = GeoDataFrame(data, crs=self.crs, geometry='location')
+        locs = GeoSeries(data['location'])
+        tu.assert_geoseries_equal(df.geometry, locs)
+        self.assert_('geometry' not in df)
+        self.assertEqual(df.geometry.name, 'location')
+        # internal implementation detail
+        self.assertEqual(df._geometry_column_name, 'location')
+
+        geom2 = [Point(x, y) for x, y in zip(range(5, 10), range(5))]
+        df2 = df.set_geometry(geom2, crs='dummy_crs')
+        self.assert_('geometry' in df2)
+        self.assert_('location' in df2)
+        self.assertEqual(df2.crs, 'dummy_crs')
+        self.assertEqual(df2.geometry.crs, 'dummy_crs')
+        # reset so it outputs okay
+        df2.crs = df.crs
+        tu.assert_geoseries_equal(df2.geometry, GeoSeries(geom2))
+        # for right now, non-geometry comes back as series
+        tu.assert_geoseries_equal(df2['location'], df['location'],
+                                  check_series_type=False, check_dtype=False)
+
     def test_geometry_property(self):
         tu.assert_geoseries_equal(self.df.geometry, self.df['geometry'],
                                   check_dtype=True, check_index_type=True)
@@ -48,6 +73,11 @@ def test_geometry_property(self):
         tu.assert_geoseries_equal(df.geometry, new_geom)
         tu.assert_geoseries_equal(df['geometry'], new_geom)
 
+        # new crs
+        gs = GeoSeries(new_geom, crs="epsg:26018")
+        df.geometry = gs
+        self.assertEqual(df.crs, "epsg:26018")
+
     def test_geometry_property_errors(self):
         # TODO: Much cleaner if we use pandas test options (since assertRaises
         # contextmanager and friends not available in 2.6), but need 0.13 for
@@ -99,25 +129,36 @@ def test_set_geometry(self):
         self.assertRaises(ValueError, self.df.set_geometry,
                           self.df)
 
-    def test_set_geometry_col(self):
-        g = self.df.geometry
-        g_simplified = g.simplify(100)
-        self.df['simplified_geometry'] = g_simplified
-        df2 = self.df.set_geometry('simplified_geometry')
+        # new crs - setting should default to GeoSeries' crs
+        gs = GeoSeries(geom, crs="epsg:26018")
+        new_df = self.df.set_geometry(gs)
+        self.assertEqual(new_df.crs, "epsg:26018")
 
-        # Drop is true by default
-        self.assert_('simplified_geometry' not in df2)
+        # explicit crs overrides self and dataframe
+        new_df = self.df.set_geometry(gs, crs="epsg:27159")
+        self.assertEqual(new_df.crs, "epsg:27159")
+        self.assertEqual(new_df.geometry.crs, "epsg:27159")
 
-        tu.assert_geoseries_equal(df2.geometry, g_simplified)
+        # Series should use dataframe's
+        new_df = self.df.set_geometry(geom.values)
+        self.assertEqual(new_df.crs, self.df.crs)
+        self.assertEqual(new_df.geometry.crs, self.df.crs)
 
-    def test_set_geometry_col_no_drop(self):
+    def test_set_geometry_col(self):
         g = self.df.geometry
         g_simplified = g.simplify(100)
         self.df['simplified_geometry'] = g_simplified
-        df2 = self.df.set_geometry('simplified_geometry', drop=False)
+        df2 = self.df.set_geometry('simplified_geometry')
+
+        # Drop is false by default
         self.assert_('simplified_geometry' in df2)
         tu.assert_geoseries_equal(df2.geometry, g_simplified)
 
+        # If True, drops column and renames to geometry
+        df3 = self.df.set_geometry('simplified_geometry', drop=True)
+        self.assert_('simplified_geometry' not in df3)
+        tu.assert_geoseries_equal(df3.geometry, g_simplified)
+
     def test_set_geometry_inplace(self):
         geom = [Point(x,y) for x,y in zip(range(5), range(5))]
         ret = self.df.set_geometry(geom, inplace=True)
@@ -252,3 +293,29 @@ def test_from_postgis_custom_geom_col(self):
             con.close()
 
         tu.validate_boro_df(self, df)
+
+    def test_dataframe_to_geodataframe(self):
+        df = pd.DataFrame({"A": range(len(self.df)), "location":
+                           list(self.df.geometry)}, index=self.df.index)
+        gf = df.set_geometry('location', crs=self.df.crs)
+        tu.assert_isinstance(df, pd.DataFrame)
+        tu.assert_isinstance(gf, GeoDataFrame)
+        tu.assert_geoseries_equal(gf.geometry, self.df.geometry)
+        self.assertEqual(gf.geometry.name, 'location')
+        self.assert_('geometry' not in gf)
+
+        gf2 = df.set_geometry('location', crs=self.df.crs, drop=True)
+        tu.assert_isinstance(df, pd.DataFrame)
+        tu.assert_isinstance(gf2, GeoDataFrame)
+        self.assertEqual(gf2.geometry.name, 'geometry')
+        self.assert_('geometry' in gf2)
+        self.assert_('location' not in gf2)
+        self.assert_('location' in df)
+
+        # should be a copy
+        df.ix[0, "A"] = 100
+        self.assertEqual(gf.ix[0, "A"], 0)
+        self.assertEqual(gf2.ix[0, "A"], 0)
+
+        self.assertRaises(ValueError, df.set_geometry, 'location',
+                          inplace=True)
diff --git a/tests/test_types.py b/tests/test_types.py
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -66,6 +66,12 @@ def setUp(self):
 
     def test_geometry(self):
         assert type(self.df.geometry) is GeoSeries
+        # still GeoSeries if different name
+        df2 = GeoDataFrame({"coords": [Point(x,y) for x, y in zip(range(5),
+                                                                  range(5))],
+                            "nums": range(5)}, geometry="coords")
+        assert type(df2.geometry) is GeoSeries
+        assert type(df2['coords']) is GeoSeries
 
     def test_nongeometry(self):
         assert type(self.df['value1']) is Series