Skip to content

Commit 49d61b3

Browse files
authored
Refactor CRS-aware index (#27)
* refactor CRS-aware index ProjIndexMixin class: - replace the ``_proj_get_crs`` abstract method by the ``crs`` abstract (public) property. - add ``_proj_crs_equals`` helper method Xproj now identifies an Xarray index as CRS-aware if it has a ``crs`` property that returns either a ``pyproj.CRS`` object or ``None``. A CRS-aware index may have an undefined CRS (``None``). Add ``CRSAwareIndex`` protocol class. * add a couple of CRS utility functions Copied and adapted from xvec. * CI: mypy install additional types * doc: add crs property in mixin class API * docstring tweaks * misc. typing fixes * ci: temp fix (xarray upstream)
1 parent 6e0da23 commit 49d61b3

File tree

15 files changed

+338
-68
lines changed

15 files changed

+338
-68
lines changed

.github/workflows/ci-additional.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,4 @@ jobs:
4545
4646
- name: Run mypy
4747
run: |
48-
python -m mypy
48+
python -m mypy --install-types --non-interactive

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ jobs:
3737
- name: Install xproj
3838
run: |
3939
python -m pip install -e .[test]
40+
# TODO: remove in next xarray release (https://github.com/pydata/xarray/pull/10413)
41+
python -m pip install typing_extensions
4042
python -m pip install pytest-cov
4143
4244
- name: Run tests

docs/_templates/autosummary/mixin.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
.. autosummary::
1010
{% for item in members %}
11-
{% if item.startswith('_proj') %}
11+
{% if item.startswith('_proj') or item == "crs" %}
1212
~{{ name }}.{{ item }}
1313
{% endif %}
1414
{%- endfor %}

docs/api-hidden.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
:toctree: _api_generated/
1313

1414
ProjAccessorMixin._proj_set_crs
15-
ProjIndexMixin._proj_get_crs
15+
ProjIndexMixin.crs
16+
ProjIndexMixin._proj_crs_equals
1617
ProjIndexMixin._proj_set_crs
1718
ProjIndexMixin._proj_to_crs
1819
format_compact_cf

docs/api.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ To enable it, be sure to import ``xproj`` after ``xarray``:
7474

7575
.. currentmodule:: xproj
7676

77+
CRS utility functions
78+
---------------------
79+
80+
.. autosummary::
81+
:toctree: _api_generated/
82+
83+
format_crs
84+
get_common_crs
85+
7786
3rd-party Xarray extensions
7887
---------------------------
7988

docs/integration.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ class GeoIndex(xr.indexes.PandasIndex, xproj.ProjIndexMixin):
126126
127127
return super().sel(*args, **kwargs)
128128
129-
def _proj_get_crs(self):
129+
@property
130+
def crs(self):
130131
return self._crs
131132
132133
def _proj_set_crs(self, spatial_ref, crs):
@@ -184,8 +185,9 @@ CRS-aware! (just a warning is emitted below).
184185
ds_geo_wgs84.sel(lat=70)
185186
```
186187

187-
Since ``GeoIndex`` also implements the ``_proj_get_crs`` method it is possible
188-
to get the CRS from the "lat" coordinate like so:
188+
``GeoIndex`` has a ``crs`` property (as required by
189+
{class}`~xproj.ProjIndexMixin`), which is possible to access also via the
190+
``proj`` accessor like so:
189191

190192
```{code-cell} ipython3
191193
ds_geo_wgs84.proj("lat").crs

xproj/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from .accessor import ProjAccessor as _ProjAccessor # noqa: F401
44
from .accessor import register_accessor
5-
from .crs_utils import format_compact_cf, format_full_cf_gdal
5+
from .crs_utils import format_compact_cf, format_crs, format_full_cf_gdal, get_common_crs
66
from .index import CRSIndex # noqa: F401
77
from .mixins import ProjAccessorMixin, ProjIndexMixin
88

@@ -12,7 +12,9 @@
1212
"ProjAccessorMixin",
1313
"ProjIndexMixin",
1414
"format_compact_cf",
15+
"format_crs",
1516
"format_full_cf_gdal",
17+
"get_common_crs",
1618
"register_accessor",
1719
]
1820

xproj/accessor.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from xproj.crs_utils import format_compact_cf
1111
from xproj.index import CRSIndex
1212
from xproj.mixins import ProjIndexMixin
13+
from xproj.typing import CRSAwareIndex
1314
from xproj.utils import Frozen, FrozenDict
1415

1516

@@ -94,21 +95,31 @@ class CRSProxy:
9495

9596
_obj: xr.Dataset | xr.DataArray
9697
_crs_coord_name: Hashable
97-
_crs: pyproj.CRS
98+
_crs: pyproj.CRS | None
9899

