Skip to content

Commit dda84ed

Browse files
committed
Two-tier CRS resolution: lite CRS first, pyproj fallback (#1057)
1 parent f87cfa4 commit dda84ed

File tree

2 files changed

+113
-12
lines changed

2 files changed

+113
-12
lines changed

xrspatial/reproject/_crs_utils.py

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,86 @@
1-
"""CRS detection utilities and optional pyproj import guard."""
1+
"""CRS detection utilities and optional pyproj import guard.
2+
3+
Uses a two-tier strategy: try the lightweight built-in CRS first,
4+
then fall back to pyproj for codes/formats not in the built-in table.
5+
"""
26
from __future__ import annotations
37

8+
from xrspatial.reproject._lite_crs import CRS as LiteCRS
49

5-
def _require_pyproj():
6-
"""Import and return the pyproj module, raising a clear error if missing."""
10+
11+
def _try_import_pyproj():
12+
"""Try to import pyproj, returning the module or None."""
713
try:
814
import pyproj
915
return pyproj
1016
except ImportError:
17+
return None
18+
19+
20+
def _require_pyproj():
21+
"""Import and return the pyproj module, raising a clear error if missing."""
22+
pyproj = _try_import_pyproj()
23+
if pyproj is None:
1124
raise ImportError(
1225
"pyproj is required for CRS reprojection. "
1326
"Install it with: pip install pyproj "
1427
"or: pip install xarray-spatial[reproject]"
1528
)
29+
return pyproj
1630

1731

1832
def _resolve_crs(crs_input):
19-
"""Convert *crs_input* to a ``pyproj.CRS`` object.
20-
21-
Accepts anything ``pyproj.CRS()`` accepts: EPSG int, authority string,
22-
WKT, proj4 dict, or an existing ``pyproj.CRS`` instance.
23-
24-
Returns None if *crs_input* is None.
33+
"""Convert *crs_input* to a CRS object.
34+
35+
Resolution order:
36+
37+
1. ``None`` passes through as ``None``.
38+
2. An existing ``LiteCRS`` instance passes through unchanged.
39+
3. An existing ``pyproj.CRS`` instance passes through unchanged
40+
(only checked when pyproj is importable).
41+
4. Try ``LiteCRS(crs_input)`` -- covers EPSG ints and ``"EPSG:XXXX"``
42+
strings for codes in the built-in table.
43+
5. Fall back to ``pyproj.CRS(crs_input)`` -- raises ``ImportError``
44+
if pyproj is not installed.
2545
"""
2646
if crs_input is None:
2747
return None
28-
pyproj = _require_pyproj()
29-
if isinstance(crs_input, pyproj.CRS):
48+
49+
# Pass through existing LiteCRS
50+
if isinstance(crs_input, LiteCRS):
51+
return crs_input
52+
53+
# Pass through existing pyproj.CRS (if pyproj available)
54+
pyproj = _try_import_pyproj()
55+
if pyproj is not None and isinstance(crs_input, pyproj.CRS):
3056
return crs_input
57+
58+
# Try lite CRS first
59+
try:
60+
return LiteCRS(crs_input)
61+
except (ValueError, TypeError):
62+
pass
63+
64+
# Fall back to pyproj
65+
pyproj = _require_pyproj()
3166
return pyproj.CRS(crs_input)
3267

3368

69+
def _crs_from_wkt(wkt):
70+
"""Build a CRS from an OGC WKT string.
71+
72+
Tries ``LiteCRS.from_wkt`` first (extracts the AUTHORITY tag),
73+
then falls back to ``pyproj.CRS.from_wkt``.
74+
"""
75+
try:
76+
return LiteCRS.from_wkt(wkt)
77+
except (ValueError, TypeError):
78+
pass
79+
80+
pyproj = _require_pyproj()
81+
return pyproj.CRS.from_wkt(wkt)
82+
83+
3484
def _detect_source_crs(raster):
3585
"""Auto-detect the CRS of a DataArray.
3686
@@ -47,7 +97,7 @@ def _detect_source_crs(raster):
4797

4898
crs_wkt = raster.attrs.get('crs_wkt')
4999
if crs_wkt is not None:
50-
return _resolve_crs(crs_wkt)
100+
return _crs_from_wkt(crs_wkt)
51101

52102
# rioxarray fallback
53103
try:

xrspatial/tests/test_lite_crs.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,57 @@ def test_wkt_contains_authority(self):
163163
assert 'AUTHORITY["EPSG","4326"]' in wkt
164164

165165

166+
# -----------------------------------------------------------------------
167+
# Two-tier CRS resolution (_crs_utils integration)
168+
# -----------------------------------------------------------------------
169+
try:
170+
import pyproj as _pyproj_mod
171+
_HAS_PYPROJ = True
172+
except ImportError:
173+
_HAS_PYPROJ = False
174+
175+
from xrspatial.reproject._crs_utils import _resolve_crs, _crs_from_wkt
176+
177+
178+
class TestTwoTierResolution:
179+
def test_resolve_crs_int_uses_lite(self):
180+
result = _resolve_crs(4326)
181+
assert isinstance(result, CRS)
182+
assert result.to_epsg() == 4326
183+
184+
def test_resolve_crs_string_uses_lite(self):
185+
result = _resolve_crs("EPSG:32632")
186+
assert isinstance(result, CRS)
187+
assert result.to_epsg() == 32632
188+
189+
@pytest.mark.skipif(not _HAS_PYPROJ, reason="pyproj not installed")
190+
def test_resolve_crs_unknown_falls_back(self):
191+
result = _resolve_crs(2193)
192+
assert not isinstance(result, CRS)
193+
assert hasattr(result, "to_epsg")
194+
assert result.to_epsg() == 2193
195+
196+
def test_resolve_crs_none_returns_none(self):
197+
assert _resolve_crs(None) is None
198+
199+
def test_resolve_crs_passes_through_lite_crs(self):
200+
lite = CRS(4326)
201+
result = _resolve_crs(lite)
202+
assert result is lite
203+
204+
@pytest.mark.skipif(not _HAS_PYPROJ, reason="pyproj not installed")
205+
def test_resolve_crs_passes_through_pyproj(self):
206+
pp = _pyproj_mod.CRS.from_epsg(4326)
207+
result = _resolve_crs(pp)
208+
assert result is pp
209+
210+
def test_crs_from_wkt_lite(self):
211+
wkt = CRS(4326).to_wkt()
212+
result = _crs_from_wkt(wkt)
213+
assert isinstance(result, CRS)
214+
assert result.to_epsg() == 4326
215+
216+
166217
# -----------------------------------------------------------------------
167218
# Validate against pyproj (skipped when pyproj not installed)
168219
# -----------------------------------------------------------------------

0 commit comments

Comments
 (0)