Description
surface_direction() returns wrong compass bearings in two unrelated
situations. surface_distance() and surface_allocation() are correct in
both cases, so only the direction output is affected. Found by the accuracy
sweep against xrspatial/surface_distance.py.
Bug 1: the bearing comes from row/column indices, not coordinates
_finalize_direction() (xrspatial/surface_distance.py:359-380) builds the
offset to the source pixel out of array indices scaled by an absolute cell
size:
dx = (src_col.astype(np.float64) - col_idx) * cellsize_x
dy = (src_row.astype(np.float64) - row_idx) * cellsize_y
_compute() has already dropped the axis direction at line 1315-1316
(cellsize_y = abs(float(cellsize_y))), so the row index stands in for the y
coordinate. On a north-up raster, which is what every GeoTIFF this library
reads produces, y descends as the row index rises. Every bearing then comes
out mirrored across the east-west axis, and north reads as south.
proximity.direction() documents the identical compass convention and uses
real coordinate values (_vectorized_calc_direction(query_x, target_x, query_y, target_y), xrspatial/proximity.py:931). The two functions disagree
on the same input. Same class of defect as #2896, where geodesic slope/aspect
used index coordinates in place of lat/lon.
Bug 2: the dask iterative path mixes global source indices with chunk-local pixel indices
_run_tile() records source positions as global indices at line 963-964
(src_row[r, c] = r + row_offset). _assemble_sd() hands those straight to
_extract_output() at line 1177, and _finalize_direction() builds its
row_idx/col_idx from np.arange over the tile shape. Global minus local
leaves the chunk offset sitting in the answer.
Every chunk except (0, 0) then reports the bearing it would have had as a
standalone raster. The output is visibly a tiled repeat of the first chunk.
This is the default path. max_distance defaults to np.inf, and
_surface_distance_dask() routes anything unbounded (or with an implied
radius larger than the chunk) to _sd_dask_iterative(). The bounded
map_overlap path keeps indices local on both sides and is unaffected.
Reproduction
Both run on CPU, no GPU needed.
Bug 1
import numpy as np
import xarray as xr
from xrspatial.surface_distance import surface_direction
from xrspatial.proximity import direction
def make(data, y):
return xr.DataArray(
data.astype(float), dims=['y', 'x'],
coords={'y': y, 'x': np.arange(data.shape[1], dtype=float)})
src = np.zeros((3, 3)); src[1, 1] = 1.0
elev = np.zeros((3, 3))
y = np.array([2.0, 1.0, 0.0]) # descending y, i.e. north-up
print(np.asarray(surface_direction(make(src, y), make(elev, y)).data))
print(np.asarray(direction(make(src, y)).data))
surface_direction: proximity.direction:
[[135. 180. 225.] [[ 45. 360. 315.]
[ 90. 0. 270.] [ 90. 0. 270.]
[ 45. 360. 315.]] [135. 180. 225.]]
Row 0 sits north of the source. proximity.direction says 360 (north),
surface_direction says 180 (south). Rerun the same call with an ascending y
coordinate and surface_direction prints byte-identical output. It never
looks at the coordinate.
Bug 2
import numpy as np, xarray as xr, dask.array as da
from xrspatial.surface_distance import surface_direction
def make(d, chunks=None):
a = xr.DataArray(
d.astype(float), dims=['y', 'x'],
coords={'y': np.arange(d.shape[0], dtype=float),
'x': np.arange(d.shape[1], dtype=float)},
attrs={'res': (1.0, 1.0)})
if chunks:
a.data = da.from_array(a.data, chunks=chunks)
return a
src = np.zeros((8, 10)); src[2, 3] = 1.0
elev = np.random.default_rng(7).uniform(0, 5, (8, 10))
base = np.asarray(surface_direction(make(src), make(elev)).data)
dk = surface_direction(make(src, (4, 5)), make(elev, (4, 5))).data.compute()
numpy: dask+numpy, 4x5 chunks:
[[123.7 135. 153.4 180. 206.6 225. ... [[123.7 135. 153.4 180. 206.6 123.7 ...
[108.4 116.6 135. 180. 225. 243.4 ... [108.4 116.6 135. 180. 225. 108.4 ...
[ 90. 90. 90. 0. 270. 270. ... [ 90. 90. 90. 0. 270. 90. ...
... ...
[ 31. 21.8 11.3 360. 348.7 338.2 ... [ 71.6 63.4 45. 126.9 135. 113.2 ...
direction max|diff| vs numpy: 360.0, 58 of 80 pixels differ
distance max|diff| vs numpy: 0.0
Expected behaviour
surface_direction() should report the bearing using the raster's real y/x
axis orientation and agree with proximity.direction() on the same input.
The dask result should match numpy on every chunk, bounded or unbounded.
Environment
xarray-spatial main @ 069fc13, Python 3.14, dask installed. Bug 1 affects
every backend (the cupy branch at line 602-616 repeats the same index math).
Bug 2 is specific to dask+numpy and dask+cupy on the unbounded path.
Description
surface_direction()returns wrong compass bearings in two unrelatedsituations.
surface_distance()andsurface_allocation()are correct inboth cases, so only the direction output is affected. Found by the accuracy
sweep against
xrspatial/surface_distance.py.Bug 1: the bearing comes from row/column indices, not coordinates
_finalize_direction()(xrspatial/surface_distance.py:359-380) builds theoffset to the source pixel out of array indices scaled by an absolute cell
size:
_compute()has already dropped the axis direction at line 1315-1316(
cellsize_y = abs(float(cellsize_y))), so the row index stands in for the ycoordinate. On a north-up raster, which is what every GeoTIFF this library
reads produces, y descends as the row index rises. Every bearing then comes
out mirrored across the east-west axis, and north reads as south.
proximity.direction()documents the identical compass convention and usesreal coordinate values (
_vectorized_calc_direction(query_x, target_x, query_y, target_y), xrspatial/proximity.py:931). The two functions disagreeon the same input. Same class of defect as #2896, where geodesic slope/aspect
used index coordinates in place of lat/lon.
Bug 2: the dask iterative path mixes global source indices with chunk-local pixel indices
_run_tile()records source positions as global indices at line 963-964(
src_row[r, c] = r + row_offset)._assemble_sd()hands those straight to_extract_output()at line 1177, and_finalize_direction()builds itsrow_idx/col_idxfromnp.arangeover the tile shape. Global minus localleaves the chunk offset sitting in the answer.
Every chunk except (0, 0) then reports the bearing it would have had as a
standalone raster. The output is visibly a tiled repeat of the first chunk.
This is the default path.
max_distancedefaults tonp.inf, and_surface_distance_dask()routes anything unbounded (or with an impliedradius larger than the chunk) to
_sd_dask_iterative(). The boundedmap_overlappath keeps indices local on both sides and is unaffected.Reproduction
Both run on CPU, no GPU needed.
Bug 1
Row 0 sits north of the source.
proximity.directionsays 360 (north),surface_directionsays 180 (south). Rerun the same call with an ascending ycoordinate and
surface_directionprints byte-identical output. It neverlooks at the coordinate.
Bug 2
Expected behaviour
surface_direction()should report the bearing using the raster's real y/xaxis orientation and agree with
proximity.direction()on the same input.The dask result should match numpy on every chunk, bounded or unbounded.
Environment
xarray-spatial
main@ 069fc13, Python 3.14, dask installed. Bug 1 affectsevery backend (the cupy branch at line 602-616 repeats the same index math).
Bug 2 is specific to dask+numpy and dask+cupy on the unbounded path.