Skip to content

Commit 6c897b0

Browse files
authored
XProj integration (#120)
* add xproj as a required dependency * remove optional xproj checks Note: with HAS_XPROJ checks removed it is now possible that accessing the CRS via `.proj.crs` returns an error (when multiple CRSs are found in the Xarray object) instead of setting `None` (when HAS_XPROJ was False). This is possible in theory but quite unlikely in practice, though. * make GeometryIndex an xproj's CRS-aware index GeometryIndex now inherits from `xproj.ProjIndexMixin`. Reuse functionality that has been implemented in xproj (moved from xvec). Some warning and/or error messages may slightly change, although I tried to keep them clear and meaningful. * more xproj integration Set or convert the CRS of a GeometryIndex via XProj's API.
1 parent 2dc635f commit 6c897b0

File tree

7 files changed

+108
-110
lines changed

7 files changed

+108
-110
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies = [
3131
"pyproj >= 3.0.0",
3232
"shapely >= 2.0b1",
3333
"cf_xarray >= 0.9.2",
34+
"xproj >= 0.2.0",
3435
]
3536

3637
[project.optional-dependencies]
@@ -69,4 +70,4 @@ ignore = ['E501', 'Q000', 'Q001', 'Q002', 'Q003', 'W191', 'C408']
6970
select = ["E", "F", "W", "I", "UP", "B", "A", "C4", "Q"]
7071

7172
[tool.ruff.lint.per-file-ignores]
72-
"doc/**" = ["F401"]
73+
"doc/**" = ["F401"]

xvec/accessor.py

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
import pandas as pd
1010
import shapely
1111
import xarray as xr
12-
from pyproj import CRS, Transformer
12+
import xproj # noqa: F401
13+
from pyproj import CRS
1314

1415
from .index import GeometryIndex
1516
from .plotting import _plot
17+
from .utils import transform_geom
1618
from .zonal import (
1719
_variable_zonal,
1820
_zonal_stats_exactextract,
@@ -23,13 +25,6 @@
2325
if TYPE_CHECKING:
2426
from geopandas import GeoDataFrame
2527

26-
try:
27-
import xproj # noqa: F401
28-
29-
HAS_XPROJ = True
30-
except ImportError:
31-
HAS_XPROJ = False
32-
3328

3429
@xr.register_dataarray_accessor("xvec")
3530
@xr.register_dataset_accessor("xvec")
@@ -361,7 +356,7 @@ def to_crs(
361356
"handling projection information."
362357
)
363358

364-
data = _obj[key]
359+
data = _obj[key].data
365360
data_crs = self._obj.xindexes[key].crs # type: ignore
366361

367362
# transformation code taken from geopandas (BSD 3-clause license)
@@ -376,25 +371,7 @@ def to_crs(
376371
if data_crs.is_exact_same(crs):
377372
pass
378373

379-
transformer = Transformer.from_crs(data_crs, crs, always_xy=True)
380-
381-
has_z = shapely.has_z(data)
382-
383-
result = np.empty_like(data)
384-
385-
coordinates = shapely.get_coordinates(data[~has_z], include_z=False)
386-
new_coords = transformer.transform(coordinates[:, 0], coordinates[:, 1])
387-
result[~has_z] = shapely.set_coordinates(
388-
data[~has_z].copy(), np.array(new_coords).T
389-
)
390-
391-
coords_z = shapely.get_coordinates(data[has_z], include_z=True)
392-
new_coords_z = transformer.transform(
393-
coords_z[:, 0], coords_z[:, 1], coords_z[:, 2]
394-
)
395-
result[has_z] = shapely.set_coordinates(
396-
data[has_z].copy(), np.array(new_coords_z).T
397-
)
374+
result = transform_geom(data, data_crs, crs)
398375

399376
transformed[key] = (result, crs)
400377

@@ -973,9 +950,7 @@ def to_geodataframe(
973950

974951
if geometry is not None:
975952
if geometry not in self._geom_coords_all: # variable geometry
976-
return df.set_geometry(
977-
geometry, crs=self._obj.proj.crs if HAS_XPROJ else None
978-
)
953+
return df.set_geometry(geometry, crs=self._obj.proj.crs)
979954

980955
# coordinate geometry
981956
return df.set_geometry(
@@ -987,7 +962,7 @@ def to_geodataframe(
987962
name if name else (self._obj.name if hasattr(self._obj, "name") else None)
988963
)
989964
if name is not None and shapely.is_valid_input(df[name]).all():
990-
return df.set_geometry(name, crs=self._obj.proj.crs if HAS_XPROJ else None)
965+
return df.set_geometry(name, crs=self._obj.proj.crs)
991966

992967
warnings.warn(
993968
"No active geometry column to be set. The resulting object "
@@ -1525,15 +1500,15 @@ def encode_wkb(self) -> xr.DataArray | xr.Dataset:
15251500
if isinstance(obj, xr.DataArray):
15261501
if np.all(shapely.is_valid_input(obj.data)):
15271502
obj = shapely.to_wkb(obj)
1528-
if HAS_XPROJ and obj.proj.crs:
1503+
if obj.proj.crs:
15291504
obj.attrs["crs"] = obj.proj.crs.to_json()
15301505
obj.attrs["wkb_encoded_geometry"] = True
15311506

15321507
else:
15331508
for data in obj.data_vars:
15341509
if np.all(shapely.is_valid_input(obj[data].data)):
15351510
obj[data] = shapely.to_wkb(obj[data])
1536-
if HAS_XPROJ and obj[data].proj.crs:
1511+
if obj[data].proj.crs:
15371512
obj[data].attrs["crs"] = obj[data].proj.crs.to_json()
15381513
obj[data].attrs["wkb_encoded_geometry"] = True
15391514

@@ -1565,7 +1540,7 @@ def decode_wkb(self) -> xr.DataArray | xr.Dataset:
15651540
if isinstance(obj, xr.DataArray):
15661541
if obj.attrs.get("wkb_encoded_geometry", False):
15671542
obj.data = shapely.from_wkb(obj)
1568-
if HAS_XPROJ and "crs" in obj.attrs:
1543+
if "crs" in obj.attrs:
15691544
obj = obj.proj.assign_crs(
15701545
spatial_ref=json.loads(obj.attrs.pop("crs")),
15711546
allow_override=True,
@@ -1576,7 +1551,7 @@ def decode_wkb(self) -> xr.DataArray | xr.Dataset:
15761551
for data in obj.data_vars:
15771552
if obj[data].attrs.get("wkb_encoded_geometry", False):
15781553
obj[data].data = shapely.from_wkb(obj[data])
1579-
if HAS_XPROJ and "crs" in obj[data].attrs:
1554+
if "crs" in obj[data].attrs:
15801555
obj = obj.proj.assign_crs(
15811556
spatial_ref=json.loads(obj[data].attrs.pop("crs")),
15821557
allow_override=True,
@@ -1675,9 +1650,7 @@ def _union(x: xr.DataArray) -> xr.DataArray:
16751650
return (
16761651
self._obj.assign_coords(summary_geometry=(dim, summary))
16771652
.set_xindex("summary_geometry")
1678-
.xvec.set_geom_indexes(
1679-
"summary_geometry", crs=self._obj.proj.crs if HAS_XPROJ else None
1680-
)
1653+
.xvec.set_geom_indexes("summary_geometry", crs=self._obj.proj.crs)
16811654
)
16821655

16831656
def plot(

xvec/index.py

Lines changed: 35 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,46 +7,16 @@
77
import numpy as np
88
import pandas as pd
99
import shapely
10+
import xproj
1011
from pyproj import CRS
1112
from xarray import DataArray, Variable, get_options
1213
from xarray.core.indexing import IndexSelResult
1314
from xarray.indexes import Index, PandasIndex
1415

16+
from xvec.utils import transform_geom
1517

16-
def _format_crs(crs: CRS | None, max_width: int = 50) -> str:
17-
if crs is not None:
18-
srs = crs.to_string()
19-
else:
20-
srs = "None"
2118

22-
return srs if len(srs) <= max_width else " ".join([srs[:max_width], "..."])
23-
24-
25-
def _get_common_crs(crs_set: set[CRS | None]) -> CRS | None:
26-
# code taken from geopandas (BSD-3 Licence)
27-
28-
crs_not_none = [crs for crs in crs_set if crs is not None]
29-
names = [crs.name for crs in crs_not_none]
30-
31-
if len(crs_not_none) == 0:
32-
return None
33-
if len(crs_not_none) == 1:
34-
if len(crs_set) != 1:
35-
warnings.warn( # noqa: B028
36-
"CRS not set for some of the concatenation inputs. "
37-
f"Setting output's CRS as {names[0]} "
38-
"(the single non-null CRS provided).",
39-
stacklevel=3,
40-
)
41-
return crs_not_none[0]
42-
43-
raise ValueError(
44-
f"cannot determine common CRS for concatenation inputs, got {names}. "
45-
# "Use `to_crs()` to transform geometries to the same CRS before merging."
46-
)
47-
48-
49-
class GeometryIndex(Index):
19+
class GeometryIndex(Index, xproj.ProjIndexMixin):
5020
"""An CRS-aware, Xarray-compatible index for vector geometries.
5121
5222
This index can be set from any 1-dimensional coordinate of
@@ -89,6 +59,30 @@ def crs(self) -> CRS | None:
8959
"""
9060
return self._crs
9161

62+
def _proj_set_crs(
63+
self: GeometryIndex, spatial_ref: Hashable, crs: CRS
64+
) -> GeometryIndex:
65+
# Returns a geometry index shallow copy with a replaced CRS, without transformation
66+
# (XProj integration via xproj.ProjIndexMixin)
67+
# Note: XProj already handles the case of overriding any existing CRS
68+
return GeometryIndex(self._index, crs=crs)
69+
70+
def _proj_to_crs(
71+
self: GeometryIndex, spatial_ref: Hashable, crs: CRS
72+
) -> GeometryIndex:
73+
# Returns a new geometry index with a replaced CRS and transformed geometries
74+
# (XProj integration via xproj.ProjIndexMixin)
75+
# Note: XProj already handles the case of overriding any existing CRS
76+
77+
# XProj redirects to `._proj_set_crs()` if this index's CRS is undefined
78+
assert self.crs is not None
79+
80+
result = transform_geom(np.asarray(self._index.index), self.crs, crs)
81+
index = PandasIndex(
82+
result, self._index.dim, coord_dtype=self._index.coord_dtype
83+
)
84+
return GeometryIndex(index, crs=crs)
85+
9286
@property
9387
def sindex(self) -> shapely.STRtree:
9488
"""Returns the spatial index, i.e., a :class:`shapely.STRtree` object.
@@ -99,25 +93,14 @@ def sindex(self) -> shapely.STRtree:
9993
self._sindex = shapely.STRtree(self._index.index)
10094
return self._sindex
10195

102-
def _check_crs(self, other_crs: CRS | None, allow_none: bool = False) -> bool:
103-
"""Check if the index's projection is the same than the given one.
104-
If allow_none is True, empty CRS is treated as the same.
105-
"""
106-
if allow_none:
107-
if self.crs is None or other_crs is None:
108-
return True
109-
if not self.crs == other_crs:
110-
return False
111-
return True
112-
11396
def _crs_mismatch_raise(
11497
self, other_crs: CRS | None, warn: bool = False, stacklevel: int = 3
11598
) -> None:
11699
"""Raise a CRS mismatch error or warning with the information
117100
on the assigned CRS.
118101
"""
119-
srs = _format_crs(self.crs, max_width=50)
120-
other_srs = _format_crs(other_crs, max_width=50)
102+
srs = xproj.format_crs(self.crs, max_width=50)
103+
other_srs = xproj.format_crs(other_crs, max_width=50)
121104

122105
# TODO: expand message with reproject suggestion
123106
msg = (
@@ -152,7 +135,7 @@ def concat(
152135
positions: Iterable[Iterable[int]] | None = None,
153136
) -> GeometryIndex:
154137
crs_set = {idx.crs for idx in indexes}
155-
crs = _get_common_crs(crs_set)
138+
crs = xproj.get_common_crs(crs_set)
156139

157140
indexes_ = [idx._index for idx in indexes]
158141
index = PandasIndex.concat(indexes_, dim, positions)
@@ -232,14 +215,14 @@ def equals(
232215
) -> bool:
233216
if not isinstance(other, GeometryIndex):
234217
return False
235-
if not self._check_crs(other.crs, allow_none=True):
218+
if not self._proj_crs_equals(other, allow_none=True):
236219
return False
237220
return self._index.equals(other._index, exclude=exclude)
238221

239222
def join(
240223
self: GeometryIndex, other: GeometryIndex, how: str = "inner"
241224
) -> GeometryIndex:
242-
if not self._check_crs(other.crs, allow_none=True):
225+
if not self._proj_crs_equals(other, allow_none=True):
243226
self._crs_mismatch_raise(other.crs)
244227

245228
index = self._index.join(other._index, how=how)
@@ -251,7 +234,7 @@ def reindex_like(
251234
method: str | None = None,
252235
tolerance: int | float | Iterable[int | float] | None = None,
253236
) -> dict[Hashable, Any]:
254-
if not self._check_crs(other.crs, allow_none=True):
237+
if not self._proj_crs_equals(other, allow_none=True):
255238
self._crs_mismatch_raise(other.crs)
256239

257240
return self._index.reindex_like(
@@ -273,11 +256,11 @@ def _repr_inline_(self, max_width: int) -> str:
273256
if max_width is None:
274257
max_width = get_options()["display_width"]
275258

276-
srs = _format_crs(self.crs, max_width=max_width)
259+
srs = xproj.format_crs(self.crs, max_width=max_width)
277260
return f"{self.__class__.__name__} (crs={srs})"
278261

279262
def __repr__(self) -> str:
280-
srs = _format_crs(self.crs)
263+
srs = xproj.format_crs(self.crs)
281264
shape = self._index.index.shape[0]
282265
if shape == 0:
283266
return f"GeometryIndex([], crs={srs})"

xvec/plotting.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,9 @@
44
import numpy as np
55
import shapely
66
import xarray as xr
7+
import xproj # noqa: F401
78
from xarray.plot.utils import _determine_cmap_params, label_from_attrs
89

9-
try:
10-
import xproj # noqa: F401
11-
12-
HAS_XPROJ = True
13-
except ImportError:
14-
HAS_XPROJ = False
15-
1610

1711
def _setup_axes(n_rows, n_cols, arr, geometry, crs, subplot_kws, figsize):
1812
import matplotlib.pyplot as plt
@@ -67,15 +61,9 @@ def _get_crs(arr, geometry=None):
6761
if geometry:
6862
return arr[geometry].crs
6963
elif np.all(shapely.is_valid_input(arr.data)):
70-
return arr.proj.crs if HAS_XPROJ else None
64+
return arr.proj.crs
7165
return arr.xindexes[list(arr.xvec._geom_coords_all)[0]].crs
72-
return (
73-
arr[geometry].crs
74-
if hasattr(arr[geometry], "crs")
75-
else arr.proj.crs
76-
if HAS_XPROJ
77-
else None
78-
)
66+
return arr[geometry].crs if hasattr(arr[geometry], "crs") else arr.proj.crs
7967

8068

8169
def _setup_legend(fig, cmap_params, label=None):

xvec/tests/test_index.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,7 @@ def test_concat(geom_dataset, geom_array, geom_dataset_no_index, geom_dataset_no
5151
xr.testing.assert_identical(actual, expected)
5252

5353
# mixed CRS / no CRS
54-
with pytest.warns(
55-
UserWarning, match="CRS not set for some of the concatenation inputs"
56-
):
54+
with pytest.warns(UserWarning, match="CRS is undefined for some of the inputs"):
5755
xr.concat([geom_dataset, geom_dataset_no_crs], "geom")
5856

5957

xvec/tests/test_xproj_integration.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import shapely
2+
import xproj # noqa: F401
3+
4+
5+
def test_xproj_crs(geom_dataset):
6+
assert geom_dataset.xindexes["geom"].crs.equals(geom_dataset.proj("geom").crs)
7+
8+
9+
def test_xproj_map_crs(geom_dataset_no_crs):
10+
ds_with_crsindex = geom_dataset_no_crs.proj.assign_crs(spatial_ref=26915)
11+
12+
# set crs
13+
ds = ds_with_crsindex.proj.map_crs(spatial_ref=["geom"])
14+
assert ds.proj("geom").crs.equals(ds.proj("spatial_ref").crs)
15+
16+
# to crs
17+
geom_array_4326 = shapely.from_wkt(
18+
["POINT (-97.488735 0.000018)", "POINT (-97.488717 0.000036)"]
19+
)
20+
21+
ds_with_crsindex2 = ds.proj.assign_crs(spatial_ref=4326, allow_override=True)
22+
ds2 = ds_with_crsindex2.proj.map_crs(
23+
spatial_ref=["geom"], transform=True, allow_override=True
24+
)
25+
assert ds2.proj("geom").crs.equals(ds2.proj("spatial_ref").crs)
26+
assert shapely.equals_exact(geom_array_4326, ds2.geom, tolerance=1e-5).all()

0 commit comments

Comments
 (0)