From 6842bdbcbe3f265264713e6de089075b01187e61 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Mon, 1 Jun 2026 10:44:08 -0700 Subject: [PATCH] Validate hotspots() kernel like apply()/focal_stats() (#2771) hotspots() never ran its kernel through custom_kernel(), so kernel=None and a list-of-list kernel raised AttributeError on kernel.shape, an even-dimensioned kernel silently succeeded, and a zero-sum kernel divided by zero during normalization. Run custom_kernel(kernel) before the memory check and add a zero-sum guard (hotspots is the only focal function that normalizes by kernel.sum()). Add regression tests for the None, list-of-list, even-dim, and zero-sum cases plus a valid-kernel happy path. --- xrspatial/focal.py | 8 +++++++ xrspatial/tests/test_focal.py | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/xrspatial/focal.py b/xrspatial/focal.py index 2cb276fb..8b38605c 100644 --- a/xrspatial/focal.py +++ b/xrspatial/focal.py @@ -1505,6 +1505,14 @@ def hotspots(agg=None, kernel=None, name='hotspots', boundary='nan', *, _validate_boundary(boundary) + kernel = custom_kernel(kernel) + if kernel.sum() == 0: + raise ValueError( + "hotspots(): kernel sums to zero. The kernel is normalized by " + "its sum, so a zero-sum kernel divides by zero. Supply a kernel " + "with at least one non-zero cell." + ) + rows, cols = agg.shape[-2], agg.shape[-1] _check_kernel_vs_raster_memory(kernel, rows, cols, func_name='hotspots') diff --git a/xrspatial/tests/test_focal.py b/xrspatial/tests/test_focal.py index fdb7c67e..6e50fcad 100644 --- a/xrspatial/tests/test_focal.py +++ b/xrspatial/tests/test_focal.py @@ -776,6 +776,47 @@ def test_hotspots_zero_global_std(): hotspots(agg, kernel) +def test_hotspots_kernel_none_2771(): + # Regression for #2771: hotspots skipped custom_kernel validation, so a + # None kernel raised AttributeError on kernel.shape instead of ValueError. + agg = create_test_raster(np.ones((10, 10), dtype=np.float32)) + with pytest.raises(ValueError): + hotspots(agg, None) + + +def test_hotspots_kernel_list_of_list_2771(): + # Regression for #2771: a list-of-list kernel reached kernel.shape and + # raised AttributeError; it should be rejected as a non-ndarray. + agg = create_test_raster(np.ones((10, 10), dtype=np.float32)) + with pytest.raises(ValueError): + hotspots(agg, [[1, 1, 1]]) + + +def test_hotspots_kernel_even_dim_2771(): + # Regression for #2771: an even-dimensioned kernel silently succeeded + # before; custom_kernel now rejects it for improper shape. + agg = create_test_raster(np.ones((10, 10), dtype=np.float32)) + with pytest.raises(ValueError): + hotspots(agg, np.ones((2, 2))) + + +def test_hotspots_kernel_zero_sum_2771(): + # Regression for #2771: hotspots normalizes by kernel.sum(); a zero-sum + # kernel divided by zero instead of raising a clear error. + agg = create_test_raster(np.ones((10, 10), dtype=np.float32)) + kernel = np.zeros((3, 3)) + with pytest.raises(ValueError, match=r"hotspots\(\): kernel sums to zero"): + hotspots(agg, kernel) + + +def test_hotspots_valid_kernel_happy_path_2771(data_hotspots): + # Regression for #2771: the added validation must not reject valid kernels. + data, kernel, expected_result = data_hotspots + numpy_agg = create_test_raster(data) + numpy_hotspots = hotspots(numpy_agg, kernel) + general_output_checks(numpy_agg, numpy_hotspots, expected_result, verify_attrs=False) + + def test_hotspots_numpy(data_hotspots): data, kernel, expected_result = data_hotspots numpy_agg = create_test_raster(data)