diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 60459b444..58ab835ff 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -18,6 +18,7 @@ mahalanobis,2026-06-30,3583,MEDIUM,2;3;4,,"Deep-sweep 2026-06-30 test-coverage o mcda,2026-06-10,3149,HIGH,1;2;5,,"Pass 1 (2026-06-10, deep-sweep test-coverage): test_mcda.py had 175 tests, all numpy or dask+numpy -- zero cupy/dask+cupy coverage despite explicit cupy branches in standardize._get_xp and combine._sort_descending (Cat 1 HIGH). Filed #3149, added ~70 tests: cross-backend parity for standardize (7 methods) x cupy/dask+numpy/dask+cupy, combine (wlc/wpm/fuzzy and-or-sum-product-gamma/owa) x 3 backends, constrain, boolean_overlay, sensitivity OAT+MC on GPU backends; metadata preservation (attrs/coords/dims/name) for every stage (Cat 5 MEDIUM); wpm all-NaN criterion + Inf propagation through wlc/fuzzy-and (Cat 2 MEDIUM). All RUN on a CUDA host: 233 passed, 11 xfailed. Probing surfaced real source bugs already filed by sibling sweeps as #3146 (owa raises on ALL dask backends -- _sort_descending calls nonexistent da.sort; owa cupy mixes numpy order weights into cupy stack; piecewise standardize broken on cupy + dask+cupy and categorical on dask+cupy via np.asarray on cupy chunks; monte_carlo sensitivity reads .values on cupy data) and #3147 (constrain drops attrs when masks applied) -- those paths pinned with strict xfail markers to flip on fix; constrain cupy/dask+cupy xfail(strict=False) on the known cupy 13.6 + xarray xr.where dependency incompat, not an mcda bug. Source untouched (test-only PR). LOW (documented, not fixed): name= output parameter untested across combine functions; empty (0-row) raster untested -- elementwise ops, judged low value. weights.py (ahp/rank) is pure-numpy metadata, backend matrix N/A, already well covered." morphology,2026-06-20,3404,MEDIUM,2;3,,"Added Inf/-Inf, all-NaN, Nx1/1xN strip, integer-dtype tests; source already correct, regression guards only; cupy + dask+cupy ran on GPU host" multispectral,2026-06-20,3431,MEDIUM,2;3;4,,true_color NaN/alpha + all-equal range_val==0 + nondefault nodata/c/th; evi & savi validation error paths; GPU tests ran (cupy+dask+cupy) +pathfinding,2026-07-08,3657,HIGH,1;2;3;4,96,"added 30 tests (86->96): dask barrier/NaN/no-path/warn paths, 1x1+strips all backends, error+param branches, cupy snap/radius run on GPU, HPA* friction; LOW left: chunk-cache eviction, TSP branches, non-square cellsize, _get_pixel_id default dims; dims error test deferred to #3649; dead code L1065 noted in issue" perlin,2026-06-23,,HIGH,3;4;5,,"added tests: seed/freq/name params, single-row, 1x1+Nx1 degenerate (pins all-NaN bug), dims+attrs preserve, coords-drop (pins bug); GPU tests ran on CUDA host; 2 source bugs surfaced (coords dropped; 1x1/Nx1 all-NaN ptp div0) need issues filed (gh issue create blocked by auto-mode)" polygon_clip,2026-06-10,3197,MEDIUM,1;2;3;5,,"deep-sweep test-coverage 2026-06-10 on a CUDA host. Existing file covered numpy well + one parity test per dask/cupy/dask+cupy backend. Filed #3197 (test-only) and added 13 tests: Cat1 GPU param/NaN coverage (cupy + dask+cupy each get custom nodata, all_touched=True, and NaN-input preservation vs numpy; previously only crop=False inner polygon ran on GPU); Cat2 Inf/-Inf preserved, all-NaN input -> all-NaN, int32 + sentinel nodata=-1; Cat3 Nx1 + 1xN strip rasters; Cat5 coords preserved (crop=False) + crop coords are a contiguous subset of input coords (crop=True). All 13 RAN+PASSED on GPU (6 GPU tests not skipped); full file 36 passed 0 skipped. LOW (documented, NOT fixed): rasterize_kw forwarding never tested; non-square cellsize never tested. SOURCE NOTE (out of scope, not filed): clip_polygon docstring says 'named y and x dims' but rasterize() hard-requires literal y/x, so lat/lon-dim rasters raise -- dim-name preservation (Cat5) is therefore unsupported by contract, not a test gap. SOURCE NOTE 2: polygon_clip.py:216 still passes rasterize(use_cuda=True) on dask+cupy (renamed to gpu= in #3089); harmless deprecation alias today, candidate for an api-consistency follow-up." polygonize,2026-06-12,3299,MEDIUM,1,,"Pass 4 (2026-06-12): added test_polygonize_mask_chunk_mismatch_3299.py (25 tests, all passing on a CUDA host incl. dask+cupy). Closes Cat 1 MEDIUM: the _polygonize_dask mask-rechunk branch (mask_data.chunks != dask_data.chunks -> rechunk) was never exercised; every prior dask masked test used mask chunks identical to the raster's. Mismatched layouts pinned against same-backend aligned-mask reference: (6,7) same-grid-shape misalignment (the silent-corruption layout for int rasters), single-chunk (15,18), more-blocks (4,5); int+float rasters, connectivity 4/8, dask+numpy and dask+cupy; plus exact-geometry single-masked-pixel hole anchor. Mutation (delete the rechunk guard) flips all 25 red; clean md5 restore. Full polygonize suite 486 passed / 16 skipped. Test-only; source untouched. Issue #3299. Audit re-confirmed Cat 2/3/4 closed by passes 1-3 and post-2026-05-29 changes (#2913 float-mask fix flipped prior xfails, #3041 has issue-2677 test file, #2673/#2817 covered by batch-invariance and heap tests); Cat 5 N/A (no DataArray output; CRS/transform propagation already tested). | Pass 3 (2026-05-29): added test_polygonize_mask_dtype_coverage_2026_05_29.py (41 passed, 8 xfailed on a CUDA host). Closes Cat 4 MEDIUM parameter-coverage gap: mask= is documented to accept bool/integer/float values but every prior test passed only a bool mask. Integer masks (int32/int64) now pinned against the same-backend bool-mask output on all four backends x both raster dtypes x connectivity 4/8; float-mask-on-integer-raster also pinned. Each backend is compared to its OWN bool reference to isolate mask-dtype from the unrelated numpy-vs-dask hole-vs-single-ring representation difference. Mutation (drop the not-mask[ij] exclusion in _calculate_regions) flips 11 tests red incl. the pixel-exclusion sanity anchor; clean md5 restore. Surfaced source bug #2623: a float-dtype mask on a float-dtype raster raises TypeError at polygonize.py:918 (mask & nan_mask; bitwise_and undefined for float&bool; cupy/dask route floats through _polygonize_numpy so they crash too; int masks coerce fine). 8 float-mask cases marked xfail(strict, raises=TypeError) referencing #2623. Test-only; source untouched. | Pass 2 (2026-05-27): added test_polygonize_atol_rtol_backend_coverage_2026_05_27.py with 15 tests, all passing on a CUDA host. Closes Cat 4 MEDIUM parameter-coverage gap on atol/rtol forwarding through the cupy and dask+cupy backends. atol/rtol were exposed by #2173 / #2194 and thread through _polygonize_cupy (polygonize.py:808) and _polygonize_dask (polygonize.py:1719); the dask path further plumbs them into dask.delayed(_polygonize_chunk)(...) at lines 1748-1754 and into _bucket_key_for_value for cross-chunk merge bucketing at lines 1757-1758. Pre-existing tests covered non-default atol/rtol only on numpy and dask+numpy. The cupy and dask+cupy dispatchers were untested -- a regression dropping the kwargs there would silently change the float polygon count and would not be caught. Same dispatcher-silently-drops-kwarg pattern fixed by #1561 / #1605 / #1685 / #1810 / #1974 on adjacent GeoTIFF surfaces. 15 tests: cupy strict-equality + default-tolerance pin on _REPRO_2173, dask+cupy strict-equality single-chunk + multi-chunk (engages cross-chunk merge bucket) + default-tolerance multi-chunk pin, cupy intermediate-atol small/large pair, dask+cupy intermediate-atol single/multi-chunk small + single-chunk large, cupy integer atol-ignored matrix, dask+cupy integer atol-ignored single-chunk + multi-chunk, cupy rtol-only large/small matrix. Mutation against _polygonize_cupy float branch (drop atol/rtol kwargs in the _polygonize_numpy forward call at polygonize.py:823-825) flips 3 of 5 cupy tests red; mutation against dask.delayed(_polygonize_chunk)(...) at polygonize.py:1748-1754 (drop atol, rtol args) flips 2 of 6 dask+cupy tests red. Confirmed clean restore via md5sum. Source untouched. Filed issue #2537 (test-only). Cat 4 MEDIUM (parameter coverage on cupy + dask+cupy atol/rtol forwarding). Pass 1 (2026-05-19): added test_polygonize_coverage_2026_05_19.py with 58 tests, all passing on a CUDA host. Closes Cat 3 HIGH 1x1 / Nx1 single-column geometric gaps (Nx1 exercises the nx==1 padding path at polygonize.py:565 and the cupy nx==1 numpy-fallback at polygonize.py:671), Cat 3 MEDIUM 1xN single-row and all-equal-value rasters on all four backends. Closes Cat 2 HIGH NaN parity for cupy + dask+cupy (numpy/dask were already covered by test_polygonize_nan_pixels_excluded*), Cat 2 MEDIUM all-NaN raster on all four backends, Cat 2 HIGH +/-Inf pins on all four backends. Filed source-bug issue #2155: numpy/dask/dask+cupy backends silently absorb Inf cells into adjacent finite polygons because _is_close reduces abs(inf-inf) to nan; cupy backend handles Inf correctly. Pins lock the asymmetric behaviour so the fix is visible. Closes Cat 1 MEDIUM simplify_tolerance + mask= parity gaps on dask+cupy backend (numpy/cupy/dask were already covered). Closes Cat 4 MEDIUM column_name non-default value across geopandas/spatialpandas/geojson return types and Cat 4 MEDIUM validation error paths (bad connectivity, bad transform length, mask shape mismatch, mask underlying-type mismatch). Cat 5 N/A: polygonize returns lists/dataframes, not a DataArray with attrs to propagate." diff --git a/xrspatial/tests/test_pathfinding.py b/xrspatial/tests/test_pathfinding.py index 0db74e791..5d4c87f7e 100644 --- a/xrspatial/tests/test_pathfinding.py +++ b/xrspatial/tests/test_pathfinding.py @@ -1195,3 +1195,415 @@ 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) + + +# ===================================================================== +# Issue #3657: coverage for dask edge handling, degenerate shapes, +# and untested parameter branches +# ===================================================================== + +# --- Dask barrier / NaN / no-path handling (separate pure-Python A*) --- + +_barrier_grid = np.array([[0, 1, 0, 0], + [1, 1, 0, 0], + [0, 1, 2, 2], + [1, 0, 2, 0], + [0, 2, 2, 2]], dtype=np.float64) + + +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_dask_barriers_match_numpy(): + """Dask A* honors barrier values exactly like the numpy kernel.""" + agg_np = _make_raster(_barrier_grid, backend='numpy') + agg_dask = _make_raster(_barrier_grid, backend='dask+numpy', chunks=(2, 2)) + + start = (4.0, 1.0) # pixel (0, 1), value 1 + goal = (0.0, 1.0) # pixel (4, 1), value 2 + + path_np = a_star_search(agg_np, start, goal, barriers=[0]) + path_dask = a_star_search(agg_dask, start, goal, barriers=[0]) + + # sanity: the path routes around the zeros + assert np.isfinite(path_np.values[4, 1]) + np.testing.assert_allclose( + np.asarray(path_dask.values), + path_np.values, + equal_nan=True, atol=1e-10, + ) + + +@cuda_and_cupy_available +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_dask_cupy_barriers_match_numpy(): + """Dask+cupy A* honors barriers through the cupy chunk conversion.""" + agg_np = _make_raster(_barrier_grid, backend='numpy') + agg_dc = _make_raster(_barrier_grid, backend='dask+cupy', chunks=(2, 2)) + + start = (4.0, 1.0) + goal = (0.0, 1.0) + + path_np = a_star_search(agg_np, start, goal, barriers=[0]) + path_dc = a_star_search(agg_dc, start, goal, barriers=[0]) + + dc_computed = path_dc.data.compute() + if hasattr(dc_computed, 'get'): + dc_computed = dc_computed.get() + np.testing.assert_allclose( + dc_computed, + path_np.values, + equal_nan=True, atol=1e-10, + ) + + +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_dask_nan_wall_no_path(): + """Dask A* returns all-NaN when NaN cells block every route.""" + data = np.ones((6, 6)) + data[:, 3] = np.nan + agg = _make_raster(data, backend='dask+numpy', chunks=(3, 3)) + + path = a_star_search(agg, (5.0, 0.0), (0.0, 5.0)) + assert isinstance(path.data, da.Array) + assert not np.isfinite(np.asarray(path.values)).any() + + +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_dask_start_not_crossable_warns_empty(): + """Non-crossable start on dask warns and yields an all-NaN result.""" + agg = _make_raster(_barrier_grid, backend='dask+numpy', chunks=(2, 2)) + start = (4.0, 0.0) # pixel (0, 0), value 0 = barrier + goal = (0.0, 1.0) + + with pytest.warns(Warning, match="Start at a non crossable location"): + path = a_star_search(agg, start, goal, barriers=[0]) + assert not np.isfinite(np.asarray(path.values)).any() + + +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_dask_goal_not_crossable_warns_empty(): + """Non-crossable goal on dask warns and yields an all-NaN result.""" + agg = _make_raster(_barrier_grid, backend='dask+numpy', chunks=(2, 2)) + start = (4.0, 1.0) + goal = (1.0, 3.0) # pixel (3, 3), value 0 = barrier + + with pytest.warns(Warning, match="End at a non crossable location"): + path = a_star_search(agg, start, goal, barriers=[0]) + assert not np.isfinite(np.asarray(path.values)).any() + + +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_dask_friction_blocked_start_empty(): + """NaN friction at the start pixel gives no path on dask.""" + data = np.ones((5, 5)) + friction_data = np.ones((5, 5)) + friction_data[0, 0] = np.nan # start pixel + + agg = _make_raster(data, backend='dask+numpy', chunks=(3, 3)) + friction_agg = _make_raster(friction_data, backend='dask+numpy', + chunks=(3, 3)) + + path = a_star_search(agg, (4.0, 0.0), (0.0, 4.0), friction=friction_agg) + assert not np.isfinite(np.asarray(path.values)).any() + + +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_dask_surface_numpy_friction(): + """A numpy-backed friction raster is promoted to match dask chunks.""" + data = np.ones((5, 5)) + friction_data = np.ones((5, 5)) + friction_data[2, 2] = 2.0 + + agg_np = _make_raster(data, backend='numpy') + friction_np = _make_raster(friction_data, backend='numpy') + agg_dask = _make_raster(data, backend='dask+numpy', chunks=(3, 3)) + + path_np = a_star_search(agg_np, (4.0, 0.0), (0.0, 4.0), + friction=friction_np) + path_dask = a_star_search(agg_dask, (4.0, 0.0), (0.0, 4.0), + friction=friction_np) + + np.testing.assert_allclose( + np.asarray(path_dask.values), + path_np.values, + equal_nan=True, atol=1e-10, + ) + + +# --- Degenerate raster shapes --- + +@pytest.mark.parametrize("backend", _backends) +def test_single_pixel_raster(backend): + """1x1 raster: start == goal at the only pixel, cost 0.""" + agg = _make_raster(np.ones((1, 1)), backend=backend) + path = a_star_search(agg, (0.0, 0.0), (0.0, 0.0)) + vals = np.asarray(path.values) + assert vals.shape == (1, 1) + assert vals[0, 0] == 0.0 + + +@pytest.mark.parametrize("backend", _backends) +def test_single_row_raster(backend): + """1xN strip: path walks the row with unit-step costs.""" + agg = _make_raster(np.ones((1, 5)), backend=backend) + path = a_star_search(agg, (0.0, 0.0), (0.0, 4.0)) + np.testing.assert_allclose( + np.asarray(path.values), [[0., 1., 2., 3., 4.]], atol=1e-10) + + +@pytest.mark.parametrize("backend", _backends) +def test_single_column_raster(backend): + """Nx1 strip: path walks the column with unit-step costs.""" + agg = _make_raster(np.ones((5, 1)), backend=backend) + path = a_star_search(agg, (4.0, 0.0), (0.0, 0.0)) + np.testing.assert_allclose( + np.asarray(path.values).ravel(), [0., 1., 2., 3., 4.], atol=1e-10) + + +@cuda_and_cupy_available +def test_degenerate_shapes_cupy(): + """1x1 and 1xN rasters work on the cupy backend.""" + agg1 = _make_raster(np.ones((1, 1)), backend='cupy') + path1 = a_star_search(agg1, (0.0, 0.0), (0.0, 0.0)) + assert path1.data.get()[0, 0] == 0.0 + + agg2 = _make_raster(np.ones((1, 5)), backend='cupy') + path2 = a_star_search(agg2, (0.0, 0.0), (0.0, 4.0)) + np.testing.assert_allclose( + path2.data.get(), [[0., 1., 2., 3., 4.]], atol=1e-10) + + agg3 = _make_raster(np.ones((5, 1)), backend='cupy') + path3 = a_star_search(agg3, (4.0, 0.0), (0.0, 0.0)) + np.testing.assert_allclose( + path3.data.get().ravel(), [0., 1., 2., 3., 4.], atol=1e-10) + + +# --- Parameter / error-path coverage --- + +def test_invalid_connectivity_raises(): + agg = _make_raster(np.ones((5, 5))) + with pytest.raises(ValueError, match="4 or 8-connectivity"): + a_star_search(agg, (4.0, 0.0), (0.0, 4.0), connectivity=5) + + +def test_a_star_friction_shape_mismatch_raises(): + agg = _make_raster(np.ones((5, 5))) + friction_agg = _make_raster(np.ones((3, 3))) + with pytest.raises(ValueError, match="same shape as surface"): + a_star_search(agg, (4.0, 0.0), (0.0, 4.0), friction=friction_agg) + + +def test_friction_no_positive_finite_raises_numpy(): + agg = _make_raster(np.ones((5, 5))) + friction_agg = _make_raster(np.zeros((5, 5))) + with pytest.raises(ValueError, match="no positive finite values"): + a_star_search(agg, (4.0, 0.0), (0.0, 4.0), friction=friction_agg) + + +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_friction_no_positive_finite_raises_dask(): + agg = _make_raster(np.ones((5, 5)), backend='dask+numpy', chunks=(3, 3)) + friction_agg = _make_raster(np.zeros((5, 5)), backend='dask+numpy', + chunks=(3, 3)) + with pytest.raises(ValueError, match="no positive finite values"): + a_star_search(agg, (4.0, 0.0), (0.0, 4.0), friction=friction_agg) + + +@cuda_and_cupy_available +def test_friction_no_positive_finite_raises_cupy(): + agg = _make_raster(np.ones((5, 5)), backend='cupy') + friction_agg = _make_raster(np.zeros((5, 5)), backend='cupy') + with pytest.raises(ValueError, match="no positive finite values"): + a_star_search(agg, (4.0, 0.0), (0.0, 4.0), friction=friction_agg) + + +def test_friction_blocked_start_empty_numpy(): + """NaN friction at the start pixel gives no path on numpy.""" + data = np.ones((5, 5)) + friction_data = np.ones((5, 5)) + friction_data[0, 0] = np.nan # start pixel + + agg = _make_raster(data) + friction_agg = _make_raster(friction_data) + + path = a_star_search(agg, (4.0, 0.0), (0.0, 4.0), friction=friction_agg) + assert not np.isfinite(path.values).any() + + +def test_multi_stop_friction_shape_mismatch_raises(): + agg = _make_raster(np.ones((5, 5))) + friction_agg = _make_raster(np.ones((3, 3))) + with pytest.raises(ValueError, match="same shape as surface"): + multi_stop_search(agg, [(4.0, 0.0), (0.0, 4.0)], + friction=friction_agg) + + +def test_search_radius_with_friction_matches_full(): + """Bounded A* slices the friction surface consistently with full A*.""" + data = np.ones((5, 5)) + friction_data = np.ones((5, 5)) + friction_data[2, 2] = 5.0 + + agg = _make_raster(data) + friction_agg = _make_raster(friction_data) + + start = (4.0, 0.0) + goal = (0.0, 4.0) + + path_full = a_star_search(agg, start, goal, friction=friction_agg) + path_bounded = a_star_search(agg, start, goal, friction=friction_agg, + search_radius=10) + np.testing.assert_allclose( + path_bounded.values, path_full.values, equal_nan=True, atol=1e-10) + + +def test_multi_stop_snap_remaps_goal(): + """snap=True routes through a waypoint that sits on a NaN pixel.""" + data = np.ones((6, 6)) + data[0, 3] = np.nan # middle waypoint lands here + agg = _make_raster(data) + + wp0 = (5.0, 0.0) # pixel (0, 0) + wp1 = (5.0, 3.0) # pixel (0, 3) — NaN, must be snapped + wp2 = (5.0, 5.0) # pixel (0, 5) + + result = multi_stop_search(agg, [wp0, wp1, wp2], snap=True) + vals = result.values + + assert vals[0, 0] == 0.0 + assert np.isfinite(vals[0, 5]) + # the NaN pixel itself is never on the path + assert np.isnan(vals[0, 3]) + assert result.attrs['total_cost'] > 0 + assert len(result.attrs['segment_costs']) == 2 + + +def test_multi_stop_optimize_order_two_waypoints_warns(): + agg = _make_raster(np.ones((5, 5))) + with pytest.warns(UserWarning, match="fewer than 3 waypoints"): + multi_stop_search(agg, [(4.0, 0.0), (0.0, 4.0)], + optimize_order=True) + + +def test_multi_stop_wrong_waypoint_length_raises(): + agg = _make_raster(np.ones((5, 5))) + with pytest.raises(ValueError, match="exactly 2 elements"): + multi_stop_search(agg, [(4.0, 0.0), (0.0, 4.0, 1.0)]) + + +@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array") +def test_multi_stop_snap_dask_raises(): + agg = _make_raster(np.ones((5, 5)), backend='dask+numpy', chunks=(3, 3)) + with pytest.raises(ValueError, match="snap is not supported"): + multi_stop_search(agg, [(4.0, 0.0), (0.0, 4.0)], snap=True) + + +# --- CuPy snap and search_radius --- + +@cuda_and_cupy_available +def test_cupy_snap_matches_numpy(): + """snap_start/snap_goal on cupy match the numpy snap result.""" + data = np.ones((5, 5)) + data[0, 0] = np.nan # requested start pixel + data[4, 4] = np.nan # requested goal pixel + + agg_np = _make_raster(data, backend='numpy') + agg_cupy = _make_raster(data, backend='cupy') + + start = (4.0, 0.0) # pixel (0, 0) — NaN, snapped + goal = (0.0, 4.0) # pixel (4, 4) — NaN, snapped + + path_np = a_star_search(agg_np, start, goal, + snap_start=True, snap_goal=True) + path_cupy = a_star_search(agg_cupy, start, goal, + snap_start=True, snap_goal=True) + + assert np.isfinite(path_np.values).any() # sanity: snapping worked + np.testing.assert_allclose( + path_cupy.data.get(), + path_np.values, + equal_nan=True, atol=1e-10, + ) + + +@cuda_and_cupy_available +def test_cupy_search_radius_matches_full(): + """Explicit search_radius on cupy matches the full-grid cupy result.""" + data = np.ones((10, 10)) + agg = _make_raster(data, backend='cupy') + + start = (9.0, 0.0) + goal = (0.0, 9.0) + + path_full = a_star_search(agg, start, goal) + path_bounded = a_star_search(agg, start, goal, search_radius=20) + + np.testing.assert_allclose( + path_bounded.data.get(), + path_full.data.get(), + equal_nan=True, atol=1e-10, + ) + + +@cuda_and_cupy_available +def test_cupy_not_crossable_warns(): + """Non-crossable start/goal warn on the cupy backend too.""" + agg = _make_raster(_barrier_grid, backend='cupy') + + with pytest.warns(Warning, match="Start at a non crossable location"): + path = a_star_search(agg, (4.0, 0.0), (0.0, 1.0), barriers=[0]) + assert not np.isfinite(path.data.get()).any() + + with pytest.warns(Warning, match="End at a non crossable location"): + path = a_star_search(agg, (4.0, 1.0), (1.0, 3.0), barriers=[0]) + assert not np.isfinite(path.data.get()).any() + + +@cuda_and_cupy_available +def test_cupy_auto_radius_selection(): + """Auto-radius under mocked low memory works on the cupy backend.""" + data = np.ones((20, 20)) + agg = _make_raster(data, backend='cupy') + + start = (19.0, 0.0) # pixel (0, 0) + goal = (15.0, 4.0) # pixel (4, 4) + + with patch('xrspatial.pathfinding._available_memory_bytes', + return_value=40_000): + path = a_star_search(agg, start, goal) + vals = path.data.get() + assert np.isfinite(vals[4, 4]) + assert vals[0, 0] == 0.0 + + +# --- HPA* with a friction surface --- + +def test_hpa_star_with_friction(): + """HPA* refines a route using the coarsened friction surface.""" + from xrspatial.pathfinding import _coarsen_friction + + H = W = 200 + surface_data = np.ones((H, W), dtype=np.float64) + friction_data = np.ones((H, W), dtype=np.float64) + friction_data[:, 90:110] = 4.0 # expensive band mid-grid + f_min = 1.0 + + dy, dx, dd = _neighborhood_structure(1.0, 1.0, 8) + barriers = np.array([], dtype=np.float64) + + path_img = _hpa_star_search( + surface_data, friction_data, + 0, 0, 199, 199, + barriers, dy, dx, dd, + f_min, True, 1.0, 1.0, H, W) + + assert path_img[0, 0] == 0.0 + assert np.isfinite(path_img[199, 199]) + assert path_img[199, 199] > path_img[0, 0] + + # _coarsen_friction: mean of positive finite values per block, + # NaN / non-positive cells excluded + fr = np.array([[1.0, 3.0], + [np.nan, 0.0]]) + coarse = _coarsen_friction(fr, 2) + assert coarse.shape == (1, 1) + np.testing.assert_allclose(coarse[0, 0], 2.0)