Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions xrspatial/reproject/_lite_crs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<code>"`` for
table entries without a curated name.
"""
return self._entry.get("_name", f"EPSG:{self._epsg}")

# -- output methods ------------------------------------------------

def to_epsg(self) -> int:
Expand Down
29 changes: 27 additions & 2 deletions xrspatial/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -220,7 +234,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
--------
Expand Down Expand Up @@ -284,14 +302,21 @@ def from_template(name, resolution=None, *, preserve=None, backend="numpy",
ys, xs = _make_output_coords((left, bottom, right, top), (height, width))

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": (res_x, 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": (res_x, res_y), "crs": crs},
attrs=attrs,
)
template["x"].attrs["units"] = unit
template["y"].attrs["units"] = unit
Expand Down
46 changes: 46 additions & 0 deletions xrspatial/tests/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,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"
Loading