Skip to content

Commit 8af1644

Browse files
committed
added horizontal vertical unit mismatch heuristic to help address issue #840
1 parent e455a16 commit 8af1644

File tree

3 files changed

+256
-3
lines changed

3 files changed

+256
-3
lines changed

xrspatial/slope.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ class cupy(object):
2222
from numba import cuda
2323

2424
# local modules
25-
from xrspatial.utils import (ArrayTypeFunctionMapping, cuda_args, get_dataarray_resolution, ngjit)
25+
from xrspatial.utils import ArrayTypeFunctionMapping
26+
from xrspatial.utils import cuda_args
27+
from xrspatial.utils import get_dataarray_resolution
28+
from xrspatial.utils import ngjit
29+
from xrspatial.utils import warn_if_unit_mismatch
2630

2731

2832
@ngjit
@@ -178,6 +182,9 @@ def slope(agg: xr.DataArray,
178182
Dimensions without coordinates: dim_0, dim_1
179183
"""
180184

185+
# warn if we strongly suspect degrees + meters mismatch
186+
warn_if_unit_mismatch(agg)
187+
181188
cellsize_x, cellsize_y = get_dataarray_resolution(agg)
182189
mapper = ArrayTypeFunctionMapping(
183190
numpy_func=_run_numpy,

xrspatial/tests/test_utils.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import numpy as np
2+
import xarray as xr
3+
import pytest
4+
import warnings
5+
6+
17
from xrspatial.datasets import make_terrain
2-
from xrspatial.utils import canvas_like
8+
from xrspatial import utils
39
from xrspatial.tests.general_checks import dask_array_available
410

511

@@ -8,5 +14,95 @@ def test_canvas_like():
814
# aspect ratio is 1:1
915
terrain_shape = (1000, 1000)
1016
terrain = make_terrain(shape=terrain_shape)
11-
terrain_res = canvas_like(terrain, width=50)
17+
terrain_res = utils.canvas_like(terrain, width=50)
1218
assert terrain_res.shape == (50, 50)
19+
20+
21+
def test_warn_if_unit_mismatch_degrees_horizontal_elevation_vertical(monkeypatch):
22+
"""
23+
If coordinates look like degrees (lon/lat) and values look like elevation
24+
(e.g., meters), warn the user about a likely unit mismatch.
25+
"""
26+
data = np.linspace(0, 999, 10 * 10, dtype=float).reshape(10, 10)
27+
28+
# Coordinates in degrees (lon/lat-ish)
29+
y = np.linspace(5.0, 5.0025, 10)
30+
x = np.linspace(-74.93, -74.9275, 10)
31+
32+
da = xr.DataArray(
33+
data,
34+
dims=("y", "x"),
35+
coords={"y": y, "x": x},
36+
attrs={"units": "m"}, # elevation in meters
37+
)
38+
39+
def fake_get_dataarray_resolution(arr):
40+
return float(x[1] - x[0]), float(y[1] - y[0])
41+
42+
monkeypatch.setattr(utils, "get_dataarray_resolution", fake_get_dataarray_resolution)
43+
44+
# Here we *do* expect a warning
45+
with pytest.warns(UserWarning, match="appears to have coordinates in degrees"):
46+
utils.warn_if_unit_mismatch(da)
47+
48+
49+
def test_warn_if_unit_mismatch_no_warning_for_projected_like_grid(monkeypatch):
50+
"""
51+
If coordinates look like projected linear units (e.g., meters) and values
52+
look like elevation, we should NOT warn.
53+
"""
54+
data = np.linspace(0, 999, 10 * 10, dtype=float).reshape(10, 10)
55+
56+
# Coordinates in meters (projected-looking)
57+
y = np.arange(10) * 30.0 # 0, 30, 60, ...
58+
x = 500_000.0 + np.arange(10) * 30.0 # UTM-ish eastings
59+
60+
da = xr.DataArray(
61+
data,
62+
dims=("y", "x"),
63+
coords={"y": y, "x": x},
64+
attrs={"units": "m"}, # elevation in meters
65+
)
66+
67+
def fake_get_dataarray_resolution(arr):
68+
return float(x[1] - x[0]), float(y[1] - y[0]) # 30 m
69+
70+
monkeypatch.setattr(utils, "get_dataarray_resolution", fake_get_dataarray_resolution)
71+
72+
# Capture warnings using the stdlib warnings module
73+
with warnings.catch_warnings(record=True) as w:
74+
warnings.simplefilter("always")
75+
utils.warn_if_unit_mismatch(da)
76+
77+
assert len(w) == 0, "Expected no warnings for projected-like coordinates"
78+
79+
80+
def test_warn_if_unit_mismatch_degrees_but_angle_vertical(monkeypatch):
81+
"""
82+
If coordinates are in degrees but the DataArray itself looks like an angle
83+
(e.g., units='degrees'), we should NOT warn; slope/aspect outputs fall into
84+
this category.
85+
"""
86+
data = np.linspace(0, 90, 10 * 10, dtype=float).reshape(10, 10)
87+
88+
# Coordinates in degrees again
89+
y = np.linspace(5.0, 5.0025, 10)
90+
x = np.linspace(-74.93, -74.9275, 10)
91+
92+
da = xr.DataArray(
93+
data,
94+
dims=("y", "x"),
95+
coords={"y": y, "x": x},
96+
attrs={"units": "degrees"}, # angle, not elevation
97+
)
98+
99+
def fake_get_dataarray_resolution(arr):
100+
return float(x[1] - x[0]), float(y[1] - y[0])
101+
102+
monkeypatch.setattr(utils, "get_dataarray_resolution", fake_get_dataarray_resolution)
103+
104+
with warnings.catch_warnings(record=True) as w:
105+
warnings.simplefilter("always")
106+
utils.warn_if_unit_mismatch(da)
107+
108+
assert len(w) == 0, "Expected no warnings when vertical units are angles"

xrspatial/utils.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from math import ceil
4+
import warnings
45

56
import datashader as ds
67
import datashader.transfer_functions as tf
@@ -449,3 +450,152 @@ def _convert_color(c):
449450
_converted_colors = {k: _convert_color(v) for k, v in color_key.items()}
450451
f = np.vectorize(lambda v: _converted_colors.get(v, 0))
451452
return tf.Image(f(agg.data))
453+
454+
455+
456+
def _infer_coord_unit_type(coord: xr.DataArray, cellsize: float) -> str:
457+
"""
458+
Heuristic to classify a spatial coordinate axis as:
459+
- 'degrees'
460+
- 'linear' (meters/feet/etc)
461+
- 'unknown'
462+
463+
Parameters
464+
----------
465+
coord : xr.DataArray
466+
1D coordinate variable (x or y).
467+
cellsize : float
468+
Mean spacing along this coordinate.
469+
470+
Returns
471+
-------
472+
str
473+
"""
474+
units = str(coord.attrs.get("units", "")).lower()
475+
476+
# 1) Explicit units, if present
477+
if "degree" in units or units in ("deg", "degrees"):
478+
return "degrees"
479+
if units in ("m", "meter", "metre", "meters", "metres",
480+
"km", "kilometer", "kilometre", "kilometers", "kilometres",
481+
"ft", "foot", "feet"):
482+
return "linear"
483+
484+
# 2) Numeric heuristics (very conservative)
485+
vals = coord.values
486+
if vals.size < 2 or not np.issubdtype(vals.dtype, np.number):
487+
return "unknown"
488+
489+
vmin = float(np.nanmin(vals))
490+
vmax = float(np.nanmax(vals))
491+
span = abs(vmax - vmin)
492+
dx = abs(float(cellsize))
493+
494+
# Typical global geographic axes: span <= 360, spacing ~1e-5–0.5 deg
495+
if -360.0 <= vmin <= 360.0 and -360.0 <= vmax <= 360.0:
496+
if 1e-5 <= dx <= 0.5:
497+
return "degrees"
498+
499+
# Typical projected axes in meters: span >> 1, spacing > ~0.1
500+
# (e.g. UTM / national grids)
501+
if span > 1000.0 and dx >= 0.1:
502+
return "linear"
503+
504+
return "unknown"
505+
506+
507+
def _infer_vertical_unit_type(agg: xr.DataArray) -> str:
508+
"""
509+
Heuristic to classify the DataArray values as:
510+
- 'elevation' (meters/feet etc)
511+
- 'angle' (degrees/radians)
512+
- 'unknown'
513+
"""
514+
units = str(agg.attrs.get("units", "")).lower()
515+
516+
# 1) Explicit units
517+
if any(k in units for k in ("degree", "deg")):
518+
return "angle"
519+
if "rad" in units:
520+
return "angle"
521+
if units in ("m", "meter", "metre", "meters", "metres",
522+
"km", "kilometer", "kilometre", "kilometers", "kilometres",
523+
"ft", "foot", "feet"):
524+
return "elevation"
525+
526+
# 2) Numeric heuristics on data range
527+
data = agg.values
528+
if not np.issubdtype(data.dtype, np.number):
529+
return "unknown"
530+
531+
finite = np.isfinite(data)
532+
if not np.any(finite):
533+
return "unknown"
534+
535+
vmin = float(data[finite].min())
536+
vmax = float(data[finite].max())
537+
span = vmax - vmin
538+
539+
# Elevation-like: tens–thousands of units, typical DEM ranges.
540+
if 10.0 <= span <= 20000.0 and vmin > -500.0:
541+
return "elevation"
542+
543+
# Angle-like: often 0–360, -180–180, or small (-pi, pi)
544+
if -360.0 <= vmin <= 360.0 and -360.0 <= vmax <= 360.0:
545+
# If the span is not huge, treat as angle-ish
546+
if span <= 720.0:
547+
return "angle"
548+
549+
return "unknown"
550+
551+
def warn_if_unit_mismatch(agg: xr.DataArray) -> None:
552+
"""
553+
Heuristic check for horizontal vs vertical unit mismatch.
554+
555+
Intended to catch the common case of:
556+
- coordinates in degrees (lon/lat)
557+
- elevation values in meters/feet
558+
559+
Emits a UserWarning if a likely mismatch is detected.
560+
"""
561+
try:
562+
cellsize_x, cellsize_y = get_dataarray_resolution(agg)
563+
except Exception:
564+
# If we can't even get a resolution, we also can't say much
565+
return
566+
567+
# pick "x" and "y" coords in a generic way:
568+
# - typically dims are ('y', 'x') or ('lat', 'lon')
569+
# - fall back to last two dims
570+
if len(agg.dims) < 2:
571+
return
572+
573+
dim_y, dim_x = agg.dims[-2], agg.dims[-1]
574+
coord_x = agg.coords.get(dim_x, None)
575+
coord_y = agg.coords.get(dim_y, None)
576+
577+
if coord_x is None or coord_y is None:
578+
# Can't infer spatial types without coords
579+
return
580+
581+
horiz_x = _infer_coord_unit_type(coord_x, cellsize_x)
582+
horiz_y = _infer_coord_unit_type(coord_y, cellsize_y)
583+
vert = _infer_vertical_unit_type(agg)
584+
585+
horiz_types = {horiz_x, horiz_y} - {"unknown"}
586+
587+
# Only act if we have some signal about horizontal AND vertical
588+
if not horiz_types or vert == "unknown":
589+
return
590+
591+
# If any axis looks like degrees and vertical looks like elevation,
592+
# it's almost certainly "lat/lon degrees + meter elevations"
593+
if "degrees" in horiz_types and vert == "elevation":
594+
warnings.warn(
595+
"xrspatial: input DataArray appears to have coordinates in degrees "
596+
"but elevation values in a linear unit (e.g. meters/feet). "
597+
"Slope/aspect operations expect horizontal distances in the same "
598+
"units as vertical. Consider reprojecting to a projected CRS with "
599+
"meter-based coordinates before calling `slope`.",
600+
UserWarning,
601+
)

0 commit comments

Comments
 (0)