diff --git a/.claude/sweep-api-consistency-state.csv b/.claude/sweep-api-consistency-state.csv index 9bd6a61c8..7d90da5c4 100644 --- a/.claude/sweep-api-consistency-state.csv +++ b/.claude/sweep-api-consistency-state.csv @@ -8,6 +8,7 @@ interpolate,2026-06-12,3285,MEDIUM,2,"Sweep 2026-06-12 (deep-sweep-api-consisten mcda,2026-06-10,3148,HIGH,1;2;3;5,"Sweep 2026-06-10 (deep-sweep-api-consistency-mcda-2026-06-10). Fixed in this branch (#3148): (HIGH Cat 1) owa() named its criterion-weight dict criterion_weights while wlc/wpm/sensitivity use weights (same semantics, same _validate_weights); renamed to weights with keyword-only criterion_weights deprecation shim (DeprecationWarning; both names -> TypeError; positional callers untouched). (MEDIUM Cat 2) boolean_overlay annotated criteria as dict-only while every sibling combiner takes xr.Dataset; Dataset already worked via the Mapping interface -- now annotated/documented as xr.Dataset | dict. (MEDIUM Cat 3) ahp_weights docstring Raises claimed ValueError on incomplete comparisons but code warns (UserWarning) and defaults missing pairs to 1 -- docstring now documents Warns behaviour. (MEDIUM Cat 5) ConsistencyResult returned by public ahp_weights but absent from xrspatial/mcda __all__ and docs/source/reference/mcda.rst -- exported and documented. Documented, NOT fixed here: (MEDIUM Cat 2, deferred to parallel sweep-metadata sibling to avoid duplicate PR) constrain() drops attrs via xr.where while the other nine public functions preserve them. (LOW Cat 2) ahp_weights returns (weights, ConsistencyResult) tuple vs rank_weights bare dict -- intentional, documented in both docstrings, no fix. (LOW Cat 4) name=None inherit-input-name (standardize/constrain) vs literal-name defaults (combiners) -- defensible split, document only. Pre-existing backend bugs surfaced by the mandated cupy smoke (accuracy/test-coverage lane, recorded in #3148 body): owa fails on cupy (numpy order-weights array mixed into cupy multiply, combine.py ~336-340) and on ANY dask backend at graph construction (da.sort does not exist, combine.py:356, despite the owa MemoryError message recommending dask); sensitivity(method=monte_carlo) fails on cupy (template.values implicit-conversion guard). constrain on cupy blocked by the known library-wide cupy 13.6 + xarray xr.where astype incompat (dependency-pin issue), not mcda-specific. cuda-validated: CUDA_AVAILABLE=True; all 10 public functions smoke-tested on cupy DataArrays; owa weights=/criterion_weights= shim verified on numpy AND cupy entry points (cupy execution stops at the pre-existing mixed-array bug, signature acceptance confirmed)." morphology,2026-06-20,3399,MEDIUM,5,"Sweep 2026-06-20 (deep-sweep-api-consistency-morphology-2026-06-20). 1 MEDIUM Cat 5 finding filed as #3399, fixed in this branch (PR #3409). Cat 5 accessor-parity gap: all 7 public morphology functions are exported in xrspatial/__init__.py, documented, and tested, but the .xrs accessor exposed only morph_erode/dilate/opening/closing on both XrsSpatialDataArrayAccessor and XrsSpatialDatasetAccessor; morph_gradient/white_tophat/black_tophat were missing (da.xrs.morph_gradient -> AttributeError while da.xrs.morph_erode works). Root cause: base 4 ops landed #949 + accessors #1042; derived 3 ops landed later #1026 and the accessor was never updated. Fix adds the 3 forwarding methods to both accessor classes (mechanical, matches existing pattern) plus accessor tests (none existed for morph before) and guards all 7 method names in the expected-methods checks. Clean elsewhere: Cat 1 no in-module naming drift -- all 7 publics share the exact signature (agg, kernel=None, boundary='nan', name=''), verified programmatically; matches kernel-op siblings convolution_2d/focal.apply/hotspots which also use agg/kernel/boundary/name. Cat 2 no return drift (all 7 return xr.DataArray with coords/dims/attrs preserved via _dispatch / @supports_dataset). Cat 3 docstring/signature parity exact on all 7 (every param documented, Returns sections all DataArray). Cat 4 no default drift (kernel=None->3x3 ones, boundary='nan' uniform across all 7). Cross-cutting, notes only per template: convolution_2d orders name before boundary while morphology orders boundary before name (both keyword-defaulted, cosmetic); focal.apply/hotspots accept a keyword-only raster= alias for agg but that alias is focal-only (not a library convention) so morphology lacking it is not drift; library-wide first-arg agg vs raster drift spans 20+ modules, out of per-module scope. cuda-validated: CUDA_AVAILABLE=True on this host; all 7 publics smoke-tested with identical kwargs on numpy AND cupy DataArrays (shape parity, no signature drift between numpy/cupy entry points). PR reviewed (COMMENTED), no findings; branch merged with origin/main (clean), left BLOCKED on REVIEW_REQUIRED for user merge." multispectral,2026-06-20,3433,MEDIUM,3,"Sweep 2026-06-20 (deep-sweep-api-consistency-multispectral). 18 public funcs, all single 2D DataArray returns except true_color (3D composite, inherently different). (#3433 MEDIUM Cat 3) nbr() docstring documented swir_agg but signature param is swir2_agg; copying the name from docs raises TypeError. Docs-only rename to swir2_agg + guard test test_docstring_params_match_signature over the 17 index funcs. No deprecation needed. LOW, documented not fixed: (Cat 5) gci/nbr2/ndmi/true_color/ebbi are NOT re-exported in xrspatial/__init__.py while 13 module siblings are; not an orphan API since the canonical documented path is xrspatial.multispectral.X (all 18 in multispectral.rst) and tests import from the submodule -- convenience-surface gap only. (Cat 3 LOW) only savi types name: str; true_color lacks band type hints and uses r/g/b (conventional for an RGB composite). Cross-module note (not filed per template): multispectral has no -> xr.DataArray return annotations while sibling fire.py annotates all 7 -- library-wide convention drift. No Cat 1 in-module (all band inputs are _agg), no Cat 2 (return shapes consistent), no Cat 4 (no mutable defaults; soil_factor=1.0 consistent evi/savi). CUDA available: numpy+cupy smoke-tested, signatures parity-clean, full suite 171 passed." +pathfinding,2026-07-08,3644,MEDIUM,2;3;4;5,"Sweep 2026-07-08 (deep-sweep-api-consistency-pathfinding-2026-07-08). 4 MEDIUM, all FIXED in one PR (issue #3644): (Cat 2) multi_stop_search replaced input attrs with routing metadata so crs/res/units were lost while sibling a_star_search preserved them -- now merges surface.attrs with waypoint_order/segment_costs/total_cost. (Cat 5) a_star_search lacked @supports_dataset while multi_stop_search and library norm (cost_distance/proximity/surface_distance/hydro) have it; Dataset accessor exposed only multi_stop_search -- added decorator + ds.xrs.a_star_search. (Cat 3) start/goal annotated Union[tuple,list,np.array] (np.array is a function, fixed to np.ndarray); x/y annotated Optional[str] but None crashes (siblings use str, fixed); friction missing Optional; docstring said surface is '2D array of values to bin' (copy-paste) and connectivity had no description -- all corrected. (Cat 4) barriers: list = [] mutable default in both public funcs (never mutated, but siblings use None sentinel) -- now Optional[list]=None with in-body substitution; test asserts None==[]==omitted parity. Repro executed numpy + dask; cupy + dask+cupy smoke-tested on GPU host (Dataset routing, attrs merge, barriers=None all pass). DOCUMENTED not fixed (Cat 1, cross-cutting): first param named surface here vs raster (cost_distance/proximity/surface_distance) vs agg (terrain/focal); library split three ways so a per-module rename shim would not converge anything. LOW noted: a_star_search snap_start/snap_goal vs multi_stop_search single snap kwarg is intentional (per-endpoint vs per-waypoint semantics)." perlin,2026-06-23,3465,MEDIUM,3,"Sweep 2026-06-23 (deep-sweep-api-consistency-perlin-2026-06-23). 1 MEDIUM Cat 3 finding filed as #3465, fixed in PR #3468. perlin() signature has name: str = 'perlin' (perlin.py:288) but the docstring Parameters section documented only agg/freq/seed, omitting name; sibling generate_terrain() documents its identical name param (terrain.py:607). Fix adds 'name : str, default=""perlin"" / Name for the output DataArray.' matching the terrain style. Pure docstring fix, no signature/behavior change, no deprecation shim needed. Tests: test_perlin_name_param (default+custom name honored), test_perlin_docstring_documents_name (regression). 14/14 test_perlin.py pass incl GPU paths. No HIGH/CRITICAL. Cat 1 clean: agg/seed/name consistent with generate_terrain. Cat 2 clean: all generators return xr.DataArray. Cat 4: seed default 5 (perlin) vs 10 (terrain) is a per-generator domain default, not surprise drift. Cat 5 clean: perlin in __init__.py. cuda-validated: CUDA_AVAILABLE=True on this host, numpy and cupy entry points both accept/propagate name. Cross-cutting, notes only: bump() exposes no name param while perlin/generate_terrain both do -- adding name to bump is a separate bump-module change, out of per-module scope." polygonize,2026-06-12,3306;3307,MEDIUM,1;3,"Re-sweep 2026-06-12 (deep-sweep-api-consistency-polygonize-2026-06-12); prior pass 2026-05-19 (#2148). 2 MEDIUM findings filed and fixed on branches -01/-02 off this one. (#3306, MEDIUM Cat 3, branch -01) column_name docstring says 'Only used if return_type is geopandas or spatialpandas' but _to_geojson also consumes it as the per-feature property key (verified: properties={'myval': 1}); docs-only fix + test pinning geojson property naming. (#3307, MEDIUM sibling-behavior drift, branch -02) return_type is the only polygonize parameter validated AFTER the computation: invalid value runs the full backend (spy-verified 1 invocation before raise) while sibling contours() validates up front and lists allowed values; fix hoists the check into the top validation block with an allowed-values message (existing test matches on prefix, unaffected). Re-confirmed prior dispositions, still documented-only per cross-module rule: (HIGH Cat 1 cross-module) connectivity (polygonize, matches GDAL/rasterio/skimage) vs neighborhood (sieve.py, zonal.regions) for the identical 4|8 rook/queen concept -- rename shim belongs in sieve/zonal, out of polygonize scope; (LOW Cat 1 cross-cutting) raster (polygonize/sieve/clip_polygon) vs agg (contours/terrain family) first-arg drift, library-wide, not filed per-module. No new Cat 2 (return_type dispatch shapes match docstring Returns section exactly); no Cat 4 (atol/rtol mirror numpy.isclose, connectivity=4 == sieve neighborhood=4); Cat 5 LOW documented-only: module has no __all__ and the non-underscore internals generated_jit + Turn leak via import-star; polygonize re-exported in __init__.py and accessor, no orphan API. Docstring/signature parity otherwise exact (all 10 params documented, all annotated). Open polygonize issues #3292/#3293 checked -- no overlap with these findings. cuda-validated: CUDA_AVAILABLE=True on this host; polygonize smoke-tested with identical full kwargs on numpy, cupy (int + float atol/rtol=0), and dask+cupy; no backend signature drift." proximity,2026-06-09,3090;3091,HIGH,2;3,"Sweep 2026-06-09 (deep-sweep-api-consistency-proximity-2026-06-09). 1 HIGH Cat 2 finding (#3090): dask+numpy (and unbounded dask+cupy, which converts to it) KDTree path violates the documented lowest-flat-index tie-break in allocation()/direction() whenever the raster has >1 chunk column. _collect_region_targets concatenates targets chunk-major (iy outer, ix inner) so the tree's target order is not global row-major; _kdtree_query_lowest_index then ties to the wrong target. Existing tie-break tests put both targets in the same raster row where chunk order coincides with row-major, so they pass. Repro: 5x5, targets 2@(1,3) and 3@(2,2), chunks (5,3), pixel (2,3) tied at d=1 -> numpy gives 2, dask gives 3. Bounded map_overlap paths are fine (local row-major order is offset-invariant). 1 MEDIUM Cat 3 finding (#3091): all 3 public docstrings claim numpy + dask+numpy support only while cupy/dask+cupy backends exist, are dispatched, and are tested (the tie-break paragraphs in the same docstrings name all 4 backends); direction() opens with a stray copy-pasted slope line ('downward slope direction') plus a doubled 'the the'; allocation example output reads as float64 but the function returns float32; stale '# convert to have same type as of input @raster' comment. Within-module Cat 1/4/5 clean: proximity/allocation/direction share an identical signature (raster, x='x', y='y', target_values=None, max_distance=np.inf, distance_metric='EUCLIDEAN'); consistent with surface_distance siblings (raster/x/y/target_values/max_distance); all 6 public symbols (incl. euclidean/manhattan/great_circle_distance) re-exported in __init__.py, no orphan API. Cross-cutting, documented not filed: sibling distance modules (surface_distance, cost_distance, balanced_allocation) use mutable default target_values: list = [] while proximity uses the None sentinel - the mutable-default fix belongs to those modules; proximity's target_values: list = None hint would be more precise as Optional[list] (LOW, matches library style). cuda-validated: CUDA_AVAILABLE=True on this host; proximity/allocation/direction smoke-tested with identical kwargs on numpy, cupy, dask+numpy, dask+cupy (proximity parity passed; allocation/direction parity failure is finding #3090)." diff --git a/xrspatial/accessor.py b/xrspatial/accessor.py index e2d20b0b3..55eae0310 100644 --- a/xrspatial/accessor.py +++ b/xrspatial/accessor.py @@ -2065,6 +2065,10 @@ def surface_direction(self, elevation, **kwargs): # ---- Pathfinding ---- + def a_star_search(self, start, goal, **kwargs): + from .pathfinding import a_star_search + return a_star_search(self._obj, start, goal, **kwargs) + def multi_stop_search(self, waypoints, **kwargs): from .pathfinding import multi_stop_search return multi_stop_search(self._obj, waypoints, **kwargs) diff --git a/xrspatial/dataset_support.py b/xrspatial/dataset_support.py index fc758d802..2d2ba164c 100644 --- a/xrspatial/dataset_support.py +++ b/xrspatial/dataset_support.py @@ -17,18 +17,29 @@ def supports_dataset(func): """ sig = inspect.signature(func) has_name_param = 'name' in sig.parameters + first_param = next(iter(sig.parameters)) @functools.wraps(func) - def wrapper(agg, *args, **kwargs): + def wrapper(*args, **kwargs): + # Bind the raster whether it was passed positionally or by its + # real keyword name (e.g. cost_distance(raster=...)); a wrapper + # that only accepts it positionally breaks keyword callers. + if args: + agg, rest = args[0], args[1:] + elif first_param in kwargs: + agg, rest = kwargs.pop(first_param), () + else: + # Missing entirely: let func raise its own TypeError + return func(*args, **kwargs) if isinstance(agg, xr.Dataset): results = {} for var_name in agg.data_vars: kw = dict(kwargs) if has_name_param: kw['name'] = var_name - results[var_name] = func(agg[var_name], *args, **kw) + results[var_name] = func(agg[var_name], *rest, **kw) return xr.Dataset(results, attrs=agg.attrs) - return func(agg, *args, **kwargs) + return func(agg, *rest, **kwargs) return wrapper diff --git a/xrspatial/pathfinding.py b/xrspatial/pathfinding.py index 3c1a4199c..ee7e019c1 100644 --- a/xrspatial/pathfinding.py +++ b/xrspatial/pathfinding.py @@ -791,16 +791,17 @@ def _make_block(ch, cw, pixels): return da.block(blocks) +@supports_dataset def a_star_search(surface: xr.DataArray, - start: Union[tuple, list, np.array], - goal: Union[tuple, list, np.array], - barriers: list = [], - x: Optional[str] = 'x', - y: Optional[str] = 'y', + start: Union[tuple, list, np.ndarray], + goal: Union[tuple, list, np.ndarray], + barriers: Optional[list] = None, + x: str = 'x', + y: str = 'y', connectivity: int = 8, snap_start: bool = False, snap_goal: bool = False, - friction: xr.DataArray = None, + friction: Optional[xr.DataArray] = None, search_radius: Optional[int] = None) -> xr.DataArray: """ Calculate the least-cost path from a starting point to a goal through @@ -847,8 +848,10 @@ def a_star_search(surface: xr.DataArray, Parameters ---------- - surface : xr.DataArray - 2D array of values to bin. + surface : xr.DataArray or xr.Dataset + 2D array of the surface to find a path across. A Dataset routes + each data variable independently and returns a Dataset of the + per-variable results. start : array-like object of 2 numeric elements (y, x) or (lat, lon) coordinates of the starting point. The point is mapped to the pixel whose cell center is nearest. @@ -857,18 +860,19 @@ def a_star_search(surface: xr.DataArray, (y, x) or (lat, lon) coordinates of the goal location. Mapped to the nearest cell center; a point outside the raster bounds raises a ``ValueError``. - barriers : array like object, default=[] + barriers : array like object, optional List of values inside the surface which are barriers - (cannot cross). + (cannot cross). Default is no barriers. x : str, default='x' Name of the x coordinate in input surface raster. - y: str, default='y' + y : str, default='y' Name of the y coordinate in input surface raster. connectivity : int, default=8 - snap_start: bool, default=False + Use 4 or 8 pixel connectivity to define neighboring cells. + snap_start : bool, default=False Snap the start location to the nearest valid value before beginning pathfinding. - snap_goal: bool, default=False + snap_goal : bool, default=False Snap the goal location to the nearest valid value before beginning pathfinding. friction : xr.DataArray, optional @@ -891,6 +895,7 @@ def a_star_search(surface: xr.DataArray, path_agg: xr.DataArray of the same type as `surface`. 2D array of pathfinding values. All other input attributes are preserved. + A Dataset input returns a Dataset of per-variable results. References ---------- @@ -964,6 +969,8 @@ def a_star_search(surface: xr.DataArray, if not _is_inside(goal_py, goal_px, h, w): raise ValueError("goal location outside the surface graph.") + if barriers is None: + barriers = [] barriers = np.asarray(barriers) # --- Snap / crossability checks --- @@ -1360,12 +1367,12 @@ def _optimize_waypoint_order(surface, waypoints, barriers, x, y, @supports_dataset def multi_stop_search(surface: xr.DataArray, waypoints: list, - barriers: list = [], - x: Optional[str] = 'x', - y: Optional[str] = 'y', + barriers: Optional[list] = None, + x: str = 'x', + y: str = 'y', connectivity: int = 8, snap: bool = False, - friction: xr.DataArray = None, + friction: Optional[xr.DataArray] = None, search_radius: Optional[int] = None, optimize_order: bool = False) -> xr.DataArray: """Find the least-cost path visiting a sequence of waypoints in order. @@ -1385,8 +1392,8 @@ def multi_stop_search(surface: xr.DataArray, waypoints : list of array-like Sequence of ``(y, x)`` coordinate pairs to visit. Must contain at least two points. - barriers : list, default=[] - Surface values that are impassable. + barriers : list, optional + Surface values that are impassable. Default is no barriers. x, y : str, default ``'x'`` / ``'y'`` Coordinate dimension names. connectivity : int, default=8 @@ -1406,9 +1413,10 @@ def multi_stop_search(surface: xr.DataArray, ------- xr.DataArray or xr.Dataset Cumulative path cost surface, backed by the same array type as - *surface* (numpy, cupy, dask, or dask+cupy). Attributes include - ``waypoint_order``, ``segment_costs``, and ``total_cost``. - A Dataset input returns a Dataset of per-variable results. + *surface* (numpy, cupy, dask, or dask+cupy). Input attributes + are preserved, with ``waypoint_order``, ``segment_costs``, and + ``total_cost`` added. A Dataset input returns a Dataset of + per-variable results. Raises ------ @@ -1421,6 +1429,9 @@ def multi_stop_search(surface: xr.DataArray, _validate_raster(surface, func_name='multi_stop_search', name='surface', ndim=2) + if barriers is None: + barriers = [] + if friction is not None: _validate_raster(friction, func_name='multi_stop_search', name='friction', ndim=2) @@ -1531,6 +1542,7 @@ def multi_stop_search(surface: xr.DataArray, coords=surface.coords, dims=surface.dims, attrs={ + **surface.attrs, 'waypoint_order': [tuple(wp) for wp in waypoints], 'segment_costs': segment_costs, 'total_cost': cumulative_cost, diff --git a/xrspatial/tests/test_accessor.py b/xrspatial/tests/test_accessor.py index 8801fa18b..e2f73bd0a 100644 --- a/xrspatial/tests/test_accessor.py +++ b/xrspatial/tests/test_accessor.py @@ -118,7 +118,7 @@ def test_dataset_accessor_has_expected_methods(): 'morph_erode', 'morph_dilate', 'morph_opening', 'morph_closing', 'morph_gradient', 'morph_white_tophat', 'morph_black_tophat', 'proximity', 'allocation', 'direction', 'cost_distance', - 'multi_stop_search', + 'a_star_search', 'multi_stop_search', 'ndvi', 'evi', 'arvi', 'savi', 'nbr', 'sipi', 'rasterize', 'validate', @@ -301,6 +301,16 @@ def test_da_multi_stop_search_kwargs(elevation): xr.testing.assert_identical(result, expected) +def test_ds_a_star_search(elevation): + from xrspatial.pathfinding import a_star_search + ds = xr.Dataset({'a': elevation, 'b': elevation + 100}) + start, goal = (0, 0), (9, 9) + expected = a_star_search(ds, start, goal) + result = ds.xrs.a_star_search(start, goal) + xr.testing.assert_identical(result, expected) + assert set(result.data_vars) == {'a', 'b'} + + def test_ds_multi_stop_search(elevation): from xrspatial.pathfinding import multi_stop_search ds = xr.Dataset({'a': elevation, 'b': elevation + 100}) diff --git a/xrspatial/tests/test_dataset_support.py b/xrspatial/tests/test_dataset_support.py index 06b305e0a..8a597afa6 100644 --- a/xrspatial/tests/test_dataset_support.py +++ b/xrspatial/tests/test_dataset_support.py @@ -117,6 +117,39 @@ def test_dataset_error_propagation(self): with pytest.raises(Exception): slope(ds) + def test_first_param_by_keyword(self, elevation_dataset): + """The raster binds by its real keyword name, not just positionally. + + Regression test: the wrapper used to require the first argument + positionally, so slope(agg=...) worked by accident while + cost_distance(raster=...) and a_star_search(surface=...) raised + TypeError. + """ + da = elevation_dataset['dem1'] + xr.testing.assert_allclose(slope(agg=da), slope(da)) + + from xrspatial import proximity + target = da.copy() + target.data = np.zeros_like(target.data) + target.data[10, 10] = 1.0 + xr.testing.assert_allclose( + proximity(raster=target), proximity(target)) + + from xrspatial import a_star_search + surf = da.copy() + surf['y'] = np.linspace(19, 0, 20) + surf['x'] = np.linspace(0, 19, 20) + surf.attrs['res'] = (1.0, 1.0) # match the replaced coords + start, goal = (19.0, 0.0), (0.0, 19.0) + xr.testing.assert_allclose( + a_star_search(surface=surf, start=start, goal=goal), + a_star_search(surf, start, goal), + ) + + def test_first_param_missing_raises_typeerror(self): + with pytest.raises(TypeError): + slope() + def test_aspect_dataset(self, elevation_dataset): result = aspect(elevation_dataset) assert isinstance(result, xr.Dataset) diff --git a/xrspatial/tests/test_pathfinding.py b/xrspatial/tests/test_pathfinding.py index 0db74e791..1c8897b65 100644 --- a/xrspatial/tests/test_pathfinding.py +++ b/xrspatial/tests/test_pathfinding.py @@ -1195,3 +1195,64 @@ def test_multi_stop_caps_waypoints(self): too_many = [(i % 10, (i * 7) % 10) for i in range(_MAX_WAYPOINTS + 1)] with pytest.raises(ValueError, match=f"at most {_MAX_WAYPOINTS}"): multi_stop_search(s, too_many) + + +# --- API consistency tests (#3644) --- + +def test_multi_stop_search_preserves_input_attrs(): + """Input attrs survive alongside the routing attrs.""" + data = np.ones((8, 8)) + agg = _make_raster(data) + agg.attrs['crs'] = 4326 + agg.attrs['units'] = 'm' + + result = multi_stop_search(agg, [(7.0, 0.0), (0.0, 7.0)]) + + # input attrs preserved + assert result.attrs['crs'] == 4326 + assert result.attrs['units'] == 'm' + assert result.attrs['res'] == (1.0, 1.0) + # routing attrs added + for key in ('waypoint_order', 'segment_costs', 'total_cost'): + assert key in result.attrs + + +def test_a_star_search_accepts_dataset(): + """a_star_search on a Dataset routes each variable, like its sibling.""" + import xarray as xr + data = np.ones((8, 8)) + agg = _make_raster(data) + ds = xr.Dataset({'elev': agg, 'other': agg + 1.0}) + start, goal = (7.0, 0.0), (0.0, 7.0) + + result = a_star_search(ds, start, goal) + + assert isinstance(result, xr.Dataset) + assert set(result.data_vars) == {'elev', 'other'} + expected = a_star_search(agg, start, goal) + np.testing.assert_allclose( + result['elev'].values, expected.values, equal_nan=True) + + +def test_barriers_default_none_matches_empty_list(): + """barriers=None (new default) behaves like the old barriers=[]. + + Numpy only: the None -> [] substitution happens before backend + dispatch, so it is backend-independent. + """ + data = np.ones((6, 6)) + agg = _make_raster(data) + start, goal = (5.0, 0.0), (0.0, 5.0) + + r_default = a_star_search(agg, start, goal) + r_none = a_star_search(agg, start, goal, barriers=None) + r_empty = a_star_search(agg, start, goal, barriers=[]) + + np.testing.assert_allclose( + r_none.values, r_default.values, equal_nan=True) + np.testing.assert_allclose( + r_empty.values, r_default.values, equal_nan=True) + + m_none = multi_stop_search(agg, [start, goal], barriers=None) + np.testing.assert_allclose( + m_none.values, r_default.values, equal_nan=True)