Skip to content
8 changes: 5 additions & 3 deletions docs/source/reference/templates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ a region name, a world-city name, or a country code into a NaN-filled
:class:`xarray.DataArray` that follows the xarray-spatial array contract, so it
feeds straight into the rest of the library. Cities (national capitals, major
regional metros, and recognizable US secondary cities) come back as a metro
bounding box in their UTM zone. Whole-world canvases are available in a few
projections too: ``'web_mercator'`` (EPSG:3857), ``'wgs84'`` / ``'latlon'``
(EPSG:4326), and ``'equal_earth'`` (EPSG:8857).
bounding box in their UTM zone. Curated regions span North America, Europe, and
now Southeast Asia, Central America, the Caribbean, and West Africa, each in an
EPSG-coded continental equal-area projection. Whole-world canvases are available
in a few projections too: ``'web_mercator'`` (EPSG:3857), ``'wgs84'`` /
``'latlon'`` (EPSG:4326), and ``'equal_earth'`` (EPSG:8857).

Call :func:`~xrspatial.templates.list_templates` to discover every name
``from_template`` accepts (curated regions, world cities, and country codes).
Expand Down
118 changes: 118 additions & 0 deletions xrspatial/_template_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,124 @@
'nyc': dict(bounds=(558916, 4481270, 614426, 4534084), crs=32618,
default_resolution=30, label='New York City (UTM 18N)',
lonlat=(-74.30, 40.48, -73.65, 40.95)),
# Continental regions in their EPSG-coded GLANCE equal-area projection
# (Lambert azimuthal equal-area), the same family as Europe's LAEA. bounds
# are the lon/lat box projected into the GLANCE CRS. No shape_epsg is set:
# there is no EPSG conformal projection for these continental extents, so
# preserve='shape' falls back to the centroid's UTM zone (covers a slice
# only). The lon/lat boxes follow the real region extent and may run a
# degree past the GLANCE area of use near the equator; LAEA still projects
# those points finitely.
'southeast_asia': dict(
bounds=(-987821, -5961342, 4923248, -932582), crs=10594,
default_resolution=10000, label='Southeast Asia (GLANCE Asia LAEA)',
lonlat=(92.0, -11.0, 141.0, 28.0), area_epsg=10594),
'central_america': dict(
bounds=(821182, -4620810, 2695977, -3111382), crs=10598,
default_resolution=2000, label='Central America (GLANCE N. America LAEA)',
lonlat=(-92.5, 7.0, -77.0, 18.5), area_epsg=10598),
'caribbean': dict(
bounds=(1500961, -4302986, 4618873, -1455289), crs=10598,
default_resolution=5000, label='Caribbean (GLANCE N. America LAEA)',
lonlat=(-85.0, 9.0, -59.0, 27.5), area_epsg=10598),
'west_africa': dict(
bounds=(-4141633, -109304, -404383, 2659299), crs=10592,
default_resolution=5000, label='West Africa (GLANCE Africa LAEA)',
lonlat=(-18.0, 4.0, 16.0, 27.0), area_epsg=10592),
'north_africa': dict(
bounds=(-3867207, 1434990, 1804670, 3865653), crs=10592,
default_resolution=10000, label='North Africa (GLANCE Africa LAEA)',
lonlat=(-17.0, 18.0, 37.0, 38.0), area_epsg=10592),
'east_africa': dict(
bounds=(851984, -1872363, 3520386, 1573813), crs=10592,
default_resolution=5000, label='East Africa (GLANCE Africa LAEA)',
lonlat=(28.0, -12.0, 52.0, 18.0), area_epsg=10592),
'southern_africa': dict(
bounds=(-997536, -4373971, 2316937, -1421722), crs=10592,
default_resolution=5000, label='Southern Africa (GLANCE Africa LAEA)',
lonlat=(11.0, -35.0, 41.0, -8.0), area_epsg=10592),
'south_asia': dict(
bounds=(-4560937, -4342133, -175948, 56016), crs=10594,
default_resolution=5000, label='South Asia (GLANCE Asia LAEA)',
lonlat=(60.0, 5.0, 98.0, 38.0), area_epsg=10594),
'east_asia': dict(
bounds=(-2888124, -2967169, 4752508, 1872058), crs=10594,
default_resolution=10000, label='East Asia (GLANCE Asia LAEA)',
lonlat=(73.0, 18.0, 146.0, 54.0), area_epsg=10594),
'central_asia': dict(
bounds=(-4529046, -1031518, -748494, 2365024), crs=10594,
default_resolution=5000, label='Central Asia (GLANCE Asia LAEA)',
lonlat=(46.0, 35.0, 88.0, 56.0), area_epsg=10594),
'middle_east': dict(
bounds=(-6742347, -2794074, -2936764, 1798849), crs=10594,
default_resolution=5000, label='Middle East (GLANCE Asia LAEA)',
lonlat=(34.0, 12.0, 63.0, 42.0), area_epsg=10594),
'south_america': dict(
bounds=(-2461362, -4624186, 2901620, 3066400), crs=10603,
default_resolution=10000, label='South America (GLANCE S. America LAEA)',
lonlat=(-82.0, -56.0, -34.0, 13.0), area_epsg=10603),
# Oceania bounded west of the antimeridian (Australia, New Guinea, New
# Zealand) so the lon/lat box does not wrap 180.
'oceania': dict(
bounds=(-2736534, -4140479, 4725674, 773877), crs=10601,
default_resolution=10000, label='Oceania (GLANCE Oceania LAEA)',
lonlat=(110.0, -48.0, 179.0, -8.0), area_epsg=10601),
'australia': dict(
bounds=(-2397336, -3314170, 2074139, 552968), crs=10601,
default_resolution=5000, label='Australia (GLANCE Oceania LAEA)',
lonlat=(113.0, -44.0, 154.0, -10.0), area_epsg=10601),
'new_zealand': dict(
bounds=(2357704, -4140479, 3965299, -2362651), crs=10601,
default_resolution=2000, label='New Zealand (GLANCE Oceania LAEA)',
lonlat=(166.0, -48.0, 179.0, -34.0), area_epsg=10601),
'central_africa': dict(
bounds=(-1335054, -2091682, 1224158, 789768), crs=10592,
default_resolution=5000, label='Central Africa (GLANCE Africa LAEA)',
lonlat=(8.0, -14.0, 31.0, 12.0), area_epsg=10592),
'north_asia': dict(
bounds=(-2829330, 333534, 4672506, 4568106), crs=10594,
default_resolution=10000, label='North Asia (GLANCE Asia LAEA)',
lonlat=(60.0, 48.0, 179.0, 78.0), area_epsg=10594),
'greenland': dict(
bounds=(307428, 1266923, 3614205, 4339302), crs=10598,
default_resolution=5000, label='Greenland (GLANCE N. America LAEA)',
lonlat=(-74.0, 59.0, -11.0, 84.0), area_epsg=10598),
'amazon_basin': dict(
bounds=(-2129139, -422334, 1795411, 2309720), crs=10603,
default_resolution=5000, label='Amazon Basin (GLANCE S. America LAEA)',
lonlat=(-79.0, -18.0, -44.0, 6.0), area_epsg=10603),
'andes': dict(
bounds=(-2350812, -4564243, -133276, 2958360), crs=10603,
default_resolution=10000, label='Andes (GLANCE S. America LAEA)',
lonlat=(-81.0, -56.0, -62.0, 12.0), area_epsg=10603),
'southern_cone': dict(
bounds=(-1697271, -4517109, 744960, -221307), crs=10603,
default_resolution=5000, label='Southern Cone (GLANCE S. America LAEA)',
lonlat=(-76.0, -56.0, -53.0, -17.0), area_epsg=10603),
'western_europe': dict(
bounds=(-2382583, -1327155, -191916, 406028), crs=10596,
default_resolution=5000, label='Western Europe (GLANCE Europe LAEA)',
lonlat=(-10.0, 43.0, 17.0, 55.0), area_epsg=10596),
'eastern_europe': dict(
bounds=(-482758, -1221647, 2340946, 916013), crs=10596,
default_resolution=5000, label='Eastern Europe (GLANCE Europe LAEA)',
lonlat=(14.0, 44.0, 50.0, 60.0), area_epsg=10596),
'northern_europe': dict(
bounds=(-1039068, -111313, 782636, 1847310), crs=10596,
default_resolution=2000, label='Northern Europe (GLANCE Europe LAEA)',
lonlat=(4.0, 54.0, 32.0, 71.0), area_epsg=10596),
'southern_europe': dict(
bounds=(-2698894, -2211793, 739758, -416605), crs=10596,
default_resolution=5000, label='Southern Europe (GLANCE Europe LAEA)',
lonlat=(-10.0, 35.0, 28.0, 47.0), area_epsg=10596),
# Antarctica uses the de-facto standard Antarctic Polar Stereographic
# (EPSG:3031, conformal), so preserve='area' falls back to the EPSG-coded
# south-polar equal-area grid (EPSG:6932) rather than claiming 3031 is
# equal-area. shape_epsg is 3031 itself (already conformal).
'antarctica': dict(
bounds=(-3333134, -3333134, 3333134, 3333134), crs=3031,
default_resolution=10000, label='Antarctica (Polar Stereographic)',
lonlat=(-180.0, -90.0, 180.0, -60.0), area_epsg=6932, shape_epsg=3031),
# The default (non-preserve) world grid spans the full +/-90 in EPSG:4326.
# The preserve path uses a +/-85 latitude band (the conventional Web
# Mercator limit) so 'shape' (World Mercator) does not diverge at the poles.
Expand Down
25 changes: 14 additions & 11 deletions xrspatial/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,17 +271,20 @@ def from_template(name: str,
Parameters
----------
name : str
A curated region name (case-insensitive), e.g. ``'conus'``, ``'nyc'``,
``'europe'``, ``'world'``; a global-projection name, e.g.
``'web_mercator'`` (EPSG:3857), ``'wgs84'`` / ``'latlon'`` (EPSG:4326,
the same grid as ``'world'``), or ``'equal_earth'`` (EPSG:8857); a
world-city name (case-insensitive), e.g. ``'london'``, ``'tokyo'``,
``'sao_paulo'``; or an ISO-3166 / GADM alpha-3 country code, e.g.
``'USA'``, ``'FRA'``, ``'JPN'``. Curated regions and cities come back in
a projected CRS (cities in their UTM zone); country codes come back in
EPSG:4326. Where two cities share a name the larger keeps the bare name
and the others take a ``_<iso2>`` suffix (e.g. ``'hyderabad'`` vs
``'hyderabad_pk'``).
A curated region name (case-insensitive): a national/metro area such as
``'conus'`` or ``'nyc'``, a continental or subcontinental region such as
``'europe'``, ``'southeast_asia'``, ``'east_africa'``, or
``'south_america'`` (call :func:`list_templates` for the full set), or
``'world'``; a global-projection name, e.g. ``'web_mercator'``
(EPSG:3857), ``'wgs84'`` / ``'latlon'`` (EPSG:4326, the same grid as
``'world'``), or ``'equal_earth'`` (EPSG:8857); a world-city name
(case-insensitive), e.g. ``'london'``,
``'tokyo'``, ``'sao_paulo'``; or an ISO-3166 / GADM alpha-3 country
code, e.g. ``'USA'``, ``'FRA'``, ``'JPN'``. Curated regions and cities
come back in a projected CRS (cities in their UTM zone); country codes
come back in EPSG:4326. Where two cities share a name the larger keeps
the bare name and the others take a ``_<iso2>`` suffix (e.g.
``'hyderabad'`` vs ``'hyderabad_pk'``).
resolution : float or tuple of float, optional
Cell size in the template's CRS units (metres for projected regions,
degrees for country codes). A scalar gives square cells; a
Expand Down
123 changes: 123 additions & 0 deletions xrspatial/tests/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,87 @@ def test_global_resolution_honored_exactly():
assert agg.attrs["res"] == (100000.0, 100000.0)


