Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/sweep-test-coverage-state.csv
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ rasterize,2026-06-18,2614;3102;3105;3296;3383,HIGH,4,,"Pass 7 (2026-06-18, deep-
reproject,2026-06-09,2618;3050;3100;3101;3141,MEDIUM,1,,"CI follow-up same day: first CI run of the threaded streaming branch hard-crashed macos-arm64 py3.14 (SIGABRT in numba call_cfunc, two ThreadPoolExecutor threads concurrently inside try_numba_transform/tmerc_inverse) -- the projection kernels are @njit(parallel=True) and numba's workqueue threading layer aborts on concurrent entry; filed source bug #3141. Test fix: threaded parity test now uses transform_precision=0 (per-thread pyproj Transformer, no numba), the NaN multi-tile test and 3-D xfail forced serial (max_memory=1) so the numba fast path stays covered without concurrent entry. windows-3.14 failure was fail-fast collateral (its suite fully passed). Pass 2026-06-09 (deep-sweep test-coverage): delta re-sweep one day after the 2026-06-08 pass; module modified today by #3077 (datum-probe warning silencing) and #3081 (merge output-size guard backend-aware) -- both landed WITH their own tests (TestDatumProbeNoProjWarning; TestSecurityGuards merge-guard trio incl. the monkeypatched in-memory raise), so the delta added no gap; the guard branching is is_dask-only, so cupy eager shares the tested numpy branch (no per-backend guard test needed). Found one MEDIUM Cat 1 gap every prior pass missed: the 5th dispatch branch of reproject() -- the streaming fallback (_reproject_streaming / _process_tile_batch / _parse_max_memory, taken when source >512MB and dask is not importable) -- had zero coverage anywhere; _parse_max_memory only runs on that branch so the existing max_memory kwarg tests never reached it. Filed #3101, added test_reproject_streaming_3101.py (15 tests: parity vs in-memory numpy for threaded / serial(max_memory=1) / single-tile / nearest+NaN, plus 10 _parse_max_memory unit cases). Probe surfaced source bug #3100: streaming assembly allocates a 2-D output buffer but 3-D sources yield (h,w,b) tiles -> ValueError broadcast in both assembly loops; pinned with strict xfail, source fix left to #3100 (test-only PR, source untouched). CPU-only path so no GPU tests needed (CUDA host; file ran 14 passed + 1 xfailed). LOW carried (documented, not fixed): reproject(name=) / merge(name=) override values untested (only merge name fallback covered); non-square-cellsize successful anisotropic run; dask.bag distributed branch of _reproject_streaming still unexercised (needs a live distributed client). || PREVIOUS: Pass 2026-06-08 (deep-sweep test-coverage): #3050 closes the one live gap found this pass. reproject()'s dask+cupy backend was parity-tested only with resampling='cubic' (TestCupyPyprojFallbackParity::test_projected_to_projected_dask_cupy_match); nearest/bilinear were covered on numpy (end-to-end) and eager cupy (parametrized test_projected_to_projected_numpy_cupy_match) but never on the dask+cupy chunk-assembly path. Parametrized that test over ['nearest','bilinear','cubic']; all 3 RUN+PASS on a CUDA host. Cat 4 MEDIUM (resampling-mode parameter coverage on the dask+cupy backend). Test-only, source untouched. Re-confirmed _merge.merge() has NO genuine cupy/dask+cupy backend (_merge_inmemory/_merge_dask use _merge_arrays_numpy + raster.values; _merge_arrays_cupy is imported but never dispatched = dead code, not a test gap) matching the prior pass's observation. reproject() otherwise saturated across all 4 backends, NaN/Inf/all-NaN, degenerate shapes, metadata, vertical, bounds_policy, integer nodata. LOW (documented, not filed): dask+cupy resampling-mode parity is the only per-mode-per-backend cell that had been missing. || PREVIOUS: Pass 2026-05-29: reproject already has a deep suite (369 tests in test_reproject.py + coverage/gate files) covering all 4 backends, NaN/Inf/all-NaN/all-Inf, 1x1/2x2, metadata, vertical shift, bounds_policy x backends, integer nodata x backends. Gaps found: Cat 3 HIGH single-row (1xN) and single-col (Nx1) strip rasters never tested (hit size<2 branch of _validate_regular_axis + degenerate resampling axis); Cat 3 MEDIUM constant-value/zero-gradient raster never reprojected. Added TestDegenerateShapeReproject (12 tests): 1xN+Nx1 strips x numpy/dask/cupy/dask+cupy, constant raster numpy value-preservation + cross-backend parity. All 12 executed and passed on a CUDA host. Test-only, no source change (#2618). LOW (documented only): _merge._merge_arrays_cupy imported but never called by merge() (host-bounces via _merge_arrays_numpy) - dead-code source observation not a test gap; non-square cellsize reproject only covered via resolution-tuple validation errors not a successful anisotropic run."
resample,2026-05-29,2547;2615,HIGH,1;2;3;5,,"Pass 2 (2026-05-29): added test_resample_cupy_agg_fallback_2615.py (6 tests, all passing on CUDA host). Closes Cat 1 MEDIUM backend-coverage gap: the cupy eager aggregate CPU fallback for average/min/max at a NON-integer downsample factor (_run_cupy fy==int(fy) branch in resample.py ~L957-973) was never exercised; existing TestCuPyParity used 12x12 scale 0.5 (integer factor 2 -> GPU reshape path) and only median/mode hit the host fallback. New tests use 10x10 scale 0.3 (factor 3.33) for average/min/max parity vs numpy plus a NaN-masked variant. Issue #2615. Module is otherwise very thoroughly covered (test_resample.py + 3 supplementary files); no remaining HIGH gaps found. Pass 1 (2026-05-27): added test_resample_coverage_2026_05_27.py with 70 tests (68 passing, 2 skipped). Closes Cat 3 HIGH Nx1 single-column gap across numpy/cupy/dask+numpy/dask+cupy x 8 methods (nearest/bilinear/cubic/average/min/max/median/mode) plus Nx1 upsample-nearest parity and Nx1 cross-backend aggregate parity. Closes Cat 2 MEDIUM NaN-parity gap on cupy and dask+cupy (existing TestCuPyParity/TestDaskCuPyParity used random data without NaN; the weight-mask gate and spline-prepad had no GPU NaN coverage). Closes Cat 3 MEDIUM all-equal-value raster across 8 methods (downsample) and 3 interp methods (upsample) plus a constant-with-NaN aggregate variant. Closes Cat 5 MEDIUM non-default dim-name propagation: lat/lon, latitude/longitude, and (channel, lat, lon) 3D round-trip without being renamed to y/x; per-dim attrs (units) preserved. Closes Cat 3 MEDIUM empty-raster behaviour pin: 0-row and 0-col rasters raise (currently IndexError) -- contract covered. Filed source-bug issue #2547: cubic on dask backends fails for Nx1 / arrays smaller than depth=16; the 2 skipped tests in this file gate on that fix landing. Source untouched."
slope,2026-05-29,2697,MEDIUM,3,,"PR #2703: added degenerate-shape tests (1x1/1xN/Nx1) for all 4 planar backends + geodesic; no live bug, pins all-NaN+shape contract. CUDA host: cupy/dask+cupy ran. Backend/NaN/param/metadata coverage already complete."
surface_distance,2026-08-16,3725,HIGH,1;2;3;4;5,87,"Deep-sweep 2026-08-16 test-coverage on a CUDA host (cupy + dask+cupy RAN, not skipped). Before: 37 tests, branch_cov 80 (570/695). After: 106 tests, branch_cov 87. Cat 1 HIGH: _surface_distance_dask_cupy (:1232-1289) had ZERO coverage (neither the bounded map_overlap branch nor the unbounded convert-to-dask+numpy branch), and surface_direction was only ever called on eager numpy -- no cupy, no dask, no dask+cupy. That second hole was hiding a real SOURCE bug (#3713, filed by the sibling documentation sweep, no PR then): _run_tile records GLOBAL src_row/src_col while _finalize_direction built its pixel grid from BLOCK-LOCAL np.arange(H)/np.arange(W), so the dask iterative path returned the top-left tile's bearing field tiled across the whole raster (flat 6x6 chunked (3,3): 360 deg max error, four pixels reporting 0 'you are the source'). Fixed in the same PR by threading row_offset/col_offset through _extract_output -> _finalize_direction from _assemble_sd; eager numpy and bounded map_overlap keep offset 0 because they seed local indices. Fail-before/pass-after verified. Also covered: cupy target_values seeding (:560-561) and cupy no-source early return (:571-573); dask iterative + target_values (:698-701, :956-959); connectivity=4 off the eager path (:919-932). Cat 2 MEDIUM: Inf elevation and all-NaN elevation untested -- both behave correctly (non-finite = barrier) on all 4 backends, coverage gap not a bug. Cat 3 MEDIUM: no 1x1 and no Nx1 column-strip tests (only 1xN rows existed); both correct on all 4 backends. Cat 4 MEDIUM: all three public funcs carry @supports_dataset and none was ever called with a Dataset (same gap as the proximity trio) -- works correctly; error paths untested (raster.dims rejection :1306, geodesic NotImplementedError per backend :1371/:1379/:1401). Cat 5 HIGH: nothing asserted attrs/coords/dims preservation even though _compute reads res off attrs for its edge costs -- all 4 backends preserve them correctly. LOW (documented, not fixed): non-square cells (res_x != res_y) untested but correct (verified N=2.0/E=1.0 on a 2x1 grid); _available_memory_bytes psutil + 2GB-default fallbacks (:107-115), _available_gpu_memory_bytes failure path (:129-130), _check_gpu_memory GPU-branch raise (:156,:159), _precompute_dd_grid memory guard (:423-426) all unexercised; _dijkstra_geodesic visited/over-budget/non-finite branches (:295,:299, :313) unreached. Pre-existing flake8 F841 at test file line 236 (sd assigned but unused in test_allocation_consistency) left alone per surgical-change rule. Post-fix branch_cov of 87 is still measured with NUMBA_DISABLE_JIT=1, under which the cupy tests fail, so true with-GPU coverage is higher; full file is 106/106 GREEN with JIT on."
templates,2026-06-30,3580,MEDIUM,1;4,,"Deep-sweep test-coverage re-run on a CUDA host (cuda available). Module is already heavily tested (281 tests): 4-backend matrix (numpy/dask+numpy/cupy/dask+cupy) + dask alias + bad-backend all green; preserve area/shape across backends; single-pixel + Nx1/1xN strips; cell-cap and chunk-count guards; padding/tiling helpers; country/region/city resolution + aliases; CF metadata + no-pyproj fallback. Cat 2 N/A (procedural generator, no raster input). Found two MEDIUM parameter-coverage gaps, no source bug. Cat 4: chunks=tuple only exercised via internal _estimate_n_chunks, never end-to-end through from_template (int/'auto' were); the dask chunk-count guard message on the explicit height/width path was untested (only the resolution-path message and the eager cell-cap message named the knob). Added test-only test_chunks_tuple_through_public_api and test_explicit_shape_chunk_count_message_names_height_width; both RAN and PASSED on the CUDA host (281 passed). LOW (documented, no test): non-NaN fill is only asserted on eager numpy (fill=0); probed live and works on all 4 backends but cross-backend fill value parity is not asserted. PR #3580 opened with the two tests."
viewshed,2026-05-29,2693,HIGH,1;2;5,,"Pass 1 (2026-05-29): added 4 new test groups to test_viewshed.py (13 new tests + 1 xfail, all passing/xfailing on a CUDA+RTX host). Closes Cat 1 HIGH backend-coverage gap: the dask+cupy dispatch path in _viewshed_dask (Tier B) and _viewshed_windowed (max_distance) was registered but never invoked by any test -- added test_viewshed_dask_cupy_flat (analytical-angle parity, atol 0.03) and test_viewshed_dask_cupy_max_distance (windowed GPU run; observer cell 180, corners INVISIBLE). Both use non-zero flat terrain (1.3) because the RTX mesh builder rejects an all-zero raster (#1378). Closes Cat 5 HIGH metadata-preservation gap: only the numpy test_viewshed called general_output_checks; the cupy/dask/dask+cupy and max_distance paths never asserted attrs/coords/dims/array-type preservation. Added parametrised test_viewshed_metadata_preserved over {numpy,cupy,dask+numpy,dask+cupy} x {full, max_distance=2.0}: asserts attrs==, dims==, shape==, x/y coords allclose; runs general_output_checks (full type parity) for all backends except dask+cupy. Closes Cat 2 HIGH NaN-input gap and surfaced source bug #2693: viewshed on a numpy raster crashes with ValueError 'node not found' from _delete_from_tree when a NaN cell sits at certain positions (e.g. (2,4) in a 5x5 with observer at (2,2)), while NaN at (1,1)/(0,0)/(4,4) runs fine. Added test_viewshed_nan_input_supported_positions (parametrised working positions, asserts observer=180 and NaN cell is INVISIBLE/NaN) plus test_viewshed_nan_input_crashing_position (xfail strict, raises, links #2693). Noted but NOT fixed (source change out of scope for test sweep): the dask+cupy backend does not preserve the cupy backing -- _viewshed_dask computes then rewraps via da.from_array(result_np), so the output computes to numpy not cupy; general_output_checks is skipped for dask+cupy for that reason (candidate for the metadata/backend-parity sweep). LOW (documented only): non-square cell sizes; 1x1 and 1xN geometry covered behaviourally by probing (run without error). Test-only PR; viewshed.py untouched."
visibility,2026-06-10,3192,HIGH,1;2;4,,"cupy cumulative_viewshed/visibility_frequency broken (numpy count + cupy viewshed) -> issue #3192 (dup #3193), fix in flight in #3205 with its own cupy parity tests, xfail pins dropped to avoid an XPASS race; added cupy _extract_transect+line_of_sight parity, NaN LOS, Fresnel-blocked branch; dask+metadata already covered"
Expand Down
22 changes: 17 additions & 5 deletions xrspatial/surface_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,13 +357,20 @@ def _finalize_alloc(alloc, dist, max_distance):


