From a52cab004cd6ea60440ce85da87d7295f1055262 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 27 Jun 2026 04:58:20 -0700 Subject: [PATCH 1/7] Add regional templates to from_template (#3552) Add southeast_asia (EPSG:10594), central_america/caribbean (EPSG:10598), and west_africa (EPSG:10592), each in its GLANCE continental Lambert azimuthal equal-area projection. Real EPSG codes keep attrs['crs'] an int. Reuses the existing region dispatch, so all four backends work. --- xrspatial/_template_data.py | 19 +++++++++++ xrspatial/templates.py | 4 ++- xrspatial/tests/test_templates.py | 54 +++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/xrspatial/_template_data.py b/xrspatial/_template_data.py index bbd2ab4c5..cfa6e0320 100644 --- a/xrspatial/_template_data.py +++ b/xrspatial/_template_data.py @@ -45,6 +45,25 @@ '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. + '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), # 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. diff --git a/xrspatial/templates.py b/xrspatial/templates.py index e24371eea..08eb6d8ad 100644 --- a/xrspatial/templates.py +++ b/xrspatial/templates.py @@ -270,7 +270,9 @@ def from_template(name: str, ---------- name : str A curated region name (case-insensitive), e.g. ``'conus'``, ``'nyc'``, - ``'europe'``, ``'world'``; a world-city name (case-insensitive), e.g. + ``'europe'``, ``'southeast_asia'``, ``'central_america'``, + ``'caribbean'``, ``'west_africa'``, ``'world'``; 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 diff --git a/xrspatial/tests/test_templates.py b/xrspatial/tests/test_templates.py index 9650d31f4..a5c0609ca 100644 --- a/xrspatial/tests/test_templates.py +++ b/xrspatial/tests/test_templates.py @@ -144,6 +144,50 @@ def test_world_grid(): assert agg.attrs["crs"] == 4326 +# --------------------------------------------------------------------------- +# regional templates (GLANCE continental equal-area projections) +# --------------------------------------------------------------------------- + +_REGIONAL = [ + ("southeast_asia", 10594), + ("central_america", 10598), + ("caribbean", 10598), + ("west_africa", 10592), +] + + +@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] + + +@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"): @@ -528,6 +572,16 @@ 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_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_cf_attrs_omitted_without_pyproj(monkeypatch): # Without pyproj the default (non-reproject) path stays dependency-free: # the CF grid-mapping keys are left off rather than raising. From 71f432590ff9b790ea50799684511b2914e6f225 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 27 Jun 2026 04:59:02 -0700 Subject: [PATCH 2/7] Document regional templates (#3552) --- docs/source/reference/templates.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/reference/templates.rst b/docs/source/reference/templates.rst index b07341698..838ceee93 100644 --- a/docs/source/reference/templates.rst +++ b/docs/source/reference/templates.rst @@ -9,7 +9,9 @@ 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. +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. Call :func:`~xrspatial.templates.list_templates` to discover every name ``from_template`` accepts (curated regions, world cities, and country codes). From 4bca7b9469826feb8af208a5dea931038a532553 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 27 Jun 2026 05:01:13 -0700 Subject: [PATCH 3/7] Address review: guard regional bounds against lonlat, document shape_epsg fallback (#3552) --- xrspatial/_template_data.py | 7 ++++++- xrspatial/tests/test_templates.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/xrspatial/_template_data.py b/xrspatial/_template_data.py index cfa6e0320..f031e5733 100644 --- a/xrspatial/_template_data.py +++ b/xrspatial/_template_data.py @@ -47,7 +47,12 @@ 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. + # 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)', diff --git a/xrspatial/tests/test_templates.py b/xrspatial/tests/test_templates.py index a5c0609ca..12a42bfa0 100644 --- a/xrspatial/tests/test_templates.py +++ b/xrspatial/tests/test_templates.py @@ -572,6 +572,26 @@ 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 From 8335d54ec10c09a4b1209657f1f5c7f5f253cca8 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 27 Jun 2026 05:17:38 -0700 Subject: [PATCH 4/7] Add more regional templates: Africa, Asia, S. America, Oceania (#3552) Add north_africa/east_africa/southern_africa (GLANCE Africa 10592), south_asia/east_asia/central_asia/middle_east (GLANCE Asia 10594), south_america (GLANCE S. America 10603), and oceania (GLANCE Oceania 10601). Same EPSG-coded continental LAEA pattern as the first batch; covered by the existing parametrized regional tests. --- xrspatial/_template_data.py | 38 +++++++++++++++++++++++++++++++ xrspatial/templates.py | 14 +++++++----- xrspatial/tests/test_templates.py | 9 ++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/xrspatial/_template_data.py b/xrspatial/_template_data.py index 3c3dfe81c..3eb5aef28 100644 --- a/xrspatial/_template_data.py +++ b/xrspatial/_template_data.py @@ -69,6 +69,44 @@ 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), # 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. diff --git a/xrspatial/templates.py b/xrspatial/templates.py index 7b176021b..92bfe04ca 100644 --- a/xrspatial/templates.py +++ b/xrspatial/templates.py @@ -271,12 +271,14 @@ def from_template(name: str, Parameters ---------- name : str - A curated region name (case-insensitive), e.g. ``'conus'``, ``'nyc'``, - ``'europe'``, ``'southeast_asia'``, ``'central_america'``, - ``'caribbean'``, ``'west_africa'``, ``'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'``, + 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 diff --git a/xrspatial/tests/test_templates.py b/xrspatial/tests/test_templates.py index 358162d58..7c06ad4d8 100644 --- a/xrspatial/tests/test_templates.py +++ b/xrspatial/tests/test_templates.py @@ -210,6 +210,15 @@ def test_global_resolution_honored_exactly(): ("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), ] From 05cf9fa3521bcfae68b495e2e7f5f44cd4d11166 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 27 Jun 2026 07:26:09 -0700 Subject: [PATCH 5/7] Cover every continent with regional templates (#3552) Add Europe subregions (western/eastern/northern/southern_europe, GLANCE Europe 10596), central_africa, north_asia, greenland, South America subregions (amazon_basin/andes/southern_cone), australia, new_zealand, and antarctica (Antarctic Polar Stereographic 3031, with area_epsg=6932 S-polar LAEA for preserve='area'). Every continent now has a few regions. --- xrspatial/_template_data.py | 56 +++++++++++++++++++++++++++++++ xrspatial/tests/test_templates.py | 40 ++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/xrspatial/_template_data.py b/xrspatial/_template_data.py index 3eb5aef28..d7ab936b9 100644 --- a/xrspatial/_template_data.py +++ b/xrspatial/_template_data.py @@ -107,6 +107,62 @@ 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. diff --git a/xrspatial/tests/test_templates.py b/xrspatial/tests/test_templates.py index 7c06ad4d8..b7b69e106 100644 --- a/xrspatial/tests/test_templates.py +++ b/xrspatial/tests/test_templates.py @@ -219,6 +219,18 @@ def test_global_resolution_honored_exactly(): ("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), ] @@ -238,6 +250,22 @@ def test_regional_template_contract(name, crs): 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) @@ -682,6 +710,18 @@ def test_regional_template_grid_mapping(name, crs): 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"), From bbab5ec06a3c7a5e772d3f3e3680803042855858 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 27 Jun 2026 07:33:37 -0700 Subject: [PATCH 6/7] Add common North America regions to from_template (#3552) Add canada, mexico, great_lakes, pacific_northwest, gulf_coast, new_england, great_plains, and american_southwest, all in GLANCE North America LAEA (EPSG:10598). Covered by the parametrized regional tests. --- xrspatial/_template_data.py | 32 +++++++++++++++++++++++++++++++ xrspatial/tests/test_templates.py | 8 ++++++++ 2 files changed, 40 insertions(+) diff --git a/xrspatial/_template_data.py b/xrspatial/_template_data.py index d7ab936b9..6189c88dd 100644 --- a/xrspatial/_template_data.py +++ b/xrspatial/_template_data.py @@ -127,6 +127,38 @@ 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), + 'canada': dict( + bounds=(-3271722, -999331, 3748086, 3935367), crs=10598, + default_resolution=10000, label='Canada (GLANCE N. America LAEA)', + lonlat=(-141.0, 41.0, -52.0, 84.0), area_epsg=10598), + 'mexico': dict( + bounds=(-2026872, -3928666, 1581449, -1690630), crs=10598, + default_resolution=5000, label='Mexico (GLANCE N. America LAEA)', + lonlat=(-118.0, 14.0, -86.0, 33.0), area_epsg=10598), + 'great_lakes': dict( + bounds=(506191, -972693, 2067092, 243847), crs=10598, + default_resolution=2000, label='Great Lakes (GLANCE N. America LAEA)', + lonlat=(-93.0, 41.0, -75.0, 49.5), area_epsg=10598), + 'pacific_northwest': dict( + bounds=(-2033861, -823605, -752346, 508938), crs=10598, + default_resolution=2000, label='Pacific Northwest (GLANCE N. America LAEA)', + lonlat=(-125.0, 42.0, -111.0, 52.0), area_epsg=10598), + 'gulf_coast': dict( + bounds=(193569, -2859113, 1963599, -1884446), crs=10598, + default_resolution=2000, label='Gulf Coast (GLANCE N. America LAEA)', + lonlat=(-98.0, 24.0, -81.0, 31.0), area_epsg=10598), + 'new_england': dict( + bounds=(1914015, -633460, 2686814, 258722), crs=10598, + default_resolution=1000, label='New England (GLANCE N. America LAEA)', + lonlat=(-74.0, 41.0, -67.0, 47.5), area_epsg=10598), + 'great_plains': dict( + bounds=(-483626, -2100615, 387002, -99072), crs=10598, + default_resolution=5000, label='Great Plains (GLANCE N. America LAEA)', + lonlat=(-105.0, 31.0, -96.0, 49.0), area_epsg=10598), + 'american_southwest': dict( + bounds=(-1913308, -2095218, -249048, -674284), crs=10598, + default_resolution=2000, label='American Southwest (GLANCE N. America LAEA)', + lonlat=(-120.0, 31.0, -103.0, 42.0), area_epsg=10598), 'amazon_basin': dict( bounds=(-2129139, -422334, 1795411, 2309720), crs=10603, default_resolution=5000, label='Amazon Basin (GLANCE S. America LAEA)', diff --git a/xrspatial/tests/test_templates.py b/xrspatial/tests/test_templates.py index b7b69e106..02961ae08 100644 --- a/xrspatial/tests/test_templates.py +++ b/xrspatial/tests/test_templates.py @@ -224,6 +224,14 @@ def test_global_resolution_honored_exactly(): ("central_africa", 10592), ("north_asia", 10594), ("greenland", 10598), + ("canada", 10598), + ("mexico", 10598), + ("great_lakes", 10598), + ("pacific_northwest", 10598), + ("gulf_coast", 10598), + ("new_england", 10598), + ("great_plains", 10598), + ("american_southwest", 10598), ("amazon_basin", 10603), ("andes", 10603), ("southern_cone", 10603), From a66d19ece5f21eb9561522d59fd57adbd2a89b96 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 27 Jun 2026 07:39:40 -0700 Subject: [PATCH 7/7] Add Pacific-centered PDC Mercator template (#3552) Add 'pacific' (alias 'pdc') global template in EPSG:3832 (WGS 84 / PDC Mercator), the Pacific Disaster Center projection centered on lon_0=150 so the Pacific Ocean is continuous (map seam in the Atlantic). Conformal, so preserve='area' falls back to Equal Earth. --- xrspatial/_template_data.py | 14 +++++++++++++- xrspatial/templates.py | 3 ++- xrspatial/tests/test_templates.py | 23 +++++++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/xrspatial/_template_data.py b/xrspatial/_template_data.py index 6189c88dd..462d30b1c 100644 --- a/xrspatial/_template_data.py +++ b/xrspatial/_template_data.py @@ -218,13 +218,25 @@ default_resolution=50000, label='World (Equal Earth)', lonlat=(-180.0, -90.0, 180.0, 90.0), area_epsg=8857, shape_epsg=3395), + # Pacific-centered world (EPSG:3832, WGS 84 / PDC Mercator). lon_0=150 so the + # Pacific Ocean is continuous, with the map seam in the Atlantic (~30 W). x + # spans the full a*pi longitude extent; y is the ellipsoidal Mercator value + # at the conventional +/-85.0511287798 latitude limit. Conformal, so + # area_epsg falls back to Equal Earth for preserve='area'. + 'pacific': dict( + bounds=(-20037508, -19994875, 20037508, 19994875), crs=3832, + default_resolution=50000, label='Pacific-centered World (PDC Mercator)', + lonlat=(-180.0, -85.0511287798, 180.0, 85.0511287798), + area_epsg=8857, shape_epsg=3832), } # Alternate spellings that resolve to a curated region (single source of truth). -# 'wgs84' / 'latlon' are friendly names for the EPSG:4326 'world' grid. +# 'wgs84' / 'latlon' are friendly names for the EPSG:4326 'world' grid; 'pdc' is +# the Pacific Disaster Center's name for its Pacific-centered Mercator. _REGION_ALIASES = { 'wgs84': 'world', 'latlon': 'world', + 'pdc': 'pacific', } # Equal-area fallback when a template has no curated ``area_epsg`` diff --git a/xrspatial/templates.py b/xrspatial/templates.py index 92bfe04ca..18d818038 100644 --- a/xrspatial/templates.py +++ b/xrspatial/templates.py @@ -277,7 +277,8 @@ def from_template(name: str, ``'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 + ``'world'``), ``'equal_earth'`` (EPSG:8857), or ``'pacific'`` / ``'pdc'`` + (EPSG:3832, a Pacific-centered PDC Mercator); 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 diff --git a/xrspatial/tests/test_templates.py b/xrspatial/tests/test_templates.py index 02961ae08..c787390ef 100644 --- a/xrspatial/tests/test_templates.py +++ b/xrspatial/tests/test_templates.py @@ -152,7 +152,7 @@ def test_world_grid(): @pytest.mark.parametrize( "name,crs", [("web_mercator", 3857), ("wgs84", 4326), ("latlon", 4326), - ("equal_earth", 8857)], + ("equal_earth", 8857), ("pacific", 3832)], ) def test_global_projection_contract(name, crs): agg = from_template(name) @@ -179,7 +179,8 @@ def test_wgs84_latlon_alias_world(alias): assert a.name == alias -@pytest.mark.parametrize("name", ["web_mercator", "equal_earth", "latlon"]) +@pytest.mark.parametrize("name", ["web_mercator", "equal_earth", "latlon", + "pacific", "pdc"]) def test_global_projection_case_insensitive(name): a = from_template(name) b = from_template(name.upper()) @@ -187,6 +188,24 @@ def test_global_projection_case_insensitive(name): assert a.attrs == b.attrs +def test_pacific_pdc_mercator(): + # the Pacific Disaster Center projection (EPSG:3832, WGS 84 / PDC Mercator) + # is a Pacific-centered Mercator, so the ocean is continuous; CF names it + # 'mercator' and the WKT carries the PDC name. + agg = from_template("pacific") + assert agg.attrs["crs"] == 3832 + assert agg.attrs["grid_mapping_name"] == "mercator" + assert "PDC Mercator" in agg.attrs["crs_wkt"] + assert agg.x.attrs["units"] == "m" + # 'pdc' is an alias for the same grid (only the name differs) + pdc = from_template("pdc") + np.testing.assert_array_equal(pdc.x.values, agg.x.values) + assert pdc.attrs == agg.attrs + assert pdc.name == "pdc" + # conformal, so preserve='area' hands back the Equal Earth equal-area code + assert from_template("pacific", preserve="area").attrs["crs"] == 8857 + + def test_web_mercator_metre_coords_within_bounds(): agg = from_template("web_mercator") assert agg.x.attrs["units"] == "m"