Repository: ibis-project/ibis

Issue:
[discussion] Support `always_xy` for GeoConvert 
After playing around a bit with duckdb `ST_Transform` function and our Ibis implementation `convert` I ran into couple of problems, that are mostly related to conventions, and choices of a what's the default.

After a discussion with @gforsyth we found out that in `duckdb`:

- Output is always in x, y format
- The assume input (with default value `always_xy=False`) is lat/long which is equivalent to y, x
- If the user is providing input data as lon/lat they need to pass `always_xy=True`

But it also depends on the projection parameters you pass in. I think the big source of confusion is that wgs84 (epsg 4326) is standardized to be in lat/lon (even though almost all gis systems ignore that and pretend its always lon/lat), while basically every other projection youd care about is in lon/lat. 

In `Geopandas` things are different (@jorisvandenbossche please correct us here if we are wrong) : 

- All the inputs are expected as lon/lat 
- Somehow things get handled for the case of `epsg 4326` 


I think this means that we should expose the `always_xy` variable to the user but maybe not?. That being said the name is terrible and we should change it, ideas are welcome. 



Discussion:
We talked about this a bit offline, but I'll write out here what I think we should do (with the caveat that I don't know anything except what I've read in the last day or two).

It seems like PostGIS always expects coordinates in XY order (long/lat in ESPG4326).

For datasets that are in YX order, they have a function to flip those coordinates: https://postgis.net/docs/ST_FlipCoordinates.html

I think this is a reasonable approach to follow and DuckDB spatial does have a fast flip coordinates function we can make use of.


So all geospatial calls assume XY ordering, and if a user has data already ingested in YX ordering, then they can flip them and proceed from there.

This also seems much simpler from a documentation perspective -- we can state at the top of our geospatial docs that all operations assume XY ordering and explain using flip as necessary.

So I say we leave the `always_xy` argument hardcoded and users only have to think about coordinate order at the beginning of their analysis (if at all) and not every time they do a transform.


xref: https://github.com/duckdb/duckdb_spatial/issues/63
I agree and I think this is a sensitive choice. I'll leave this issue open until sorted and maybe there are more opinions but for now the TODOs are:
- [ ] Add extra line of docs to our `convert` function to state that we expect that all operations assume XY ordering (long/lat)  and to use `flip_coordinates` if needed to satisfy this.
- [ ] Implement `flip_coordinates` 

Resolving patch:
diff --git a/ibis/backends/duckdb/registry.py b/ibis/backends/duckdb/registry.py
--- a/ibis/backends/duckdb/registry.py
+++ b/ibis/backends/duckdb/registry.py
@@ -61,6 +61,11 @@ def _centroid(t, op):
     return sa.func.st_centroid(arg, type_=Geometry_WKB)
 
 
+def _geo_flip_coordinates(t, op):
+    arg = t.translate(op.arg)
+    return sa.func.st_flipcoordinates(arg, type_=Geometry_WKB)
+
+
 def _geo_end_point(t, op):
     arg = t.translate(op.arg)
     return sa.func.st_endpoint(arg, type_=Geometry_WKB)
@@ -568,6 +573,7 @@ def _array_remove(t, op):
         ops.GeoX: unary(sa.func.ST_X),
         ops.GeoY: unary(sa.func.ST_Y),
         ops.GeoConvert: _geo_convert,
+        ops.GeoFlipCoordinates: _geo_flip_coordinates,
         # other ops
         ops.TimestampRange: fixed_arity(sa.func.range, 3),
         ops.RegexSplit: fixed_arity(sa.func.str_split_regex, 2),
diff --git a/ibis/expr/operations/geospatial.py b/ibis/expr/operations/geospatial.py
--- a/ibis/expr/operations/geospatial.py
+++ b/ibis/expr/operations/geospatial.py
@@ -487,3 +487,10 @@ class GeoAsText(GeoSpatialUnOp):
     """Return the Well-Known Text (WKT) representation of the input, without SRID metadata."""
 
     dtype = dt.string
+
+
+@public
+class GeoFlipCoordinates(GeoSpatialUnOp):
+    """Returns a new geometry with the coordinates of the input geometry "flipped" so that x = y and y = x."""
+
+    dtype = dt.geometry
diff --git a/ibis/expr/types/geospatial.py b/ibis/expr/types/geospatial.py
--- a/ibis/expr/types/geospatial.py
+++ b/ibis/expr/types/geospatial.py
@@ -674,7 +674,8 @@ def transform(self, srid: ir.IntegerValue) -> GeoSpatialValue:
     def convert(
         self, source: ir.StringValue, target: ir.StringValue | ir.IntegerValue
     ) -> GeoSpatialValue:
-        """Transform a geometry into a new SRID (CRS).
+        """Transform a geometry into a new SRID (CRS). The coordinates are assumed
+        to always be XY (Longitude-Latitude).
 
         Parameters
         ----------
@@ -687,6 +688,11 @@ def convert(
         -------
         GeoSpatialValue
             Transformed geometry
+
+        See Also
+        --------
+        [`flip_coordinates`](#ibis.expr.types.geospatial.GeoSpatialValue.flip_coordinates)
+
         """
         return ops.GeoConvert(self, source, target).to_expr()
 
@@ -748,6 +754,16 @@ def line_merge(self) -> ir.LineStringValue:
         """
         return ops.GeoLineMerge(self).to_expr()
 
+    def flip_coordinates(self) -> GeoSpatialValue:
+        """Flip coordinates of a geometry so that x = y  and y = x.
+
+        Returns
+        -------
+        GeoSpatialValue
+            New geometry with flipped coordinates
+        """
+        return ops.GeoFlipCoordinates(self).to_expr()
+
 
 @public
 class GeoSpatialScalar(NumericScalar, GeoSpatialValue):


Test patch:
diff --git a/ibis/backends/duckdb/tests/test_geospatial.py b/ibis/backends/duckdb/tests/test_geospatial.py
--- a/ibis/backends/duckdb/tests/test_geospatial.py
+++ b/ibis/backends/duckdb/tests/test_geospatial.py
@@ -214,6 +214,26 @@ def test_geospatial_convert(geotable, gdf):
     )
 
 
+def test_geospatial_flip_coordinates(geotable):
+    flipped = geotable.geom.flip_coordinates()
+
+    # flipped coords
+    point = shapely.geometry.Point(40, -100)
+    line_string = shapely.geometry.LineString([[0, 0], [1, 1], [1, 2], [2, 2]])
+    polygon = shapely.geometry.Polygon(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)))
+
+    d = {
+        "name": ["Point", "LineString", "Polygon"],
+        "geometry": [point, line_string, polygon],
+    }
+
+    flipped_gdf = gpd.GeoDataFrame(d)
+
+    gtm.assert_geoseries_equal(
+        flipped.to_pandas(), flipped_gdf.geometry, check_crs=False
+    )
+
+
 def test_create_table_geospatial_types(geotable, con):
     name = ibis.util.gen_name("geotable")