def _finalize_direction(src_row, src_col, dist, cellsize_x, cellsize_y,
max_distance):
max_distance, row_offset=0, col_offset=0):
"""Compute compass bearing from each pixel to its allocated source.

Uses pixel index differences scaled by cell size.

``src_row``/``src_col`` may hold indices in a wider coordinate space
than the block being finalized -- the dask iterative path records
global indices while each block sees only its own shape. The
offsets shift the pixel grid into that same space so the difference
is taken between comparable indices.
"""
H, W = dist.shape
row_idx, col_idx = np.meshgrid(np.arange(H), np.arange(W), indexing='ij')
row_idx, col_idx = np.meshgrid(np.arange(H) + row_offset,
np.arange(W) + col_offset, indexing='ij')

# Coordinate differences (source - pixel)
dx = (src_col.astype(np.float64) - col_idx) * cellsize_x
Expand All @@ -381,15 +388,17 @@ def _finalize_direction(src_row, src_col, dist, cellsize_x, cellsize_y,


def _extract_output(dist, alloc, src_row, src_col,
cellsize_x, cellsize_y, max_distance, mode):
cellsize_x, cellsize_y, max_distance, mode,
row_offset=0, col_offset=0):
"""Select and finalize the requested output from raw Dijkstra arrays."""
if mode == DISTANCE:
return _finalize_dist(dist, max_distance)
elif mode == ALLOCATION:
return _finalize_alloc(alloc, dist, max_distance)
else:
return _finalize_direction(src_row, src_col, dist,
cellsize_x, cellsize_y, max_distance)
cellsize_x, cellsize_y, max_distance,
row_offset, col_offset)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1174,8 +1183,11 @@ def _tile_fn(source_block, elev_block, block_info=None):
max_distance, dy, dx, dd, row_offset, col_offset,
)

# _run_tile records global src_row/src_col, so the pixel grid has
# to be built in global coordinates too.
return _extract_output(dist, alloc_arr, srow, scol,
cellsize_x, cellsize_y, max_distance, mode)
cellsize_x, cellsize_y, max_distance, mode,
row_offset, col_offset)

return da.map_blocks(
_tile_fn,
Expand Down
Loading
Loading