99-
def __init__(self, obj: xr.Dataset | xr.DataArray, coord_name: Hashable, crs: pyproj.CRS):
100+
def __init__(
101+
self, obj: xr.Dataset | xr.DataArray, coord_name: Hashable, crs: pyproj.CRS | None
102+
):
100103
self._obj = obj
101104
self._crs_coord_name = coord_name
102105
self._crs = crs
103106

104107
@property
105-
def crs(self) -> pyproj.CRS:
106-
"""Return the coordinate reference system as a :class:`pyproj.CRS` object."""
108+
def crs(self) -> pyproj.CRS | None:
109+
"""Return the coordinate reference system as a :class:`pyproj.CRS` object, or
110+
``None`` if the CRS is undefined.
111+
"""
107112
return self._crs
108113

109114

110115
def is_crs_aware(index: xr.Index) -> bool:
111-
return isinstance(index, ProjIndexMixin) or hasattr(index, "_proj_get_crs")
116+
if isinstance(index, ProjIndexMixin):
117+
return True
118+
if hasattr(index, "crs"):
119+
crs = getattr(index, "crs")
120+
if isinstance(crs, pyproj.CRS) or crs is None:
121+
return True
122+
return False
112123

113124

114125
@xr.register_dataset_accessor("proj")
@@ -118,7 +129,7 @@ class ProjAccessor:
118129

119130
_obj: xr.Dataset | xr.DataArray
120131
_crs_indexes: dict[Hashable, CRSIndex] | None
121-
_crs_aware_indexes: dict[Hashable, xr.Index] | None
132+
_crs_aware_indexes: dict[Hashable, CRSAwareIndex] | None
122133
_crs: pyproj.CRS | None | Literal[False]
123134

124135
def __init__(self, obj: xr.Dataset | xr.DataArray):
@@ -138,7 +149,7 @@ def _cache_all_crs_indexes(self):
138149
self._crs_indexes[name] = idx
139150
elif is_crs_aware(idx):
140151
for name in vars:
141-
self._crs_aware_indexes[name] = idx
152+
self._crs_aware_indexes[name] = cast(CRSAwareIndex, idx)
142153

143154
@property
144155
def crs_indexes(self) -> Frozen[Hashable, CRSIndex]:
@@ -154,13 +165,12 @@ def crs_indexes(self) -> Frozen[Hashable, CRSIndex]:
154165
return FrozenDict(self._crs_indexes)
155166

156167
@property
157-
def crs_aware_indexes(self) -> Frozen[Hashable, xr.Index]:
168+
def crs_aware_indexes(self) -> Frozen[Hashable, CRSAwareIndex]:
158169
"""Return an immutable dictionary of coordinate names as keys and
159170
xarray Index objects that are CRS-aware.
160171
161172
A :term:`CRS-aware index` is an :py:class:`xarray.Index` object that
162-
must at least implements a method like
163-
:py:meth:`~xproj.ProjIndexMixin._proj_get_crs`.
173+
must at least implement a property like :py:meth:`~xproj.ProjIndexMixin.crs`.
164174
165175
"""
166176
if self._crs_aware_indexes is None:
@@ -205,10 +215,10 @@ def __call__(self, coord_name: Hashable):
205215
A proxy accessor for a single CRS.
206216
207217
"""
208-
crs: pyproj.CRS
218+
crs: pyproj.CRS | None
209219

210220
if coord_name in self.crs_aware_indexes:
211-
crs = self.crs_aware_indexes[coord_name]._proj_get_crs() # type: ignore
221+
crs = self.crs_aware_indexes[coord_name].crs
212222
else:
213223
crs = self._get_crs_index(coord_name).crs
214224

@@ -236,7 +246,7 @@ def crs(self) -> pyproj.CRS | None:
236246
if self._crs is False:
237247
all_crs = {name: idx.crs for name, idx in self.crs_indexes.items()}
238248
for name, idx in self.crs_aware_indexes.items():
239-
crs = idx._proj_get_crs() # type: ignore
249+
crs = idx.crs
240250
if crs is not None:
241251
all_crs[name] = crs
242252

@@ -387,7 +397,7 @@ def map_crs(
387397
)
388398
continue
389399

390-
index_crs = index._proj_get_crs() # type: ignore
400+
index_crs = cast(CRSAwareIndex, index).crs
391401

392402
if not allow_override:
393403
if index_crs is not None and index_crs != crs:

xproj/crs_utils.py

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
1-
from typing import Any
1+
import warnings
2+
from collections.abc import Sequence
3+
from typing import Any, Literal
24

3-
import pyproj
5+
from pyproj import CRS
46

57

6-
def format_compact_cf(crs: pyproj.CRS) -> dict[str, Any]:
8+
def format_crs(crs: CRS | None, max_width: int = 50) -> str:
9+
"""Format CRS as a string.
10+
11+
Parameters
12+
----------
13+
crs : pyproj.crs.CRS
14+
The input CRS object to format.
15+
max_width : int, optional
16+
Maximum number of characters beyond which the formatted CRS
17+
will be truncated (default: 50).
18+
19+
"""
20+
if crs is not None:
21+
srs = crs.to_string()
22+
else:
23+
srs = "None"
24+
25+
return srs if len(srs) <= max_width else " ".join([srs[:max_width], "..."])
26+
27+
28+
def format_compact_cf(crs: CRS) -> dict[str, Any]:
729
"""Format CRS as a dictionary for minimal compatibility with
830
CF conventions.
931
@@ -30,7 +52,7 @@ def format_compact_cf(crs: pyproj.CRS) -> dict[str, Any]:
3052
return {"crs_wkt": crs.to_wkt()}
3153