# ---------------------------------------------------------------------------
# regional templates (GLANCE continental equal-area projections)
# ---------------------------------------------------------------------------

_REGIONAL = [
("southeast_asia", 10594),
("central_america", 10598),
("caribbean", 10598),
("west_africa", 10592),
("north_africa", 10592),
("east_africa", 10592),
("southern_africa", 10592),
("south_asia", 10594),
("east_asia", 10594),
("central_asia", 10594),
("middle_east", 10594),
("south_america", 10603),
("oceania", 10601),
("australia", 10601),
("new_zealand", 10601),
("central_africa", 10592),
("north_asia", 10594),
("greenland", 10598),
("amazon_basin", 10603),
("andes", 10603),
("southern_cone", 10603),
("western_europe", 10596),
("eastern_europe", 10596),
("northern_europe", 10596),
("southern_europe", 10596),
]


@pytest.mark.parametrize("name,crs", _REGIONAL)
def test_regional_template_contract(name, crs):
agg = from_template(name)
assert agg.attrs["crs"] == crs
assert agg.dims == ("y", "x")
assert agg.shape[0] > 1 and agg.shape[1] > 1
assert np.isnan(agg.values).all()
assert agg.dtype == np.float32
assert agg.name == name
# projected (LAEA) metre coordinates, north-up, ascending x
assert agg.x.attrs["units"] == "m"
assert agg.x.attrs["standard_name"] == "projection_x_coordinate"
assert agg.y.values[0] > agg.y.values[-1]
assert agg.x.values[0] < agg.x.values[-1]


