diff --git a/python/sedona/spark/geopandas/_crs.py b/python/sedona/spark/geopandas/_crs.py new file mode 100644 index 00000000000..c7f85509c29 --- /dev/null +++ b/python/sedona/spark/geopandas/_crs.py @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""CRS metadata helpers for the distributed GeoPandas API.""" + +from __future__ import annotations + +from typing import Any + +from pyproj import CRS +from pyspark.pandas.internal import InternalField + +CRS_METADATA_KEY = "sedona.geopandas.crs_wkt" +NO_CRS_OVERRIDE = object() + + +def read_crs_metadata(field: InternalField) -> tuple[bool, CRS | None]: + """Return whether CRS metadata is present and its decoded value.""" + metadata = field.metadata or {} + if CRS_METADATA_KEY not in metadata: + return False, None + + value = metadata[CRS_METADATA_KEY] + return True, CRS.from_wkt(value) if value else None + + +def with_crs_metadata(field: InternalField, crs: Any | None) -> InternalField: + """Return an InternalField carrying normalized CRS metadata.""" + metadata = dict(field.metadata or {}) + metadata[CRS_METADATA_KEY] = ( + CRS.from_user_input(crs).to_wkt() if crs is not None else "" + ) + return field.copy(metadata=metadata) + + +def copy_crs_metadata( + source: InternalField, + target: InternalField, +) -> InternalField: + """Copy only Sedona CRS metadata while retaining all target metadata.""" + source_metadata = source.metadata or {} + target_metadata = dict(target.metadata or {}) + if CRS_METADATA_KEY in source_metadata: + target_metadata[CRS_METADATA_KEY] = source_metadata[CRS_METADATA_KEY] + else: + target_metadata.pop(CRS_METADATA_KEY, None) + return target.copy(metadata=target_metadata) diff --git a/python/sedona/spark/geopandas/geodataframe.py b/python/sedona/spark/geopandas/geodataframe.py index eb742f968d7..a5968e1892a 100644 --- a/python/sedona/spark/geopandas/geodataframe.py +++ b/python/sedona/spark/geopandas/geodataframe.py @@ -51,10 +51,12 @@ ) from pyspark.pandas.utils import log_advice +from sedona.spark.geopandas._crs import copy_crs_metadata, with_crs_metadata from sedona.spark.geopandas._typing import Label from sedona.spark.geopandas.base import GeoFrame from sedona.spark.sql import st_aggregates as sta from sedona.spark.sql import st_constructors as stc +from sedona.spark.sql import st_functions as stf from pandas.api.extensions import register_extension_dtype from geopandas.geodataframe import crs_mismatch_error @@ -617,15 +619,17 @@ def __getitem__(self, key: Any) -> Any: item = pspd.DataFrame.__getitem__(self, key) if isinstance(item, pspd.DataFrame): - # Don't specify crs=self.crs here because it might not include the geometry column. - # If it does include the geometry column, we don't need to set crs anyways. - return GeoDataFrame(item) + result = GeoDataFrame(item) + if self._geometry_column_name in result.columns: + result._geometry_column_name = self._geometry_column_name + return result elif isinstance(item, pspd.Series): ps_series: pspd.Series = item try: - return sgpd.GeoSeries(ps_series) + result = sgpd.GeoSeries(ps_series) except TypeError: return ps_series + return result else: raise Exception(f"Logical Error: Unexpected type: {type(item)}") @@ -713,7 +717,47 @@ def __init__( raise ValueError(crs_mismatch_error) if geometry: - self.set_geometry(geometry, inplace=True, crs=crs) + existing_geometry = None + if crs is not None and pd.api.types.is_hashable(geometry): + try: + candidate = self[geometry] + except (KeyError, TypeError, ValueError): + pass + else: + if isinstance(candidate, sgpd.GeoSeries): + existing_geometry = candidate + + if existing_geometry is None: + self.set_geometry(geometry, inplace=True, crs=crs) + else: + # Updating the existing Spark column directly avoids index + # alignment, which would multiply rows for duplicate indexes. + from pyproj import CRS + + normalized_crs = CRS.from_user_input(crs) + new_epsg = normalized_crs.to_epsg() or 0 + geometry_field = with_crs_metadata( + existing_geometry._internal.data_fields[0], + normalized_crs, + ) + geometry_column_name = ( + existing_geometry._internal.data_spark_column_names[0] + ) + geometry_field = geometry_field.copy(name=geometry_column_name) + self._update_internal_frame( + self._internal.with_new_spark_column( + existing_geometry._column_label, + stf.ST_SetSRID( + existing_geometry.spark.column, + new_epsg, + ).alias( + geometry_column_name, + metadata=geometry_field.metadata, + ), + field=geometry_field, + ) + ) + self._geometry_column_name = geometry if geometry is None and "geometry" in self.columns: @@ -771,11 +815,7 @@ def _get_geometry(self) -> sgpd.GeoSeries: ) raise MissingGeometryColumnError(msg) - geometry = self[self._geometry_column_name] - empty_crs_source = getattr(self, "_empty_crs_source", None) - if empty_crs_source is not None: - geometry._empty_crs_source = empty_crs_source - return geometry + return self[self._geometry_column_name] def _set_geometry(self, col): # This check is included in the original geopandas. Note that this prevents assigning a str to the property @@ -888,7 +928,8 @@ def set_geometry( else: frame = self.copy() - geo_column_name = self._geometry_column_name + previous_geometry_name = self._geometry_column_name + geo_column_name = previous_geometry_name new_series = False if geo_column_name is None: @@ -970,7 +1011,6 @@ def set_geometry( if new_series: # Note: This casts GeoSeries back into pspd.Series, so we lose any metadata that's not serialized. frame[geo_column_name] = level - object.__setattr__(frame, "_empty_crs_source", level) if not inplace: return frame @@ -1090,7 +1130,14 @@ def _to_geopandas(self) -> gpd.GeoDataFrame: else: pd_df[col_name] = series._to_pandas() - return gpd.GeoDataFrame(pd_df, geometry=self._geometry_column_name) + result = gpd.GeoDataFrame( + pd_df, + geometry=self._geometry_column_name, + crs=self.crs if self._geometry_column_name is not None else None, + ) + if self._geometry_column_name is None: + result._geometry_column_name = None + return result def to_spark_pandas(self) -> pspd.DataFrame: """ @@ -1124,9 +1171,10 @@ def copy(self, deep=False) -> GeoDataFrame: 0 POINT (1 1) 2 3 """ # Note: The deep parameter is a dummy parameter just as it is in PySpark pandas. - return GeoDataFrame( + result = GeoDataFrame( pspd.DataFrame(self._internal.copy()), geometry=self.active_geometry_name ) + return result def _safe_get_crs(self): """ @@ -2081,9 +2129,12 @@ def dissolve( *[scol_for(aggregated_sdf, name) for name in attribute_output_names], ], data_fields=[ - InternalField( - np.dtype("object"), - aggregated_sdf.schema[geometry_output_name], + copy_crs_metadata( + self.geometry._internal.data_fields[0], + InternalField( + np.dtype("object"), + aggregated_sdf.schema[geometry_output_name], + ), ), InternalField.from_struct_field( aggregated_sdf.schema[order_output_spark_name] @@ -2133,7 +2184,6 @@ def dissolve( result_labels.geometry_name, ) - object.__setattr__(aggregated, "_empty_crs_source", self.geometry) return aggregated # ============================================================================ diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py index 138f6e584c1..83e3a3e7c3e 100644 --- a/python/sedona/spark/geopandas/geoseries.py +++ b/python/sedona/spark/geopandas/geoseries.py @@ -45,6 +45,11 @@ import shapely from shapely.geometry.base import BaseGeometry +from sedona.spark.geopandas._crs import ( + NO_CRS_OVERRIDE, + read_crs_metadata, + with_crs_metadata, +) from sedona.spark.geopandas._typing import Label from sedona.spark.geopandas.base import GeoFrame from sedona.spark.geopandas.geodataframe import GeoDataFrame @@ -338,14 +343,12 @@ def __init__( dtype: geometry """ assert data is not None + if crs is None and isinstance(data, gpd.GeoSeries): + crs = data.crs self._anchor: GeoDataFrame self._col_label: Label self._sindex: SpatialIndex = None - self._empty_crs_source: typing.Optional["GeoSeries"] = None - # Explicit CRS metadata wins over the lineage fallback below when - # geometry rows are empty or carry SRID 0. - self._empty_crs_value = None if isinstance( data, (GeoDataFrame, GeoSeries, PandasOnSparkSeries, PandasOnSparkDataFrame) @@ -415,7 +418,7 @@ def __init__( f"received data of dtype '{self.spark.data_type.typeName()}'" ) - if crs: + if crs is not None: self.set_crs(crs, inplace=True) def _is_empty(self) -> bool: @@ -463,13 +466,13 @@ def crs(self) -> Union["CRS", None]: """ from pyproj import CRS + has_crs_metadata, metadata_crs = read_crs_metadata( + self._internal.data_fields[0] + ) + if has_crs_metadata: + return metadata_crs + if self._is_empty(): - # Empty data has no SRID to inspect, so explicit CRS metadata wins - # over the inherited lineage metadata. - if self._empty_crs_value is not None: - return self._empty_crs_value - if self._empty_crs_source is not None: - return self._empty_crs_source.crs return None # F.first is non-deterministic, but it doesn't matter because all non-null values should be the same. @@ -489,16 +492,7 @@ def crs(self) -> Union["CRS", None]: srid = 0 if np.isnan(srid) else srid # Sedona returns 0 if SRID doesn't exist. - if srid != 0: - return CRS.from_user_input(srid) - # These fallbacks are metadata rather than a fresh read from geometry - # coordinates. Explicit metadata takes precedence over inherited - # lineage metadata, including for non-empty geometries with SRID 0. - if self._empty_crs_value is not None: - return self._empty_crs_value - if self._empty_crs_source is not None: - return self._empty_crs_source.crs - return None + return CRS.from_user_input(srid) if srid != 0 else None @crs.setter def crs(self, value: Union["CRS", None]): @@ -627,19 +621,17 @@ def set_crs( ) # 0 indicates no SRID in Sedona. - new_epsg = crs.to_epsg() if crs else 0 + new_epsg = (crs.to_epsg() or 0) if crs is not None else 0 spark_col = stf.ST_SetSRID(self.spark.column, new_epsg) - result = self._query_geometry_column(spark_col, keep_name=True) - result._empty_crs_value = crs - if crs is None: - result._empty_crs_source = None + result = self._query_geometry_column( + spark_col, + keep_name=True, + crs_override=crs, + ) if inplace: self._update_inplace(result, invalidate_sindex=False) - self._empty_crs_value = crs - if crs is None: - self._empty_crs_source = None return None return result @@ -655,6 +647,7 @@ def _query_geometry_column( returns_geom: bool = True, is_aggr: bool = False, keep_name: bool = False, + crs_override: Any = NO_CRS_OVERRIDE, ) -> Union["GeoSeries", pspd.Series]: """ Helper method to query a single geometry column with a specified operation. @@ -683,7 +676,14 @@ def _query_geometry_column( if keep_name and self.name: rename = self.name - col_expr = spark_col.alias(rename) + result_field = None + if returns_geom: + result_field = self._internal.data_fields[0].copy(name=rename) + if crs_override is not NO_CRS_OVERRIDE: + result_field = with_crs_metadata(result_field, crs_override) + col_expr = spark_col.alias(rename, metadata=result_field.metadata) + else: + col_expr = spark_col.alias(rename) exprs = [col_expr] @@ -718,6 +718,13 @@ def _query_geometry_column( else: sdf = df.select(*exprs) + if result_field is not None: + schema_field = sdf.schema[rename] + result_field = result_field.copy( + spark_type=schema_field.dataType, + nullable=schema_field.nullable, + ) + internal = self._internal.copy( spark_frame=sdf, index_fields=index_fields, @@ -727,7 +734,7 @@ def _query_geometry_column( data_spark_columns=[scol_for(sdf, rename)], data_fields=[ ( - self._internal.data_fields[0].copy(name=rename) + result_field if returns_geom else InternalField.from_struct_field(sdf.schema[rename]) ) @@ -740,8 +747,7 @@ def _query_geometry_column( series_name = None if rename == SPARK_DEFAULT_SERIES_NAME else rename ps_series = ps_series.rename(series_name) - result = GeoSeries(ps_series) if returns_geom else ps_series - return result + return GeoSeries(ps_series) if returns_geom else ps_series def _expand_geometry_array( self, @@ -918,11 +924,8 @@ def copy(self, deep=False): dtype: geometry """ if deep: - return GeoSeries( - self._anchor.copy(), dtype=self.dtype, index=self._col_label - ) - else: - return self + return GeoSeries(pspd.Series.copy(self, deep=True)) + return self @property def area(self) -> pspd.Series: @@ -3574,15 +3577,14 @@ def explode(self, ignore_index=False, index_parts=False) -> "GeoSeries": index_fields=index_fields, data_spark_columns=[scol_for(output_sdf, geometry_col)], data_fields=[ - InternalField(np.dtype("object"), output_sdf.schema[geometry_col]) + self._internal.data_fields[0].copy( + name=geometry_col, + spark_type=output_sdf.schema[geometry_col].dataType, + nullable=output_sdf.schema[geometry_col].nullable, + ) ], ) - result = GeoSeries(first_series(PandasOnSparkDataFrame(result_internal))) - # An explode can remove every row even though its input carries an - # SRID. Keep a lazy reference to the input so CRS remains available - # without eagerly evaluating or collecting the result. - result._empty_crs_source = self - return result + return GeoSeries(first_series(PandasOnSparkDataFrame(result_internal))) def to_crs( self, crs: Union[Any, None] = None, epsg: Union[int, None] = None @@ -3671,6 +3673,7 @@ def to_crs( return self._query_geometry_column( spark_expr, keep_name=True, + crs_override=crs, ) @property @@ -4187,6 +4190,7 @@ def to_geoframe(self, name=None): result = GeoDataFrame( pspd.DataFrame(renamed._internal).to_spark(index_col).pandas_api(index_col) ) + result._geometry_column_name = renamed.name result.index.name = self.index.name return result diff --git a/python/sedona/spark/geopandas/tools/clip.py b/python/sedona/spark/geopandas/tools/clip.py index ffed1cfb820..bdbfc292934 100644 --- a/python/sedona/spark/geopandas/tools/clip.py +++ b/python/sedona/spark/geopandas/tools/clip.py @@ -81,14 +81,10 @@ def _rebuild_like(obj, internal, sdf): ) if isinstance(obj, GeoSeries): - result = GeoSeries(first_series(PandasOnSparkDataFrame(result_internal))) - result._empty_crs_source = obj - return result + return GeoSeries(first_series(PandasOnSparkDataFrame(result_internal))) result = GeoDataFrame(PandasOnSparkDataFrame(result_internal)) result._geometry_column_name = obj.active_geometry_name - # Bypass GeoDataFrame.__setattr__, which would interpret this as a new column. - object.__setattr__(result, "_empty_crs_source", obj.geometry) return result diff --git a/python/tests/geopandas/test_geodataframe.py b/python/tests/geopandas/test_geodataframe.py index e5d4fae9c51..fddd678eef3 100644 --- a/python/tests/geopandas/test_geodataframe.py +++ b/python/tests/geopandas/test_geodataframe.py @@ -63,13 +63,26 @@ def test_constructor(self, obj): def test_construct_from_geopandas(self): gpd_df = gpd.GeoDataFrame( - {"geometry1": [Point(0, 0)]}, geometry="geometry1", crs="EPSG:3857" + {"geometry1": [Point(0, 0), Point(1, 1)]}, + index=[0, 0], + geometry="geometry1", + crs="EPSG:3857", ) with ps.option_context("compute.ops_on_diff_frames", True): sgpd_df = GeoDataFrame(gpd_df) assert sgpd_df.crs == "EPSG:3857" assert sgpd_df.geometry.crs == "EPSG:3857" assert sgpd_df.geometry.name == "geometry1" + assert len(sgpd_df) == len(gpd_df) + + all_null_gpd = gpd.GeoDataFrame( + {"geometry": [None]}, + crs="EPSG:4326", + ) + with ps.option_context("compute.ops_on_diff_frames", True): + all_null_sgpd = GeoDataFrame(all_null_gpd) + assert all_null_sgpd.crs == "EPSG:4326" + assert all_null_sgpd.to_geopandas().crs == "EPSG:4326" @pytest.mark.parametrize( "obj", @@ -306,6 +319,47 @@ def test_set_crs(self): # Ensure set_crs without inplace modifies a copy and not current df assert sgpd_df.crs is None + all_null = sgpd.GeoDataFrame({"geometry": [None], "value": [1]}) + with ps.option_context("compute.ops_on_diff_frames", True): + result = all_null.set_crs(4326) + assert result.crs.to_epsg() == 4326 + assert result.geometry.crs.to_epsg() == 4326 + assert result.to_geopandas().crs.to_epsg() == 4326 + assert all_null.crs is None + + with ps.option_context("compute.ops_on_diff_frames", True): + result.set_crs(3857, inplace=True, allow_override=True) + assert result.crs.to_epsg() == 3857 + + with ps.option_context("compute.ops_on_diff_frames", True): + result.crs = None + assert result.crs is None + + from pyproj import CRS + + custom_crs = CRS.from_proj4( + "+proj=aeqd +lat_0=12.345 +lon_0=67.89 " "+datum=WGS84 +units=m +no_defs" + ) + with ps.option_context("compute.ops_on_diff_frames", True): + custom_result = all_null.set_crs(custom_crs) + assert custom_result.crs == custom_crs + assert custom_result.to_geopandas().crs == custom_crs + + def test_crs_metadata_survives_frame_selection(self): + source = GeoSeries([None], name="geometry", crs=4326) + with ps.option_context("compute.ops_on_diff_frames", True): + frame = GeoDataFrame({"value": [1]}).set_geometry(source) + + selected = frame["geometry"] + projected = frame[["value", "geometry"]] + filtered = frame[frame["value"] > 1] + + assert selected.crs.to_epsg() == 4326 + assert projected.crs.to_epsg() == 4326 + assert filtered.crs.to_epsg() == 4326 + assert len(filtered) == 0 + assert filtered.to_geopandas().crs.to_epsg() == 4326 + def test_to_crs(self): from pyproj import CRS @@ -403,6 +457,62 @@ def test_set_geometry_crs(self): assert df.crs == "EPSG:3857" assert df.geometry.crs == "EPSG:3857" + all_null = GeoSeries([None], name="shape", crs="EPSG:4326") + with ps.option_context("compute.ops_on_diff_frames", True): + df = GeoDataFrame({"value": [1]}).set_geometry(all_null) + + assert df.active_geometry_name == "shape" + assert df.crs == "EPSG:4326" + assert df.geometry.crs == "EPSG:4326" + + copied = df.copy() + reconstructed = GeoDataFrame(df) + assert copied.crs == "EPSG:4326" + assert reconstructed.crs == "EPSG:4326" + + same_geometry = df.set_geometry("shape") + assert same_geometry.crs == "EPSG:4326" + + with ps.option_context("compute.ops_on_diff_frames", True): + switchable = GeoDataFrame({"other": [Point(0, 0)]}).set_geometry(all_null) + + switched = switchable.set_geometry("other") + assert switched.crs is None + assert switchable.crs == "EPSG:4326" + assert switched.set_geometry("shape").crs == "EPSG:4326" + + switchable.set_geometry("other", inplace=True) + assert switchable.crs is None + switchable.set_geometry("shape", inplace=True) + assert switchable.crs == "EPSG:4326" + + renamed = df.rename_geometry("renamed") + assert renamed.crs == "EPSG:4326" + assert renamed.active_geometry_name == "renamed" + + replacement = GeoSeries([None], name="shape", crs="EPSG:3857") + with ps.option_context("compute.ops_on_diff_frames", True): + df["shape"] = replacement + assert df.crs == "EPSG:3857" + + property_replacement = GeoSeries([None], name="shape", crs="EPSG:26909") + with ps.option_context("compute.ops_on_diff_frames", True): + df.geometry = property_replacement + assert df.crs == "EPSG:26909" + + first = GeoSeries([None], name="first", crs="EPSG:4326") + second = GeoSeries([None], name="second", crs="EPSG:3857") + with ps.option_context("compute.ops_on_diff_frames", True): + multi_crs = GeoDataFrame({"value": [1]}).set_geometry(first) + multi_crs["second"] = second + assert multi_crs.set_geometry("second").crs == "EPSG:3857" + assert multi_crs.set_geometry("second").set_geometry("first").crs == "EPSG:4326" + + with ps.option_context("compute.ops_on_diff_frames", True): + independent = GeoDataFrame({"value": [1]}).set_geometry(all_null) + all_null.set_crs(3857, inplace=True, allow_override=True) + assert independent.crs == "EPSG:4326" + def test_active_geometry_name(self): if parse_version(gpd.__version__) < parse_version("1.0.0"): return diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py index 6bf2db569c4..4dadb448cad 100644 --- a/python/tests/geopandas/test_geoseries.py +++ b/python/tests/geopandas/test_geoseries.py @@ -4765,6 +4765,8 @@ def test_project(self): self.check_pd_series_equal(df_result, expected) def test_set_crs(self): + from pyproj import CRS + geo_series = sgpd.GeoSeries([Point(0, 0), Point(1, 1)], name="geometry") assert geo_series.crs == None geo_series = geo_series.set_crs(epsg=4326) @@ -4790,9 +4792,65 @@ def test_set_crs(self): geo_series = sgpd.GeoSeries(self.geoseries, crs=4326) assert geo_series.crs.to_epsg() == 4326 + all_null = sgpd.GeoSeries([None], name="geometry", crs=4326) + assert all_null.crs.to_epsg() == 4326 + assert all_null.copy(deep=True).crs.to_epsg() == 4326 + + without_crs = all_null.set_crs(None, allow_override=True) + assert without_crs.crs is None + assert all_null.crs.to_epsg() == 4326 + + with_other_crs = all_null.set_crs(3857, allow_override=True) + assert with_other_crs.crs.to_epsg() == 3857 + assert all_null.crs.to_epsg() == 4326 + + empty_result = sgpd.GeoSeries( + [GeometryCollection()], + crs=4326, + ).explode(ignore_index=True) + assert len(empty_result) == 0 + assert empty_result.crs.to_epsg() == 4326 + assert empty_result.set_crs(3857, allow_override=True).crs.to_epsg() == 3857 + + all_null.set_crs(None, inplace=True, allow_override=True) + assert all_null.crs is None + + custom_crs = CRS.from_proj4( + "+proj=aeqd +lat_0=12.345 +lon_0=67.89 " "+datum=WGS84 +units=m +no_defs" + ) + assert custom_crs.to_epsg() is None + custom_series = sgpd.GeoSeries([Point(0, 0)]).set_crs(custom_crs) + assert custom_series.crs == custom_crs + assert custom_series.to_geopandas().crs == custom_crs + assert ( + custom_series._internal.spark_frame.select( + stf.ST_SRID(custom_series.spark.column).alias("srid") + ).first()["srid"] + == 0 + ) + # This test errors due to a bug in pyspark. # We can uncomment it once the fix is https://github.com/apache/spark/pull/51475 is merged # It was tested locally by using the fixed version of pyspark # # First element null # geo_series = sgpd.GeoSeries([None, None, Point(1, 1)], crs=4326) # assert geo_series.crs.to_epsg() == 4326 + + def test_crs_metadata_propagation(self): + source = sgpd.GeoSeries([None], name="geometry", crs=4326) + + copied = source.copy(deep=True) + reconstructed = sgpd.GeoSeries(source) + buffered = source.buffer(1) + transformed = source.to_crs(3857) + frame = source.to_geoframe() + round_tripped = GeoDataFrame(frame.to_spark_pandas()) + + source.set_crs(3857, inplace=True, allow_override=True) + + assert copied.crs.to_epsg() == 4326 + assert reconstructed.crs.to_epsg() == 4326 + assert buffered.crs.to_epsg() == 4326 + assert frame.crs.to_epsg() == 4326 + assert round_tripped.crs.to_epsg() == 4326 + assert transformed.crs.to_epsg() == 3857