From 8b5a7bda7bbb8bd15011b8a9be7e0a60e84817ef Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 25 Jun 2026 11:56:56 -0700 Subject: [PATCH 1/2] Add crs_units and crs_name to from_template() result.attrs (#3523) --- xrspatial/reproject/_lite_crs.py | 9 ++++++ xrspatial/templates.py | 27 ++++++++++++++++-- xrspatial/tests/test_templates.py | 46 +++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/xrspatial/reproject/_lite_crs.py b/xrspatial/reproject/_lite_crs.py index 135898ff6..efe90b92a 100644 --- a/xrspatial/reproject/_lite_crs.py +++ b/xrspatial/reproject/_lite_crs.py @@ -286,6 +286,15 @@ def is_geographic(self) -> bool: """True if this CRS is geographic (lat/lon), False if projected.""" return self._entry["_is_geographic"] + @property + def name(self) -> str: + """Human-readable CRS name, e.g. ``"NAD83 / Conus Albers"``. + + Mirrors ``pyproj.CRS.name``. Falls back to ``"EPSG:"`` for + table entries without a curated name. + """ + return self._entry.get("_name", f"EPSG:{self._epsg}") + # -- output methods ------------------------------------------------ def to_epsg(self) -> int: diff --git a/xrspatial/templates.py b/xrspatial/templates.py index 7e01efa5e..eb5ea534b 100644 --- a/xrspatial/templates.py +++ b/xrspatial/templates.py @@ -173,6 +173,20 @@ def _make_data(shape, fill, backend, chunks): ) +def _crs_name(crs): + """Best-effort human-readable name for an EPSG code. + + Resolves through the same two-tier path as the rest of the library + (built-in ``LiteCRS`` table first, pyproj fallback). Returns ``None`` + when the code is outside the built-in table and pyproj is not + installed, so the default (non-reproject) path stays dependency-free. + """ + try: + return _resolve_crs(crs).name + except (ImportError, ValueError, TypeError): + return None + + def from_template(name, resolution=None, *, preserve=None, backend="numpy", fill=np.nan, chunks="auto"): """Create an empty DataArray for a common study area. @@ -214,7 +228,11 @@ def from_template(name, resolution=None, *, preserve=None, backend="numpy", ------- template : xarray.DataArray Empty 2-D raster with ``dims=('y', 'x')``, pixel-center coordinates, - and ``attrs`` carrying ``res`` and ``crs``. + and ``attrs`` carrying ``res``, ``crs``, ``crs_units`` (``'m'`` or + ``'degree'``), and ``crs_name`` (the projection's human-readable + name, e.g. ``'NAD83 / Conus Albers'``). ``crs_name`` is omitted only + when the EPSG code is outside the built-in table and pyproj is not + installed. Examples -------- @@ -276,12 +294,17 @@ def from_template(name, resolution=None, *, preserve=None, backend="numpy", data = _make_data((height, width), fill, backend, chunks) unit = "degree" if crs == 4326 else "m" + attrs = {"res": (actual_res_x, actual_res_y), "crs": crs, "crs_units": unit} + crs_name = _crs_name(crs) + if crs_name is not None: + attrs["crs_name"] = crs_name + template = xr.DataArray( data, name=key, coords={"y": ys, "x": xs}, dims=["y", "x"], - attrs={"res": (actual_res_x, actual_res_y), "crs": crs}, + attrs=attrs, ) template["x"].attrs["units"] = unit template["y"].attrs["units"] = unit diff --git a/xrspatial/tests/test_templates.py b/xrspatial/tests/test_templates.py index 37d3d6f6b..b451bb7b0 100644 --- a/xrspatial/tests/test_templates.py +++ b/xrspatial/tests/test_templates.py @@ -295,3 +295,49 @@ def test_preserve_downstream_slope(): warnings.simplefilter("ignore", RuntimeWarning) out = slope(agg) assert out.shape == agg.shape + + +def test_crs_units_attr(): + # crs_units mirrors the coordinate units: metres for a projected + # template, degrees for a country code in EPSG:4326. + proj = from_template("conus") + assert proj.attrs["crs_units"] == "m" == proj.x.attrs["units"] + geo = from_template("FRA") + assert geo.attrs["crs_units"] == "degree" == geo.x.attrs["units"] + + +def test_crs_name_attr(): + # 5070 is in the built-in LiteCRS table, so the name is deterministic + # regardless of whether pyproj is installed. + assert from_template("conus").attrs["crs_name"] == "NAD83 / Conus Albers" + assert from_template("FRA").attrs["crs_name"] == "WGS 84" + + +def test_crs_name_preserve_path(): + # The preserve path requires pyproj, so the chosen projection's name + # is always available. + name = from_template("conus", preserve="area").attrs["crs_name"] + assert name == "NAD83 / Conus Albers" + + +def test_crs_name_omitted_without_pyproj(monkeypatch): + # When the EPSG code is outside the built-in table and pyproj can't be + # imported, crs_name is left off rather than raising. + import xrspatial.templates as templates + + def _no_crs(_crs): + raise ImportError("pyproj not installed") + + monkeypatch.setattr(templates, "_resolve_crs", _no_crs) + agg = from_template("conus") + assert "crs_name" not in agg.attrs + # The rest of the contract still holds. + assert agg.attrs["crs"] == 5070 + assert agg.attrs["crs_units"] == "m" + + +def test_lite_crs_name_property(): + from xrspatial.reproject._lite_crs import CRS as LiteCRS + + assert LiteCRS(5070).name == "NAD83 / Conus Albers" + assert LiteCRS(4326).name == "WGS 84" From 54ab7e0e6b9798af5f7ad0a3c3418554df01b2db Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 25 Jun 2026 11:58:21 -0700 Subject: [PATCH 2/2] Address review nit: document crs_units unit heuristic (#3523) --- xrspatial/templates.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/xrspatial/templates.py b/xrspatial/templates.py index eb5ea534b..a4321e322 100644 --- a/xrspatial/templates.py +++ b/xrspatial/templates.py @@ -292,6 +292,8 @@ def from_template(name, resolution=None, *, preserve=None, backend="numpy", actual_res_y = (top - bottom) / height data = _make_data((height, width), fill, backend, chunks) + # Every template emits either EPSG:4326 (country codes, degrees) or a + # metre-based projected CRS (curated regions and all preserve paths). unit = "degree" if crs == 4326 else "m" attrs = {"res": (actual_res_x, actual_res_y), "crs": crs, "crs_units": unit}