def test_antarctica_contract():
# Antarctica is the one region that is not GLANCE LAEA: it uses the de-facto
# standard Antarctic Polar Stereographic (EPSG:3031), a projected metre CRS.
agg = from_template("antarctica")
assert agg.attrs["crs"] == 3031
assert agg.dims == ("y", "x")
assert agg.shape[0] > 1 and agg.shape[1] > 1
assert np.isnan(agg.values).all()
assert agg.dtype == np.float32
assert agg.name == "antarctica"
assert agg.x.attrs["units"] == "m"
assert agg.x.attrs["standard_name"] == "projection_x_coordinate"
assert agg.y.values[0] > agg.y.values[-1]
assert agg.x.values[0] < agg.x.values[-1]


@pytest.mark.parametrize("name,crs", _REGIONAL)
def test_regional_template_centers_within_bounds(name, crs):
agg = from_template(name)
left, bottom, right, top = _REGIONS[name]["bounds"]
assert left <= agg.x.values.min() and agg.x.values.max() <= right
assert bottom <= agg.y.values.min() and agg.y.values.max() <= top


@pytest.mark.parametrize("name,crs", _REGIONAL)
def test_regional_template_case_insensitive(name, crs):
a = from_template(name)
b = from_template(name.upper())
np.testing.assert_array_equal(a.x.values, b.x.values)
assert a.attrs == b.attrs