3254

33-
def format_full_cf_gdal(crs: pyproj.CRS) -> dict[str, Any]:
55+
def format_full_cf_gdal(crs: CRS) -> dict[str, Any]:
3456
"""Format CRS as a dictionary for full compatibility with
3557
CF conventions and GDAL.
3658
@@ -61,3 +83,65 @@ def format_full_cf_gdal(crs: pyproj.CRS) -> dict[str, Any]:
6183
output = crs.to_cf()
6284
output["spatial_ref"] = crs.to_wkt()
6385
return output
86+
87+
88+
def get_common_crs(
89+
crs_objs: Sequence[CRS | None] | set[CRS | None],
90+
on_undefined_crs: Literal["raise", "warn", "ignore"] = "warn",
91+
stacklevel: int = 3,
92+
) -> CRS | None:
93+
"""Try getting a common, unique CRS from an input sequence of (possibly
94+
undefined) CRS objects.
95+
96+
Parameters
97+
----------
98+
crs_objs : sequence or set
99+
Sequence of either :py:class:`pyproj.CRS` objects or ``None``
100+
(undefined CRS).
101+
on_undefined_crs : {"raise", "warn", "ignore"}, optional
102+
If "raise", raises a ValueError if a non-null CRS is found but
103+
one or more inputs have undefined CRS. If "warn" (default), emits a
104+
UserWarning instead. If "ignore", do nothing.
105+
stacklevel : int, optional
106+
Stack level value used for the emitted warning (default: 3).
107+
108+
Returns
109+
-------
110+
pyproj.crs.CRS or None
111+
The common (possibly undefined) CRS.
112+
113+
Raises
114+
------
115+
ValueError
116+
If multiple conflicting CRS objects are found.
117+
118+
Warns
119+
-----
120+
UserWarning
121+
If a common, unique CRS is found but one or more of the
122+
inputs have undefined CRS.
123+
124+
"""
125+
# code taken from geopandas (BSD-3 Licence)
126+
127+
crs_objs = set(crs_objs)
128+
129+
crs_not_none = [crs for crs in crs_objs if crs is not None]
130+
names = [crs.name for crs in crs_not_none]
131+
132+
if len(crs_not_none) == 0:
133+
return None
134+
if len(crs_not_none) == 1:
135+
if len(crs_objs) != 1:
136+
if on_undefined_crs == "raise":
137+
raise ValueError("one or more inputs have undefined CRS.")
138+
elif on_undefined_crs == "warn":
139+
warnings.warn( # noqa: B028
140+
"CRS is undefined for some of the inputs. "
141+
f"Setting output's CRS as {names[0]} "
142+
"(the single non-null CRS provided).",
143+
stacklevel=stacklevel,
144+
)
145+
return crs_not_none[0]
146+
147+
raise ValueError(f"cannot determine common CRS from inputs CRSes {names}. ")

xproj/index.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from __future__ import annotations
22

3-
from _collections_abc import Mapping
3+
from collections.abc import Hashable, Mapping
44
from typing import Any
55

66
import pyproj
77
import xarray as xr
8+
from pyproj.exceptions import CRSError
89
from xarray.indexes import Index
910

1011

@@ -69,15 +70,15 @@ def from_variables(
6970
crs = pyproj.CRS.from_user_input(options["crs"])
7071
else:
7172
crs = pyproj.CRS.from_cf(var.attrs)
72-
except pyproj.crs.CRSError:
73+
except CRSError:
7374
raise ValueError(
7475
f"CRS could not be constructed from attrs on provided variable {varname!r}"
7576
f"Either add appropriate attributes to {varname!r} or pass a `crs` kwarg."
7677
)
7778

7879
return cls(crs)
7980

80-
def equals(self, other: Index) -> bool:
81+
def equals(self, other: Index, *, exclude: frozenset[Hashable] | None = None) -> bool:
8182
if not isinstance(other, CRSIndex):
8283
return False
8384
if not self.crs == other.crs:

0 commit comments

Comments
 (0)