From 5f2717b859b2fbfb82e27f3d880e76064d5e2772 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 02:10:51 +0000 Subject: [PATCH 1/3] Initial plan From e1d086620ec0b2f2b6b63017aca1004a60f698ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 02:14:11 +0000 Subject: [PATCH 2/3] feat: add cxmax and cxmin methods to Raster class Add methods that return the (x, y) coordinate of the cell centre containing the maximum/minimum value in the raster, ignoring NaN values. Raises ValueError if all values are NaN. Agent-Logs-Url: https://github.com/tonkintaylor/rastr/sessions/ced63491-361a-409a-9829-c72aa72847f7 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> --- src/rastr/raster.py | 46 +++++++++++++++++++++++ tests/rastr/test_raster.py | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src/rastr/raster.py b/src/rastr/raster.py index 093441b..e51c902 100644 --- a/src/rastr/raster.py +++ b/src/rastr/raster.py @@ -1107,6 +1107,52 @@ def sum(self) -> float: with suppress_slice_warning(): return float(np.nansum(self.arr)) + def cxmax(self) -> tuple[float, float]: + """Get the coordinate of the cell centre with the maximum value. + + Returns the (x, y) coordinate of the centre of the pixel containing the + maximum value in the raster, ignoring NaN values. If multiple pixels share + the maximum value, the first occurrence in row-major order is returned. + + Returns: + A tuple (x, y) representing the coordinate of the cell centre with + the maximum value. + + Raises: + ValueError: If all values in the raster are NaN. + """ + if np.all(np.isnan(self.arr)): + msg = "Cannot find cxmax of an all-NaN raster." + raise ValueError(msg) + idx = np.unravel_index(np.nanargmax(self.arr), self.arr.shape) + row, col = int(idx[0]), int(idx[1]) + x_coords = self.raster_meta.get_cell_x_coords(self.arr.shape[1]) + y_coords = self.raster_meta.get_cell_y_coords(self.arr.shape[0]) + return float(x_coords[col]), float(y_coords[row]) + + def cxmin(self) -> tuple[float, float]: + """Get the coordinate of the cell centre with the minimum value. + + Returns the (x, y) coordinate of the centre of the pixel containing the + minimum value in the raster, ignoring NaN values. If multiple pixels share + the minimum value, the first occurrence in row-major order is returned. + + Returns: + A tuple (x, y) representing the coordinate of the cell centre with + the minimum value. + + Raises: + ValueError: If all values in the raster are NaN. + """ + if np.all(np.isnan(self.arr)): + msg = "Cannot find cxmin of an all-NaN raster." + raise ValueError(msg) + idx = np.unravel_index(np.nanargmin(self.arr), self.arr.shape) + row, col = int(idx[0]), int(idx[1]) + x_coords = self.raster_meta.get_cell_x_coords(self.arr.shape[1]) + y_coords = self.raster_meta.get_cell_y_coords(self.arr.shape[0]) + return float(x_coords[col]), float(y_coords[row]) + def unique(self) -> NDArray: """Get the unique cell values in the raster, including NaN. diff --git a/tests/rastr/test_raster.py b/tests/rastr/test_raster.py index ff54863..4610439 100644 --- a/tests/rastr/test_raster.py +++ b/tests/rastr/test_raster.py @@ -5026,6 +5026,82 @@ def test_quantile_all_nan_raster_no_warning(self) -> None: all_nan_slice_raster.quantile(0.8) +class TestCxmax: + """Test the cxmax method of the Raster class.""" + + def test_basic(self, stats_test_raster: Raster) -> None: + """Test cxmax returns coordinate of maximum value.""" + # The 3x3 array is [[1,2,3],[4,5,6],[7,8,9]] with cell size 2. + # Max value 9 is at row=2, col=2. + # x = (2 + 0.5) * 2 = 5, y = (2 + 0.5) * 2 = 5 + x, y = stats_test_raster.cxmax() + assert x == pytest.approx(5.0) + assert y == pytest.approx(5.0) + + def test_with_nans(self, stats_test_raster_with_nans: Raster) -> None: + """Test cxmax ignores NaN values.""" + # Array is [[1,2,nan],[4,nan,6],[7,8,9]], max=9 at row=2, col=2 + x, y = stats_test_raster_with_nans.cxmax() + assert x == pytest.approx(5.0) + assert y == pytest.approx(5.0) + + def test_all_nan_raises(self) -> None: + """Test cxmax raises ValueError for all-NaN raster.""" + meta = RasterMeta( + crs=CRS.from_epsg(2193), + transform=Affine(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + ) + all_nan = Raster(arr=np.full((3, 3), np.nan), raster_meta=meta) + with pytest.raises(ValueError, match="Cannot find cxmax"): + all_nan.cxmax() + + def test_negative_scale(self, example_neg_scaled_raster: Raster) -> None: + """Test cxmax with negative y-scale transform.""" + # Array is [[1,2],[3,4]], max=4 at row=1, col=1 + # transform Affine(2.0, 0.0, 0.0, 0.0, -2.0, 0.0) + # x = (1 + 0.5) * 2 = 3, y = (1 + 0.5) * -2 = -3 + x, y = example_neg_scaled_raster.cxmax() + assert x == pytest.approx(3.0) + assert y == pytest.approx(-3.0) + + +class TestCxmin: + """Test the cxmin method of the Raster class.""" + + def test_basic(self, stats_test_raster: Raster) -> None: + """Test cxmin returns coordinate of minimum value.""" + # Min value 1 is at row=0, col=0. + # x = (0 + 0.5) * 2 = 1, y = (0 + 0.5) * 2 = 1 + x, y = stats_test_raster.cxmin() + assert x == pytest.approx(1.0) + assert y == pytest.approx(1.0) + + def test_with_nans(self, stats_test_raster_with_nans: Raster) -> None: + """Test cxmin ignores NaN values.""" + # Array is [[1,2,nan],[4,nan,6],[7,8,9]], min=1 at row=0, col=0 + x, y = stats_test_raster_with_nans.cxmin() + assert x == pytest.approx(1.0) + assert y == pytest.approx(1.0) + + def test_all_nan_raises(self) -> None: + """Test cxmin raises ValueError for all-NaN raster.""" + meta = RasterMeta( + crs=CRS.from_epsg(2193), + transform=Affine(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + ) + all_nan = Raster(arr=np.full((3, 3), np.nan), raster_meta=meta) + with pytest.raises(ValueError, match="Cannot find cxmin"): + all_nan.cxmin() + + def test_negative_scale(self, example_neg_scaled_raster: Raster) -> None: + """Test cxmin with negative y-scale transform.""" + # Array is [[1,2],[3,4]], min=1 at row=0, col=0 + # x = (0 + 0.5) * 2 = 1, y = (0 + 0.5) * -2 = -1 + x, y = example_neg_scaled_raster.cxmin() + assert x == pytest.approx(1.0) + assert y == pytest.approx(-1.0) + + class TestNormalize: def test_example(self, example_raster: Raster): # Act From bb196fddd588c89b23346fb74b040fb7f861fc36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 02:38:13 +0000 Subject: [PATCH 3/3] refactor: extract _cx_stat helper to reduce duplication in cxmax/cxmin Agent-Logs-Url: https://github.com/tonkintaylor/rastr/sessions/89d84575-b59c-47d4-8e0d-8fae4da252c2 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> --- src/rastr/raster.py | 45 +++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/rastr/raster.py b/src/rastr/raster.py index e51c902..6f8a9ad 100644 --- a/src/rastr/raster.py +++ b/src/rastr/raster.py @@ -1107,6 +1107,33 @@ def sum(self) -> float: with suppress_slice_warning(): return float(np.nansum(self.arr)) + def _cx_stat( + self, + nanarg_func: Callable[[NDArray], np.intp], + name: str, + ) -> tuple[float, float]: + """Get the coordinate of the cell centre identified by a nanarg* function. + + Args: + nanarg_func: A NumPy nanarg function (e.g. np.nanargmax, np.nanargmin) + that returns a flat index into the array. + name: Human-readable name of the statistic, used in error messages. + + Returns: + A tuple (x, y) representing the coordinate of the identified cell centre. + + Raises: + ValueError: If all values in the raster are NaN. + """ + if np.all(np.isnan(self.arr)): + msg = f"Cannot find {name} of an all-NaN raster." + raise ValueError(msg) + idx = np.unravel_index(nanarg_func(self.arr), self.arr.shape) + row, col = int(idx[0]), int(idx[1]) + x_coords = self.raster_meta.get_cell_x_coords(self.arr.shape[1]) + y_coords = self.raster_meta.get_cell_y_coords(self.arr.shape[0]) + return float(x_coords[col]), float(y_coords[row]) + def cxmax(self) -> tuple[float, float]: """Get the coordinate of the cell centre with the maximum value. @@ -1121,14 +1148,7 @@ def cxmax(self) -> tuple[float, float]: Raises: ValueError: If all values in the raster are NaN. """ - if np.all(np.isnan(self.arr)): - msg = "Cannot find cxmax of an all-NaN raster." - raise ValueError(msg) - idx = np.unravel_index(np.nanargmax(self.arr), self.arr.shape) - row, col = int(idx[0]), int(idx[1]) - x_coords = self.raster_meta.get_cell_x_coords(self.arr.shape[1]) - y_coords = self.raster_meta.get_cell_y_coords(self.arr.shape[0]) - return float(x_coords[col]), float(y_coords[row]) + return self._cx_stat(np.nanargmax, "cxmax") def cxmin(self) -> tuple[float, float]: """Get the coordinate of the cell centre with the minimum value. @@ -1144,14 +1164,7 @@ def cxmin(self) -> tuple[float, float]: Raises: ValueError: If all values in the raster are NaN. """ - if np.all(np.isnan(self.arr)): - msg = "Cannot find cxmin of an all-NaN raster." - raise ValueError(msg) - idx = np.unravel_index(np.nanargmin(self.arr), self.arr.shape) - row, col = int(idx[0]), int(idx[1]) - x_coords = self.raster_meta.get_cell_x_coords(self.arr.shape[1]) - y_coords = self.raster_meta.get_cell_y_coords(self.arr.shape[0]) - return float(x_coords[col]), float(y_coords[row]) + return self._cx_stat(np.nanargmin, "cxmin") def unique(self) -> NDArray: """Get the unique cell values in the raster, including NaN.