@pytest.mark.parametrize("bad", ["does-not-exist", "ZZZ"])
def test_unknown_name_raises(bad):
with pytest.raises(ValueError, match="Unknown template"):
Expand Down Expand Up @@ -599,6 +680,48 @@ def test_grid_mapping_omitted_for_equal_earth():
assert "Equal Earth" in agg.attrs["crs_wkt"]


@pytest.mark.parametrize("name,crs", _REGIONAL)
def test_regional_bounds_match_reprojected_lonlat(name, crs):
# the stored bounds are hand-maintained: the lon/lat box projected into the
# GLANCE CRS. Recompute them here so a future edit or regeneration that
# drifts bounds out of sync with lonlat (which would misgeoreference the
# grid) fails loudly instead of shipping a wrong canvas.
from xrspatial.reproject._crs_utils import _resolve_crs
from xrspatial.reproject._grid import _edge_samples, _transform_boundary

lon_min, lat_min, lon_max, lat_max = _REGIONS[name]["lonlat"]
xs, ys = _edge_samples(lon_min, lat_min, lon_max, lat_max, 101)
tx, ty = _transform_boundary(_resolve_crs(4326), _resolve_crs(crs), xs, ys)
tx, ty = np.asarray(tx), np.asarray(ty)
valid = np.isfinite(tx) & np.isfinite(ty)
recomputed = (tx[valid].min(), ty[valid].min(),
tx[valid].max(), ty[valid].max())
# bounds are stored rounded to the metre; allow a couple of metres slack
np.testing.assert_allclose(_REGIONS[name]["bounds"], recomputed, atol=2.0)


@pytest.mark.parametrize("name,crs", _REGIONAL)
def test_regional_template_grid_mapping(name, crs):
# the GLANCE regions are Lambert azimuthal equal-area, which CF defines, so
# grid_mapping_name is present and crs_wkt names the GLANCE projection.
agg = from_template(name)
assert agg.attrs["grid_mapping_name"] == "lambert_azimuthal_equal_area"
assert "GLANCE" in agg.attrs["crs_wkt"]
assert _proj(crs) == "laea"


def test_antarctica_grid_mapping_and_preserve():
# Antarctic Polar Stereographic is conformal, so grid_mapping_name is
# 'polar_stereographic' and preserve='area' must hand back a real equal-area
# code (the south-polar LAEA EPSG:6932), not 3031.
agg = from_template("antarctica")
assert agg.attrs["grid_mapping_name"] == "polar_stereographic"
assert _proj(3031) == "stere"
assert from_template("antarctica", preserve="area").attrs["crs"] == 6932
assert _proj(6932) == "laea"
assert from_template("antarctica", preserve="shape").attrs["crs"] == 3031


@pytest.mark.parametrize(
"name,crs,wkt_marker",
[("web_mercator", 3857, "Pseudo-Mercator"),
Expand Down
Loading