Skip to content

surface_direction() returns mirrored bearings on north-up rasters and per-chunk-local bearings on the dask iterative path #3719

Description

@brendancol

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:surfaceArea: surfacebugSomething isn't workingdaskDask backend / chunked arraysseverity:highSweep finding: HIGHsweep-accuracyFound by /sweep-accuracy

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions