From f320528f54b4192144021e359efc00b3d10a165d Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Wed, 16 Apr 2025 21:28:23 -0600 Subject: [PATCH 01/25] WIP align implementation --- src/rasterix/raster_index.py | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 25bfe3c..263608c 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -32,6 +32,50 @@ def assign_index(obj: T_Xarray, *, x_dim: str | None = None, y_dim: str | None = return obj.assign_coords(coords) +def _assert_transforms_are_compatible(A1: Affine, A2: Affine) -> None: + assert A1.a == A2.a + assert A1.b == A2.b + assert A1.d == A2.d + assert A1.e == A2.e + + +def compute_bounding_box_affine( + A1: Affine, shape1: tuple[int, int], A2: Affine, shape2: tuple[int, int], *, how: str +): + _assert_transforms_are_compatible(A1, A2) + + # Apply both affine transformations to the corners + transformed1 = np.stack([A1 * point for point in [(0, 0), shape1]]) + transformed2 = np.stack([A2 * point for point in [(0, 0), shape2]]) + + # Concatenate transformed points + all_points = np.stack([transformed1, transformed2]) + + # Compute bounding box min/max + if how == "outer": + min_x, min_y = all_points.min(axis=(0, 1)).tolist() + max_x, max_y = all_points.max(axis=(0, 1)).tolist() + + assert max_x > min_x + assert max_y > min_y + + # FIXME: floating point error here + Nx = None if A1.a == 0 else int(np.abs((max_x - min_x) / A1.a)) + Ny = None if A1.e == 0 else int(np.abs((max_y - min_y) / A1.e)) + else: + raise NotImplementedError + + Anew = Affine( + A1.a, + A1.b, + min_x if A1.a > 0 else max_x, + A1.d, + A1.e, + min_y if A1.e > 0 else max_y, # TODO: handle orientation here + ) + return Anew, Nx, Ny + + class AffineTransform(CoordinateTransform): """Affine 2D transform wrapper.""" @@ -304,6 +348,7 @@ class RasterIndex(Index): """ _wrapped_indexes: dict[WrappedIndexCoords, WrappedIndex] + _shape: dict[str, int] def __init__(self, indexes: Mapping[WrappedIndexCoords, WrappedIndex]): idx_keys = list(indexes) @@ -321,6 +366,13 @@ def __init__(self, indexes: Mapping[WrappedIndexCoords, WrappedIndex]): assert axis_dependent ^ axis_independent self._wrapped_indexes = dict(indexes) + if axis_independent: + self._shape = { + "x": self._wrapped_indexes["x"].axis_transform.size, + "y": self._wrapped_indexes["y"].axis_transform.size, + } + else: + self._shape = next(iter(self._wrapped_indexes.values())).dim_size @classmethod def from_transform( @@ -436,3 +488,27 @@ def transform(self) -> Affine: index = next(iter(self._wrapped_indexes.values())) aff = index.affine return aff * Affine.translation(-0.5, -0.5) + + def join(self, other, how) -> "RasterIndex": + if not isinstance(other, RasterIndex): + raise ValueError( + f"Alignment is only supported between RasterIndexes. Received RasterIndex and {type(other)!r} instead" + ) + + if set(self._wrapped_indexes.keys()) != set(other._wrapped_indexes.keys()): + # TODO: better error message + raise ValueError( + "Alignment is only supported between RasterIndexes, when both contain compatible transforms." + ) + + # move affines back to top-left corner + trans = Affine.translation(-0.5, -0.5) + ours = self.transform() * trans + theirs = other.transform() * trans + + our_shape = tuple(self._shape[k] for k in ("x", "y")) + their_shape = tuple(self._shape[k] for k in ("x", "y")) + new_affine, Nx, Ny = compute_bounding_box_affine(ours, our_shape, theirs, their_shape, how=how) + + # TODO: set xdim, ydim explicitly + return self.from_transform(new_affine, width=Nx, height=Ny) From 3665bc7fe11c20b15b74f12cbd65a3ccdfc8a88d Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 17 Apr 2025 09:49:14 -0600 Subject: [PATCH 02/25] Try using BoundingBox --- src/rasterix/raster_index.py | 77 ++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 263608c..75511b8 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -3,11 +3,16 @@ import textwrap from collections.abc import Hashable, Mapping from typing import Any, TypeVar +from typing import Any, Self import numpy as np import pandas as pd from affine import Affine from xarray import Coordinates, DataArray, Dataset, Index, Variable +from xarray import DataArray, Index, Variable +from odc.geo import BoundingBox +from odc.geo.geom import bbox_intersection, bbox_union +from xarray import DataArray, Index, Variable from xarray.core.coordinate_transform import CoordinateTransform # TODO: import from public API once it is available @@ -489,6 +494,14 @@ def transform(self) -> Affine: aff = index.affine return aff * Affine.translation(-0.5, -0.5) + @property + def bbox(self) -> BoundingBox: + return BoundingBox.from_transform( + shape=tuple(self._shape[k] for k in ("x", "y")), + transform=self.combined_affine_transform() * Affine.translation(-0.5, -0.5), + crs=None, + ) + def join(self, other, how) -> "RasterIndex": if not isinstance(other, RasterIndex): raise ValueError( @@ -505,10 +518,68 @@ def join(self, other, how) -> "RasterIndex": trans = Affine.translation(-0.5, -0.5) ours = self.transform() * trans theirs = other.transform() * trans + ours, theirs = as_compatible_bboxes(self, other) + + if how == "outer": + new_bbox = bbox_union([ours, theirs]) + print(new_bbox, ours, theirs) + elif how == "inner": + new_bbox = bbox_intersection([ours, theirs]) - our_shape = tuple(self._shape[k] for k in ("x", "y")) - their_shape = tuple(self._shape[k] for k in ("x", "y")) - new_affine, Nx, Ny = compute_bounding_box_affine(ours, our_shape, theirs, their_shape, how=how) + affine = self.combined_affine_transform() + new_affine, Nx, Ny = bbox_to_affine(new_bbox, rx=affine.a, ry=affine.e) # TODO: set xdim, ydim explicitly return self.from_transform(new_affine, width=Nx, height=Ny) + + def reindex_like(self, other: Self, method=None, tolerance=None) -> dict[Hashable, Any]: + affine = self.combined_affine_transform() + ours, theirs = as_compatible_bboxes(self, other) + inter = bbox_intersection([ours, theirs]) + dx = affine.a + dy = affine.e + + # Fraction of a pixel that can be ignored, defaults to 1/100. Bounding box of the output + # geobox is allowed to be smaller than supplied bounding box by that amount. + # FIXME: translate user-provided `tolerance` to `tol` + tol: float = 0.01 + + indexers = {} + indexers["x"] = slice_bounds(theirs.left, inter.left, inter.right, spacing=dx, tol=tol) + indexers["y"] = slice_bounds(theirs.top, inter.bottom, inter.top, spacing=dy, tol=tol) + return indexers + + +def slice_bounds(off, start, stop, spacing, tol): + from math import ceil + + from odc.geo.math import maybe_int + + istart = ceil(maybe_int((start - off) / spacing, tol)) + istop = ceil(maybe_int((stop - off) / spacing, tol)) + + return slice(istart, istop) + + +def bbox_to_affine(bbox: BoundingBox, rx, ry) -> Affine: + # Fraction of a pixel that can be ignored, defaults to 1/100. Bounding box of the output + # geobox is allowed to be smaller than supplied bounding box by that amount. + # FIXME: translate user-provided `tolerance` to `tol` + tol: float = 0.01 + + from odc.geo.math import snap_grid + + offx, nx = snap_grid(bbox.left, bbox.right, rx, 0, tol=tol) + offy, ny = snap_grid(bbox.bottom, bbox.top, ry, 0, tol=tol) + + affine = Affine.translation(offx, offy) * Affine.scale(rx, ry) + + return affine, nx, ny + + +def as_compatible_bboxes(r1: RasterIndex, r2: RasterIndex) -> tuple[BoundingBox, BoundingBox]: + r1_transform = r1.combined_affine_transform() + r2_transform = r2.combined_affine_transform() + _assert_transforms_are_compatible(r1_transform, r2_transform) + + return r1.bbox, r2.bbox From 56c6308ac6a95be0390afe670a641585573bc40b Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 17 Apr 2025 11:32:16 -0600 Subject: [PATCH 03/25] bugfix --- src/rasterix/raster_index.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 75511b8..163e869 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -497,7 +497,7 @@ def transform(self) -> Affine: @property def bbox(self) -> BoundingBox: return BoundingBox.from_transform( - shape=tuple(self._shape[k] for k in ("x", "y")), + shape=tuple(self._shape[k] for k in ("y", "x")), transform=self.combined_affine_transform() * Affine.translation(-0.5, -0.5), crs=None, ) @@ -530,7 +530,9 @@ def join(self, other, how) -> "RasterIndex": new_affine, Nx, Ny = bbox_to_affine(new_bbox, rx=affine.a, ry=affine.e) # TODO: set xdim, ydim explicitly - return self.from_transform(new_affine, width=Nx, height=Ny) + new_index = self.from_transform(new_affine, width=Nx, height=Ny) + assert new_index.bbox == new_bbox + return new_index def reindex_like(self, other: Self, method=None, tolerance=None) -> dict[Hashable, Any]: affine = self.combined_affine_transform() From bf847183cbc66fcfc9cc4bb2ae19098c35840683 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 17 Apr 2025 12:14:19 -0600 Subject: [PATCH 04/25] Some more progress --- src/rasterix/raster_index.py | 39 +++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 163e869..709a801 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -3,6 +3,8 @@ import textwrap from collections.abc import Hashable, Mapping from typing import Any, TypeVar +from collections.abc import Hashable, Mapping +from collections.abc import Hashable, Iterable, Mapping, Sequence from typing import Any, Self import numpy as np @@ -502,6 +504,17 @@ def bbox(self) -> BoundingBox: crs=None, ) + @classmethod + def concat( + cls, + indexes: Sequence[Self], + dim: Hashable, + positions: Iterable[Iterable[int]] | None = None, + ) -> Self: + if len(indexes) == 1: + return next(iter(indexes)) + raise NotImplementedError + def join(self, other, how) -> "RasterIndex": if not isinstance(other, RasterIndex): raise ValueError( @@ -547,20 +560,36 @@ def reindex_like(self, other: Self, method=None, tolerance=None) -> dict[Hashabl tol: float = 0.01 indexers = {} - indexers["x"] = slice_bounds(theirs.left, inter.left, inter.right, spacing=dx, tol=tol) - indexers["y"] = slice_bounds(theirs.top, inter.bottom, inter.top, spacing=dy, tol=tol) + indexers["x"] = get_indexer( + theirs.left, ours.left, inter.left, inter.right, spacing=dx, tol=tol, size=other._shape["x"] + ) + indexers["y"] = get_indexer( + theirs.top, ours.top, inter.top, inter.bottom, spacing=dy, tol=tol, size=other._shape["y"] + ) + import ipdb + + ipdb.set_trace() return indexers -def slice_bounds(off, start, stop, spacing, tol): +def get_indexer(off, our_off, start, stop, spacing, tol, size) -> np.ndarray: from math import ceil from odc.geo.math import maybe_int istart = ceil(maybe_int((start - off) / spacing, tol)) - istop = ceil(maybe_int((stop - off) / spacing, tol)) - return slice(istart, istop) + ours_istart = ceil(maybe_int((start - our_off) / spacing, tol)) + ours_istop = ceil(maybe_int((stop - our_off) / spacing, tol)) + + idxr = np.concatenate( + [ + np.full((istart,), fill_value=-1), + np.arange(ours_istart, ours_istop), + np.full((size - istart - (ours_istop - ours_istart),), fill_value=-1), + ] + ) + return idxr def bbox_to_affine(bbox: BoundingBox, rx, ry) -> Affine: From a10bfe7e2390f202c821accc888fa8e406994230 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 16 May 2025 09:23:25 -0600 Subject: [PATCH 05/25] cleanup --- src/rasterix/raster_index.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 709a801..8a2e569 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -1,20 +1,15 @@ from __future__ import annotations import textwrap -from collections.abc import Hashable, Mapping -from typing import Any, TypeVar -from collections.abc import Hashable, Mapping from collections.abc import Hashable, Iterable, Mapping, Sequence -from typing import Any, Self +from typing import Any, Self, TypeVar import numpy as np import pandas as pd from affine import Affine -from xarray import Coordinates, DataArray, Dataset, Index, Variable -from xarray import DataArray, Index, Variable from odc.geo import BoundingBox from odc.geo.geom import bbox_intersection, bbox_union -from xarray import DataArray, Index, Variable +from xarray import Coordinates, DataArray, Dataset, Index, Variable from xarray.core.coordinate_transform import CoordinateTransform # TODO: import from public API once it is available @@ -500,7 +495,7 @@ def transform(self) -> Affine: def bbox(self) -> BoundingBox: return BoundingBox.from_transform( shape=tuple(self._shape[k] for k in ("y", "x")), - transform=self.combined_affine_transform() * Affine.translation(-0.5, -0.5), + transform=self.transform() * Affine.translation(-0.5, -0.5), crs=None, ) @@ -515,7 +510,7 @@ def concat( return next(iter(indexes)) raise NotImplementedError - def join(self, other, how) -> "RasterIndex": + def join(self, other, how) -> RasterIndex: if not isinstance(other, RasterIndex): raise ValueError( f"Alignment is only supported between RasterIndexes. Received RasterIndex and {type(other)!r} instead" @@ -538,8 +533,10 @@ def join(self, other, how) -> "RasterIndex": print(new_bbox, ours, theirs) elif how == "inner": new_bbox = bbox_intersection([ours, theirs]) + else: + raise NotImplementedError - affine = self.combined_affine_transform() + affine = self.transform() new_affine, Nx, Ny = bbox_to_affine(new_bbox, rx=affine.a, ry=affine.e) # TODO: set xdim, ydim explicitly @@ -548,7 +545,7 @@ def join(self, other, how) -> "RasterIndex": return new_index def reindex_like(self, other: Self, method=None, tolerance=None) -> dict[Hashable, Any]: - affine = self.combined_affine_transform() + affine = self.transform() ours, theirs = as_compatible_bboxes(self, other) inter = bbox_intersection([ours, theirs]) dx = affine.a @@ -609,8 +606,8 @@ def bbox_to_affine(bbox: BoundingBox, rx, ry) -> Affine: def as_compatible_bboxes(r1: RasterIndex, r2: RasterIndex) -> tuple[BoundingBox, BoundingBox]: - r1_transform = r1.combined_affine_transform() - r2_transform = r2.combined_affine_transform() + r1_transform = r1.transform() + r2_transform = r2.transform() _assert_transforms_are_compatible(r1_transform, r2_transform) return r1.bbox, r2.bbox From 0ee5616d59cde1e307ffe8ff7af2c8864461eb7a Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 16 May 2025 09:23:51 -0600 Subject: [PATCH 06/25] remove old bbox code --- src/rasterix/raster_index.py | 37 ------------------------------------ 1 file changed, 37 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 8a2e569..c10af3d 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -41,43 +41,6 @@ def _assert_transforms_are_compatible(A1: Affine, A2: Affine) -> None: assert A1.e == A2.e -def compute_bounding_box_affine( - A1: Affine, shape1: tuple[int, int], A2: Affine, shape2: tuple[int, int], *, how: str -): - _assert_transforms_are_compatible(A1, A2) - - # Apply both affine transformations to the corners - transformed1 = np.stack([A1 * point for point in [(0, 0), shape1]]) - transformed2 = np.stack([A2 * point for point in [(0, 0), shape2]]) - - # Concatenate transformed points - all_points = np.stack([transformed1, transformed2]) - - # Compute bounding box min/max - if how == "outer": - min_x, min_y = all_points.min(axis=(0, 1)).tolist() - max_x, max_y = all_points.max(axis=(0, 1)).tolist() - - assert max_x > min_x - assert max_y > min_y - - # FIXME: floating point error here - Nx = None if A1.a == 0 else int(np.abs((max_x - min_x) / A1.a)) - Ny = None if A1.e == 0 else int(np.abs((max_y - min_y) / A1.e)) - else: - raise NotImplementedError - - Anew = Affine( - A1.a, - A1.b, - min_x if A1.a > 0 else max_x, - A1.d, - A1.e, - min_y if A1.e > 0 else max_y, # TODO: handle orientation here - ) - return Anew, Nx, Ny - - class AffineTransform(CoordinateTransform): """Affine 2D transform wrapper.""" From 5909fa8ce58df4e0eae00ec4826d06bf47736590 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 16 May 2025 09:56:19 -0600 Subject: [PATCH 07/25] Adapt to new equals --- src/rasterix/raster_index.py | 18 +++++++++++------- tests/test_raster_index.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index c10af3d..a0bc4c4 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -85,7 +85,9 @@ def reverse(self, coord_labels): return results - def equals(self, other): + def equals(self, other, *, exclude): + if exclude is not None: + raise NotImplementedError if not isinstance(other, AffineTransform): return False return self.affine == other.affine and self.dim_size == other.dim_size @@ -138,9 +140,11 @@ def reverse(self, coord_labels: dict[Hashable, Any]) -> dict[str, Any]: return {self.dim: positions} - def equals(self, other): + def equals(self, other, *, exclude): if not isinstance(other, AxisAffineTransform): return False + if exclude is not None: + raise NotImplementedError # only compare the affine parameters of the relevant axis if self.is_xaxis: @@ -413,7 +417,9 @@ def sel(self, labels: dict[Any, Any], method=None, tolerance=None) -> IndexSelRe return merge_sel_results(results) - def equals(self, other: Index) -> bool: + def equals(self, other: Index, *, exclude=None) -> bool: + if exclude is None: + exclude = {} if not isinstance(other, RasterIndex): return False if set(self._wrapped_indexes) != set(other._wrapped_indexes): @@ -422,6 +428,7 @@ def equals(self, other: Index) -> bool: return all( index.equals(other._wrapped_indexes[k]) # type: ignore[arg-type] for k, index in self._wrapped_indexes.items() + if k not in exclude ) def to_pandas_index(self) -> pd.Index: @@ -473,7 +480,7 @@ def concat( return next(iter(indexes)) raise NotImplementedError - def join(self, other, how) -> RasterIndex: + def join(self, other: RasterIndex, how) -> RasterIndex: if not isinstance(other, RasterIndex): raise ValueError( f"Alignment is only supported between RasterIndexes. Received RasterIndex and {type(other)!r} instead" @@ -526,9 +533,6 @@ def reindex_like(self, other: Self, method=None, tolerance=None) -> dict[Hashabl indexers["y"] = get_indexer( theirs.top, ours.top, inter.top, inter.bottom, spacing=dy, tol=tol, size=other._shape["y"] ) - import ipdb - - ipdb.set_trace() return indexers diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index 045ae3a..663f1b7 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -1,4 +1,5 @@ import numpy as np +import pyproj import rioxarray # noqa import xarray as xr from affine import Affine @@ -31,3 +32,21 @@ def test_sel_slice(): assert actual_transform == actual.rio.transform() assert actual_transform == (transform * Affine.translation(0, 3)) + + +def test_combine_nested(): + transforms = [ + "-50.0 0.5 0.0 0.0 0.0 -0.25", + "-40.0 0.5 0.0 0.0 0.0 -0.25", + ] + crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() + + datasets = [ + xr.Dataset( + {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, + coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transform})}, + ) + for transform in transforms + ] + datasets = list(map(assign_index, datasets)) + xr.combine_nested(datasets, concat_dim="x") From fbe3b371cee2a5ed51dceaf83eb0f99c3789550e Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 16 May 2025 10:13:32 -0600 Subject: [PATCH 08/25] Vendor odc's BoundingBox --- src/rasterix/odc_compat.py | 241 +++++++++++++++++++++++++++++++++++ src/rasterix/raster_index.py | 4 +- 2 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 src/rasterix/odc_compat.py diff --git a/src/rasterix/odc_compat.py b/src/rasterix/odc_compat.py new file mode 100644 index 0000000..c67ecaa --- /dev/null +++ b/src/rasterix/odc_compat.py @@ -0,0 +1,241 @@ +import itertools +import math +from collections.abc import Iterable, Sequence +from typing import Any + +from affine import Affine + + +class BoundingBox(Sequence[float]): + """Bounding box, defining extent in cartesian coordinates.""" + + __slots__ = "_box" + + def __init__(self, left: float, bottom: float, right: float, top: float): + self._box = (left, bottom, right, top) + + @property + def left(self): + return self._box[0] + + @property + def bottom(self): + return self._box[1] + + @property + def right(self): + return self._box[2] + + @property + def top(self): + return self._box[3] + + @property + def bbox(self) -> tuple[float, float, float, float]: + return self._box + + def __iter__(self): + return self._box.__iter__() + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BoundingBox): + return self._box == other._box + return self._box == other + + def __hash__(self) -> int: + return hash(self._box) + + def __len__(self) -> int: + return 4 + + def __getitem__(self, idx): + return self._box[idx] + + def __repr__(self) -> str: + return f"BoundingBox(left={self.left}, bottom={self.bottom}, right={self.right}, top={self.top})" + + def __str__(self) -> str: + return self.__repr__() + + def __and__(self, other: "BoundingBox") -> "BoundingBox": + return bbox_intersection([self, other]) + + def __or__(self, other: "BoundingBox") -> "BoundingBox": + return bbox_union([self, other]) + + def buffered(self, xbuff: float, ybuff: float | None = None) -> "BoundingBox": + """ + Return a new BoundingBox, buffered in the x and y dimensions. + + :param xbuff: X dimension buffering amount + :param ybuff: Y dimension buffering amount + :return: new BoundingBox + """ + if ybuff is None: + ybuff = xbuff + + return BoundingBox( + left=self.left - xbuff, + bottom=self.bottom - ybuff, + right=self.right + xbuff, + top=self.top + ybuff, + ) + + @property + def span_x(self) -> float: + """Span of the bounding box along x axis.""" + return self.right - self.left + + @property + def span_y(self) -> float: + """Span of the bounding box along y axis.""" + return self.top - self.bottom + + @property + def aspect(self) -> float: + """Aspect ratio.""" + return self.span_x / self.span_y + + @property + def width(self) -> int: + """``int(span_x)``""" + return int(self.right - self.left) + + @property + def height(self) -> int: + """``int(span_y)``""" + return int(self.top - self.bottom) + + @property + def shape(self) -> tuple[int, int]: + """``(int(span_y), int(span_x))``.""" + return (self.height, self.width) + + @property + def range_x(self) -> tuple[float, float]: + """``left, right``""" + return (self.left, self.right) + + @property + def range_y(self) -> tuple[float, float]: + """``bottom, top``""" + return (self.bottom, self.top) + + @property + def points(self) -> list[tuple[float, float]]: + """Extract four corners of the bounding box.""" + x0, y0, x1, y1 = self._box + return list(itertools.product((x0, x1), (y0, y1))) + + def transform(self, transform: Affine) -> "BoundingBox": + """ + Map bounding box through a linear transform. + + Apply linear transform on four points of the bounding box and compute bounding box of these + four points. + """ + pts = [transform * pt for pt in self.points] + xx = [x for x, _ in pts] + yy = [y for _, y in pts] + return BoundingBox(min(xx), min(yy), max(xx), max(yy)) + + def map_bounds(self) -> tuple[tuple[float, float], tuple[float, float]]: + """ + Convert to bounds in folium/ipyleaflet style. + + Returns SW, and NE corners in lat/lon order. + ``((lat_w, lon_s), (lat_e, lon_n))``. + """ + x0, y0, x1, y1 = self._box + return (y0, x0), (y1, x1) + + @staticmethod + def from_xy(x: tuple[float, float], y: tuple[float, float]) -> "BoundingBox": + """ + Construct :py:class:`BoundingBox` from x and y ranges. + + :param x: (left, right) + :param y: (bottom, top) + """ + x1, x2 = sorted(x) + y1, y2 = sorted(y) + return BoundingBox(x1, y1, x2, y2) + + @staticmethod + def from_points(p1: tuple[float, float], p2: tuple[float, float]) -> "BoundingBox": + """ + Construct :py:class:`BoundingBox` from two points. + + :param p1: (x, y) + :param p2: (x, y) + """ + return BoundingBox.from_xy((p1[0], p2[0]), (p1[1], p2[1])) + + @staticmethod + def from_transform(shape: tuple[int, int], transform: Affine) -> "BoundingBox": + """ + Construct :py:class:`BoundingBox` from image shape and transform. + + :param shape: image dimensions + :param transform: Affine mapping from pixel to world + """ + ny, nx = shape + pts = [(0, 0), (nx, 0), (nx, ny), (0, ny)] + transform.itransform(pts) + xx, yy = list(zip(*pts)) + return BoundingBox.from_xy((min(xx), max(xx)), (min(yy), max(yy))) + + def round(self) -> "BoundingBox": + """ + Expand bounding box to nearest integer on all sides. + """ + x0, y0, x1, y1 = self._box + return BoundingBox(math.floor(x0), math.floor(y0), math.ceil(x1), math.ceil(y1)) + + +def bbox_union(bbs: Iterable[BoundingBox]) -> BoundingBox: + """ + Compute union of bounding boxes. + + Given a stream of bounding boxes compute enclosing :py:class:`~odc.geo.geom.BoundingBox`. + """ + # pylint: disable=invalid-name + try: + bb, *bbs = bbs + except ValueError: + raise ValueError("Union of empty stream is undefined") from None + + L, B, R, T = bb + + for bb in bbs: + l, b, r, t = bb # noqa: E741 + L = min(l, L) + B = min(b, B) + R = max(r, R) + T = max(t, T) + + return BoundingBox(L, B, R, T) + + +def bbox_intersection(bbs: Iterable[BoundingBox]) -> BoundingBox: + """ + Compute intersection of bounding boxes. + + Given a stream of bounding boxes compute the overlap :py:class:`~odc.geo.geom.BoundingBox`. + """ + # pylint: disable=invalid-name + try: + bb, *bbs = bbs + except ValueError: + raise ValueError("Intersection of empty stream is undefined") from None + + L, B, R, T = bb + + for bb in bbs: + l, b, r, t = bb # noqa: E741 + L = max(l, L) + B = max(b, B) + R = min(r, R) + T = min(t, T) + + return BoundingBox(L, B, R, T) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index a0bc4c4..0d0805f 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -7,8 +7,6 @@ import numpy as np import pandas as pd from affine import Affine -from odc.geo import BoundingBox -from odc.geo.geom import bbox_intersection, bbox_union from xarray import Coordinates, DataArray, Dataset, Index, Variable from xarray.core.coordinate_transform import CoordinateTransform @@ -16,6 +14,7 @@ from xarray.core.indexes import CoordinateTransformIndex, PandasIndex from xarray.core.indexing import IndexSelResult, merge_sel_results +from rasterix.odc_compat import BoundingBox, bbox_intersection, bbox_union from rasterix.rioxarray_compat import guess_dims T_Xarray = TypeVar("T_Xarray", "DataArray", "Dataset") @@ -466,7 +465,6 @@ def bbox(self) -> BoundingBox: return BoundingBox.from_transform( shape=tuple(self._shape[k] for k in ("y", "x")), transform=self.transform() * Affine.translation(-0.5, -0.5), - crs=None, ) @classmethod From 5cfd0afca453170f2212c31c22b32565be9c6667 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 16 May 2025 10:22:50 -0600 Subject: [PATCH 09/25] Vendor odc math funcs --- src/rasterix/odc_compat.py | 89 ++++++++++++++++++++++++++++++++++++ src/rasterix/raster_index.py | 4 +- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/rasterix/odc_compat.py b/src/rasterix/odc_compat.py index c67ecaa..a6b5ce7 100644 --- a/src/rasterix/odc_compat.py +++ b/src/rasterix/odc_compat.py @@ -6,6 +6,95 @@ from affine import Affine +def _snap_edge_pos(x0: float, x1: float, res: float, tol: float) -> tuple[float, int]: + assert res > 0 + assert x1 >= x0 + _x0 = math.floor(maybe_int(x0 / res, tol)) + _x1 = math.ceil(maybe_int(x1 / res, tol)) + nx = max(1, _x1 - _x0) + return _x0 * res, nx + + +def _snap_edge(x0: float, x1: float, res: float, tol: float) -> tuple[float, int]: + assert x1 >= x0 + if res > 0: + return _snap_edge_pos(x0, x1, res, tol) + _tx, nx = _snap_edge_pos(x0, x1, -res, tol) + tx = _tx + nx * (-res) + return tx, nx + + +def snap_grid( + x0: float, x1: float, res: float, off_pix: float | None = 0, tol: float = 1e-6 +) -> tuple[float, int]: + """ + Compute grid snapping for single axis. + + :param x0: In point ``x0 <= x1`` + :param x1: Out point ``x0 <= x1`` + :param res: Pixel size and direction (can be negative) + :param off_pix: + Pixel fraction to align to ``x=0``. + 0 - edge aligned + 0.5 - center aligned + None - don't snap + + :return: ``tx, nx`` that defines 1-d grid, such that ``x0`` and ``x1`` are within edge pixels. + """ + assert (off_pix is None) or (0 <= off_pix < 1) + if off_pix is None: + if res > 0: + nx = math.ceil(maybe_int((x1 - x0) / res, tol)) + return x0, max(1, nx) + nx = math.ceil(maybe_int((x1 - x0) / (-res), tol)) + return x1, max(nx, 1) + + off = off_pix * abs(res) + _tx, nx = _snap_edge(x0 - off, x1 - off, res, tol) + return _tx + off, nx + + +def split_float(x: float) -> tuple[float, float]: + """ + Split float number into whole and fractional parts. + + Adding the two numbers back together should result in the original value. + Fractional part is always in the ``(-0.5, +0.5)`` interval, and whole part + is equivalent to ``round(x)``. + + :param x: floating point number + :return: ``whole, fraction`` + """ + if not math.isfinite(x): + return (x, 0) + + x_part = math.fmod(x, 1.0) + x_whole = x - x_part + if x_part > 0.5: + x_part -= 1 + x_whole += 1 + elif x_part < -0.5: + x_part += 1 + x_whole -= 1 + return (x_whole, x_part) + + +def maybe_int(x: float, tol: float) -> int | float: + """ + Turn almost ints to actual ints. + + pass through other values unmodified. + """ + if not math.isfinite(x): + return x + + x_whole, x_part = split_float(x) + + if abs(x_part) < tol: # almost int + return int(x_whole) + return x + + class BoundingBox(Sequence[float]): """Bounding box, defining extent in cartesian coordinates.""" diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 0d0805f..ded01b8 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -14,7 +14,7 @@ from xarray.core.indexes import CoordinateTransformIndex, PandasIndex from xarray.core.indexing import IndexSelResult, merge_sel_results -from rasterix.odc_compat import BoundingBox, bbox_intersection, bbox_union +from rasterix.odc_compat import BoundingBox, bbox_intersection, bbox_union, snap_grid from rasterix.rioxarray_compat import guess_dims T_Xarray = TypeVar("T_Xarray", "DataArray", "Dataset") @@ -560,8 +560,6 @@ def bbox_to_affine(bbox: BoundingBox, rx, ry) -> Affine: # FIXME: translate user-provided `tolerance` to `tol` tol: float = 0.01 - from odc.geo.math import snap_grid - offx, nx = snap_grid(bbox.left, bbox.right, rx, 0, tol=tol) offy, ny = snap_grid(bbox.bottom, bbox.top, ry, 0, tol=tol) From c5e7e73105541451c4d6f2d3192205985a76d384 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 16 May 2025 14:49:11 -0600 Subject: [PATCH 10/25] Add new dim concat test --- src/rasterix/raster_index.py | 2 ++ tests/test_raster_index.py | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index ded01b8..64b3d54 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -34,6 +34,8 @@ def assign_index(obj: T_Xarray, *, x_dim: str | None = None, y_dim: str | None = def _assert_transforms_are_compatible(A1: Affine, A2: Affine) -> None: + # TODO: offsets could be multiples + # offset should be compatible too assert A1.a == A2.a assert A1.b == A2.b assert A1.d == A2.d diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index 663f1b7..3a54d3f 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -49,4 +49,23 @@ def test_combine_nested(): for transform in transforms ] datasets = list(map(assign_index, datasets)) - xr.combine_nested(datasets, concat_dim="x") + # xr.combine_nested(datasets, concat_dim="x") + xr.concat(datasets, concat_dim="x") + + +def test_concat_new_dim(): + transforms = [ + "-50.0 0.5 0.0 0.0 0.0 -0.25", + "-50.0 0.5 0.0 0.0 0.0 -0.25", + ] + crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() + + datasets = [ + xr.Dataset( + {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, + coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transform})}, + ) + for transform in transforms + ] + datasets = list(map(assign_index, datasets)) + xr.concat(datasets, dim="time", join="exact") From 9fddc8488b1f47a9312bb6248a0a0737c86d5319 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 16 May 2025 15:01:37 -0600 Subject: [PATCH 11/25] more vendoring --- src/rasterix/raster_index.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 64b3d54..5202833 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math import textwrap from collections.abc import Hashable, Iterable, Mapping, Sequence from typing import Any, Self, TypeVar @@ -14,7 +15,7 @@ from xarray.core.indexes import CoordinateTransformIndex, PandasIndex from xarray.core.indexing import IndexSelResult, merge_sel_results -from rasterix.odc_compat import BoundingBox, bbox_intersection, bbox_union, snap_grid +from rasterix.odc_compat import BoundingBox, bbox_intersection, bbox_union, maybe_int, snap_grid from rasterix.rioxarray_compat import guess_dims T_Xarray = TypeVar("T_Xarray", "DataArray", "Dataset") @@ -537,14 +538,10 @@ def reindex_like(self, other: Self, method=None, tolerance=None) -> dict[Hashabl def get_indexer(off, our_off, start, stop, spacing, tol, size) -> np.ndarray: - from math import ceil + istart = math.ceil(maybe_int((start - off) / spacing, tol)) - from odc.geo.math import maybe_int - - istart = ceil(maybe_int((start - off) / spacing, tol)) - - ours_istart = ceil(maybe_int((start - our_off) / spacing, tol)) - ours_istop = ceil(maybe_int((stop - our_off) / spacing, tol)) + ours_istart = math.ceil(maybe_int((start - our_off) / spacing, tol)) + ours_istop = math.ceil(maybe_int((stop - our_off) / spacing, tol)) idxr = np.concatenate( [ From a3691c68598e54685b3c67c24bd80211f5283291 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Wed, 28 May 2025 22:12:59 -0600 Subject: [PATCH 12/25] fix transforms --- src/rasterix/raster_index.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 5202833..9ea767e 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -454,6 +454,9 @@ def __repr__(self): def transform(self) -> Affine: """Returns Affine transform for top-left corners.""" + return self.center_transform() * Affine.translation(-0.5, -0.5) + + def center_transform(self) -> Affine: if len(self._wrapped_indexes) > 1: x = self._wrapped_indexes["x"].axis_transform.affine y = self._wrapped_indexes["y"].axis_transform.affine @@ -461,13 +464,13 @@ def transform(self) -> Affine: else: index = next(iter(self._wrapped_indexes.values())) aff = index.affine - return aff * Affine.translation(-0.5, -0.5) + return aff @property def bbox(self) -> BoundingBox: return BoundingBox.from_transform( shape=tuple(self._shape[k] for k in ("y", "x")), - transform=self.transform() * Affine.translation(-0.5, -0.5), + transform=self.transform(), ) @classmethod @@ -493,15 +496,12 @@ def join(self, other: RasterIndex, how) -> RasterIndex: "Alignment is only supported between RasterIndexes, when both contain compatible transforms." ) - # move affines back to top-left corner - trans = Affine.translation(-0.5, -0.5) - ours = self.transform() * trans - theirs = other.transform() * trans + ours = self.transform() + theirs = other.transform() ours, theirs = as_compatible_bboxes(self, other) if how == "outer": new_bbox = bbox_union([ours, theirs]) - print(new_bbox, ours, theirs) elif how == "inner": new_bbox = bbox_intersection([ours, theirs]) else: From a7a9069243e103da60e59cee360064bf30f27f7c Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Wed, 28 May 2025 22:27:10 -0600 Subject: [PATCH 13/25] Fix test --- src/rasterix/raster_index.py | 50 ++++++++++++++++++++---------------- tests/test_raster_index.py | 15 ++++++++--- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 9ea767e..36d3c5f 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -34,13 +34,15 @@ def assign_index(obj: T_Xarray, *, x_dim: str | None = None, y_dim: str | None = return obj.assign_coords(coords) -def _assert_transforms_are_compatible(A1: Affine, A2: Affine) -> None: +def _assert_transforms_are_compatible(*affines) -> None: # TODO: offsets could be multiples # offset should be compatible too - assert A1.a == A2.a - assert A1.b == A2.b - assert A1.d == A2.d - assert A1.e == A2.e + A1 = affines[0] + for A2 in affines[1:]: + assert A1.a == A2.a + assert A1.b == A2.b + assert A1.d == A2.d + assert A1.e == A2.e class AffineTransform(CoordinateTransform): @@ -482,7 +484,22 @@ def concat( ) -> Self: if len(indexes) == 1: return next(iter(indexes)) - raise NotImplementedError + + if positions is not None: + raise NotImplementedError + + # Note: I am assuming that xarray has calling `align(..., exclude="x" [or 'y'])` already + # and checked for equality along "y" [or 'x'] + new_bbox = bbox_union(as_compatible_bboxes(*indexes)) + return indexes[0]._new_with_bbox(new_bbox) + + def _new_with_bbox(self, bbox: BoundingBox) -> Self: + affine = self.transform() + new_affine, Nx, Ny = bbox_to_affine(bbox, rx=affine.a, ry=affine.e) + # TODO: set xdim, ydim explicitly + new_index = self.from_transform(new_affine, width=Nx, height=Ny) + assert new_index.bbox == bbox + return new_index def join(self, other: RasterIndex, how) -> RasterIndex: if not isinstance(other, RasterIndex): @@ -496,10 +513,7 @@ def join(self, other: RasterIndex, how) -> RasterIndex: "Alignment is only supported between RasterIndexes, when both contain compatible transforms." ) - ours = self.transform() - theirs = other.transform() ours, theirs = as_compatible_bboxes(self, other) - if how == "outer": new_bbox = bbox_union([ours, theirs]) elif how == "inner": @@ -507,13 +521,7 @@ def join(self, other: RasterIndex, how) -> RasterIndex: else: raise NotImplementedError - affine = self.transform() - new_affine, Nx, Ny = bbox_to_affine(new_bbox, rx=affine.a, ry=affine.e) - - # TODO: set xdim, ydim explicitly - new_index = self.from_transform(new_affine, width=Nx, height=Ny) - assert new_index.bbox == new_bbox - return new_index + return self._new_with_bbox(new_bbox) def reindex_like(self, other: Self, method=None, tolerance=None) -> dict[Hashable, Any]: affine = self.transform() @@ -567,9 +575,7 @@ def bbox_to_affine(bbox: BoundingBox, rx, ry) -> Affine: return affine, nx, ny -def as_compatible_bboxes(r1: RasterIndex, r2: RasterIndex) -> tuple[BoundingBox, BoundingBox]: - r1_transform = r1.transform() - r2_transform = r2.transform() - _assert_transforms_are_compatible(r1_transform, r2_transform) - - return r1.bbox, r2.bbox +def as_compatible_bboxes(*indexes: RasterIndex) -> tuple[BoundingBox, ...]: + transforms = tuple(i.transform() for i in indexes) + _assert_transforms_are_compatible(*transforms) + return tuple(i.bbox for i in indexes) diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index 3a54d3f..44572bb 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -3,6 +3,7 @@ import rioxarray # noqa import xarray as xr from affine import Affine +from xarray.testing import assert_identical from rasterix import RasterIndex, assign_index @@ -36,8 +37,8 @@ def test_sel_slice(): def test_combine_nested(): transforms = [ - "-50.0 0.5 0.0 0.0 0.0 -0.25", - "-40.0 0.5 0.0 0.0 0.0 -0.25", + "-50.0 5 0.0 0.0 0.0 -0.25", + "-40.0 5 0.0 0.0 0.0 -0.25", ] crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() @@ -49,8 +50,14 @@ def test_combine_nested(): for transform in transforms ] datasets = list(map(assign_index, datasets)) - # xr.combine_nested(datasets, concat_dim="x") - xr.concat(datasets, concat_dim="x") + + expected = xr.Dataset( + {"foo": (("y", "x"), np.ones((4, 2 * len(datasets))), {"grid_mapping": "spatial_ref"})}, + coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transforms[0]})}, + ).pipe(assign_index) + + assert_identical(xr.combine_nested(datasets, concat_dim="x", combine_attrs="override"), expected) + assert_identical(xr.concat(datasets, dim="x"), expected) def test_concat_new_dim(): From 919e3535fa71b064a2501f27318aeb1f3c52338e Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Wed, 28 May 2025 22:31:05 -0600 Subject: [PATCH 14/25] dump 3.10 --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- pyproject.toml | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c847e5e..e84faf9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-python@v5.2.0 name: Install Python with: - python-version: "3.10" + python-version: "3.12" - name: Install PyBuild run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f546fbf..a69f7d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: - python-version: ["3.10", "3.13"] + python-version: ["3.11", "3.13"] os: ["ubuntu-latest"] runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index 7ef820d..9eca752 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,14 +14,13 @@ name = "rasterix" description = "Raster extensions for Xarray" license = "Apache-2.0" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" keywords = ["xarray"] classifiers = [ "Development Status :: 4 - Beta", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -75,7 +74,7 @@ dependencies = [ features = ["test"] [[tool.hatch.envs.test.matrix]] -python = ["3.10", "3.13"] +python = ["3.11", "3.13"] [tool.hatch.envs.test.scripts] run-coverage = "pytest --cov-config=pyproject.toml --cov=pkg --cov-report xml --cov=src --junitxml=junit.xml -o junit_family=legacy" From 7fad664343db4361fc3f0ad4941d92eac0cee02f Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Tue, 3 Jun 2025 09:02:23 -0600 Subject: [PATCH 15/25] cleanup --- src/rasterix/odc_compat.py | 63 ------------------------------------ src/rasterix/raster_index.py | 4 +-- 2 files changed, 2 insertions(+), 65 deletions(-) diff --git a/src/rasterix/odc_compat.py b/src/rasterix/odc_compat.py index a6b5ce7..c192970 100644 --- a/src/rasterix/odc_compat.py +++ b/src/rasterix/odc_compat.py @@ -152,64 +152,11 @@ def __and__(self, other: "BoundingBox") -> "BoundingBox": def __or__(self, other: "BoundingBox") -> "BoundingBox": return bbox_union([self, other]) - def buffered(self, xbuff: float, ybuff: float | None = None) -> "BoundingBox": - """ - Return a new BoundingBox, buffered in the x and y dimensions. - - :param xbuff: X dimension buffering amount - :param ybuff: Y dimension buffering amount - :return: new BoundingBox - """ - if ybuff is None: - ybuff = xbuff - - return BoundingBox( - left=self.left - xbuff, - bottom=self.bottom - ybuff, - right=self.right + xbuff, - top=self.top + ybuff, - ) - - @property - def span_x(self) -> float: - """Span of the bounding box along x axis.""" - return self.right - self.left - - @property - def span_y(self) -> float: - """Span of the bounding box along y axis.""" - return self.top - self.bottom - - @property - def aspect(self) -> float: - """Aspect ratio.""" - return self.span_x / self.span_y - - @property - def width(self) -> int: - """``int(span_x)``""" - return int(self.right - self.left) - - @property - def height(self) -> int: - """``int(span_y)``""" - return int(self.top - self.bottom) - @property def shape(self) -> tuple[int, int]: """``(int(span_y), int(span_x))``.""" return (self.height, self.width) - @property - def range_x(self) -> tuple[float, float]: - """``left, right``""" - return (self.left, self.right) - - @property - def range_y(self) -> tuple[float, float]: - """``bottom, top``""" - return (self.bottom, self.top) - @property def points(self) -> list[tuple[float, float]]: """Extract four corners of the bounding box.""" @@ -228,16 +175,6 @@ def transform(self, transform: Affine) -> "BoundingBox": yy = [y for _, y in pts] return BoundingBox(min(xx), min(yy), max(xx), max(yy)) - def map_bounds(self) -> tuple[tuple[float, float], tuple[float, float]]: - """ - Convert to bounds in folium/ipyleaflet style. - - Returns SW, and NE corners in lat/lon order. - ``((lat_w, lon_s), (lat_e, lon_n))``. - """ - x0, y0, x1, y1 = self._box - return (y0, x0), (y1, x1) - @staticmethod def from_xy(x: tuple[float, float], y: tuple[float, float]) -> "BoundingBox": """ diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 36d3c5f..5084985 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -515,9 +515,9 @@ def join(self, other: RasterIndex, how) -> RasterIndex: ours, theirs = as_compatible_bboxes(self, other) if how == "outer": - new_bbox = bbox_union([ours, theirs]) + new_bbox = ours | theirs elif how == "inner": - new_bbox = bbox_intersection([ours, theirs]) + new_bbox = ours & theirs else: raise NotImplementedError From 5ab308a1265587bddaf1ce5d4fc68e974becfe71 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 12 Jun 2025 08:44:58 -0600 Subject: [PATCH 16/25] PR comment --- src/rasterix/raster_index.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 5084985..f3767d5 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -502,11 +502,6 @@ def _new_with_bbox(self, bbox: BoundingBox) -> Self: return new_index def join(self, other: RasterIndex, how) -> RasterIndex: - if not isinstance(other, RasterIndex): - raise ValueError( - f"Alignment is only supported between RasterIndexes. Received RasterIndex and {type(other)!r} instead" - ) - if set(self._wrapped_indexes.keys()) != set(other._wrapped_indexes.keys()): # TODO: better error message raise ValueError( From aa58457cf042e887a670931f06305dc62b70a9cc Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 12 Jun 2025 08:46:25 -0600 Subject: [PATCH 17/25] Add odc license --- src/rasterix/odc_compat.py | 204 +++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/src/rasterix/odc_compat.py b/src/rasterix/odc_compat.py index c192970..60cdf8a 100644 --- a/src/rasterix/odc_compat.py +++ b/src/rasterix/odc_compat.py @@ -1,3 +1,207 @@ +# The code below is reproduced from the odc-geo project under terms of their Apache-2.0 license (reproduced below) +# +# Apache License +# Version 2.0, January 2004 +# http://www.apache.org/licenses/ +# +# TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +# +# 1. Definitions. +# +# "License" shall mean the terms and conditions for use, reproduction, +# and distribution as defined by Sections 1 through 9 of this document. +# +# "Licensor" shall mean the copyright owner or entity authorized by +# the copyright owner that is granting the License. +# +# "Legal Entity" shall mean the union of the acting entity and all +# other entities that control, are controlled by, or are under common +# control with that entity. For the purposes of this definition, +# "control" means (i) the power, direct or indirect, to cause the +# direction or management of such entity, whether by contract or +# otherwise, or (ii) ownership of fifty percent (50%) or more of the +# outstanding shares, or (iii) beneficial ownership of such entity. +# +# "You" (or "Your") shall mean an individual or Legal Entity +# exercising permissions granted by this License. +# +# "Source" form shall mean the preferred form for making modifications, +# including but not limited to software source code, documentation +# source, and configuration files. +# +# "Object" form shall mean any form resulting from mechanical +# transformation or translation of a Source form, including but +# not limited to compiled object code, generated documentation, +# and conversions to other media types. +# +# "Work" shall mean the work of authorship, whether in Source or +# Object form, made available under the License, as indicated by a +# copyright notice that is included in or attached to the work +# (an example is provided in the Appendix below). +# +# "Derivative Works" shall mean any work, whether in Source or Object +# form, that is based on (or derived from) the Work and for which the +# editorial revisions, annotations, elaborations, or other modifications +# represent, as a whole, an original work of authorship. For the purposes +# of this License, Derivative Works shall not include works that remain +# separable from, or merely link (or bind by name) to the interfaces of, +# the Work and Derivative Works thereof. +# +# "Contribution" shall mean any work of authorship, including +# the original version of the Work and any modifications or additions +# to that Work or Derivative Works thereof, that is intentionally +# submitted to Licensor for inclusion in the Work by the copyright owner +# or by an individual or Legal Entity authorized to submit on behalf of +# the copyright owner. For the purposes of this definition, "submitted" +# means any form of electronic, verbal, or written communication sent +# to the Licensor or its representatives, including but not limited to +# communication on electronic mailing lists, source code control systems, +# and issue tracking systems that are managed by, or on behalf of, the +# Licensor for the purpose of discussing and improving the Work, but +# excluding communication that is conspicuously marked or otherwise +# designated in writing by the copyright owner as "Not a Contribution." +# +# "Contributor" shall mean Licensor and any individual or Legal Entity +# on behalf of whom a Contribution has been received by Licensor and +# subsequently incorporated within the Work. +# +# 2. Grant of Copyright License. Subject to the terms and conditions of +# this License, each Contributor hereby grants to You a perpetual, +# worldwide, non-exclusive, no-charge, royalty-free, irrevocable +# copyright license to reproduce, prepare Derivative Works of, +# publicly display, publicly perform, sublicense, and distribute the +# Work and such Derivative Works in Source or Object form. +# +# 3. Grant of Patent License. Subject to the terms and conditions of +# this License, each Contributor hereby grants to You a perpetual, +# worldwide, non-exclusive, no-charge, royalty-free, irrevocable +# (except as stated in this section) patent license to make, have made, +# use, offer to sell, sell, import, and otherwise transfer the Work, +# where such license applies only to those patent claims licensable +# by such Contributor that are necessarily infringed by their +# Contribution(s) alone or by combination of their Contribution(s) +# with the Work to which such Contribution(s) was submitted. If You +# institute patent litigation against any entity (including a +# cross-claim or counterclaim in a lawsuit) alleging that the Work +# or a Contribution incorporated within the Work constitutes direct +# or contributory patent infringement, then any patent licenses +# granted to You under this License for that Work shall terminate +# as of the date such litigation is filed. +# +# 4. Redistribution. You may reproduce and distribute copies of the +# Work or Derivative Works thereof in any medium, with or without +# modifications, and in Source or Object form, provided that You +# meet the following conditions: +# +# (a) You must give any other recipients of the Work or +# Derivative Works a copy of this License; and +# +# (b) You must cause any modified files to carry prominent notices +# stating that You changed the files; and +# +# (c) You must retain, in the Source form of any Derivative Works +# that You distribute, all copyright, patent, trademark, and +# attribution notices from the Source form of the Work, +# excluding those notices that do not pertain to any part of +# the Derivative Works; and +# +# (d) If the Work includes a "NOTICE" text file as part of its +# distribution, then any Derivative Works that You distribute must +# include a readable copy of the attribution notices contained +# within such NOTICE file, excluding those notices that do not +# pertain to any part of the Derivative Works, in at least one +# of the following places: within a NOTICE text file distributed +# as part of the Derivative Works; within the Source form or +# documentation, if provided along with the Derivative Works; or, +# within a display generated by the Derivative Works, if and +# wherever such third-party notices normally appear. The contents +# of the NOTICE file are for informational purposes only and +# do not modify the License. You may add Your own attribution +# notices within Derivative Works that You distribute, alongside +# or as an addendum to the NOTICE text from the Work, provided +# that such additional attribution notices cannot be construed +# as modifying the License. +# +# You may add Your own copyright statement to Your modifications and +# may provide additional or different license terms and conditions +# for use, reproduction, or distribution of Your modifications, or +# for any such Derivative Works as a whole, provided Your use, +# reproduction, and distribution of the Work otherwise complies with +# the conditions stated in this License. +# +# 5. Submission of Contributions. Unless You explicitly state otherwise, +# any Contribution intentionally submitted for inclusion in the Work +# by You to the Licensor shall be under the terms and conditions of +# this License, without any additional terms or conditions. +# Notwithstanding the above, nothing herein shall supersede or modify +# the terms of any separate license agreement you may have executed +# with Licensor regarding such Contributions. +# +# 6. Trademarks. This License does not grant permission to use the trade +# names, trademarks, service marks, or product names of the Licensor, +# except as required for reasonable and customary use in describing the +# origin of the Work and reproducing the content of the NOTICE file. +# +# 7. Disclaimer of Warranty. Unless required by applicable law or +# agreed to in writing, Licensor provides the Work (and each +# Contributor provides its Contributions) on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied, including, without limitation, any warranties or conditions +# of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +# PARTICULAR PURPOSE. You are solely responsible for determining the +# appropriateness of using or redistributing the Work and assume any +# risks associated with Your exercise of permissions under this License. +# +# 8. Limitation of Liability. In no event and under no legal theory, +# whether in tort (including negligence), contract, or otherwise, +# unless required by applicable law (such as deliberate and grossly +# negligent acts) or agreed to in writing, shall any Contributor be +# liable to You for damages, including any direct, indirect, special, +# incidental, or consequential damages of any character arising as a +# result of this License or out of the use or inability to use the +# Work (including but not limited to damages for loss of goodwill, +# work stoppage, computer failure or malfunction, or any and all +# other commercial damages or losses), even if such Contributor +# has been advised of the possibility of such damages. +# +# 9. Accepting Warranty or Additional Liability. While redistributing +# the Work or Derivative Works thereof, You may choose to offer, +# and charge a fee for, acceptance of support, warranty, indemnity, +# or other liability obligations and/or rights consistent with this +# License. However, in accepting such obligations, You may act only +# on Your own behalf and on Your sole responsibility, not on behalf +# of any other Contributor, and only if You agree to indemnify, +# defend, and hold each Contributor harmless for any liability +# incurred by, or claims asserted against, such Contributor by reason +# of your accepting any such warranty or additional liability. +# +# END OF TERMS AND CONDITIONS +# +# APPENDIX: How to apply the Apache License to your work. +# +# To apply the Apache License to your work, attach the following +# boilerplate notice, with the fields enclosed by brackets "[]" +# replaced with your own identifying information. (Don't include +# the brackets!) The text should be enclosed in the appropriate +# comment syntax for the file format. We also recommend that a +# file or class name and description of purpose be included on the +# same "printed page" as the copyright notice for easier +# identification within third-party archives. +# +# Copyright [yyyy] [name of copyright owner] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import itertools import math from collections.abc import Iterable, Sequence From 8649541cfe0e1cfe8ddbac7c9176ab022d245e90 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 12 Jun 2025 09:15:37 -0600 Subject: [PATCH 18/25] Add more tests --- tests/test_raster_index.py | 141 ++++++++++++++++++++++++++++++++++--- 1 file changed, 133 insertions(+), 8 deletions(-) diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index 44572bb..93e28c4 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -1,5 +1,6 @@ import numpy as np import pyproj +import pytest import rioxarray # noqa import xarray as xr from affine import Affine @@ -35,11 +36,39 @@ def test_sel_slice(): assert actual_transform == (transform * Affine.translation(0, 3)) -def test_combine_nested(): - transforms = [ - "-50.0 5 0.0 0.0 0.0 -0.25", - "-40.0 5 0.0 0.0 0.0 -0.25", - ] +@pytest.mark.parametrize( + "transforms, concat_dim", + [ + ( + [ + "-50.0 5 0.0 0.0 0.0 -0.25", + "-40.0 5 0.0 0.0 0.0 -0.25", + "-30.0 5 0.0 0.0 0.0 -0.25", + ], + "x", + ), + ( + [ + # decreasing Δy + "-40.0 5 0.0 2.0 0.0 -0.5", + "-40.0 5 0.0 0.0 0.0 -0.5", + "-40.0 5 0.0 -2.0 0.0 -0.5", + ], + "y", + ), + ( + [ + # increasing Δy + "-40.0 5 0.0 -2.0 0.0 0.5", + "-40.0 5 0.0 0.0 0.0 0.5", + "-40.0 5 0.0 2.0 0.0 0.5", + ], + "y", + ), + ], +) +def test_concat_and_combine_nested_1D(transforms, concat_dim): + """Models two side-by-side tiles in""" crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() datasets = [ @@ -51,16 +80,112 @@ def test_combine_nested(): ] datasets = list(map(assign_index, datasets)) + if concat_dim == "x": + new_data = np.ones((4, 2 * len(transforms))) + else: + new_data = np.ones((4 * len(transforms), 2)) expected = xr.Dataset( - {"foo": (("y", "x"), np.ones((4, 2 * len(datasets))), {"grid_mapping": "spatial_ref"})}, + {"foo": (("y", "x"), new_data, {"grid_mapping": "spatial_ref"})}, coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transforms[0]})}, ).pipe(assign_index) - assert_identical(xr.combine_nested(datasets, concat_dim="x", combine_attrs="override"), expected) - assert_identical(xr.concat(datasets, dim="x"), expected) + for actual in [ + xr.combine_nested(datasets, concat_dim=concat_dim, combine_attrs="override"), + xr.concat(datasets, dim=concat_dim), + ]: + assert_identical(actual, expected) + assert_identical(actual, expected) + concat_coord = xr.concat([ds[concat_dim] for ds in datasets], dim=concat_dim) + assert_identical(actual[concat_dim], concat_coord) + + +@pytest.mark.parametrize( + "transforms, concat_dim", + [ + ( + [ + # out-of-order for Y + "-40.0 5 0.0 -2.0 0.0 -0.5", + "-40.0 5 0.0 0.0 0.0 -0.5", + "-40.0 5 0.0 2.0 0.0 -0.5", + ], + "y", + ), + ( + [ + # incompatible, different origins + "-50.0 5 0.0 -2.0 0.0 -0.5", + "-40.0 5 0.0 0.0 0.0 -0.5", + ], + "x", + ), + ( + [ + # incompatible, different Δx + "-50.0 2 0.0 0.0 0.0 -0.5", + "-40.0 5 0.0 0.0 0.0 -0.5", + ], + "x", + ), + ( + [ + # incompatible, different Δy + "-50.0 2 0.0 0.0 0.0 -0.5", + "-40.0 5 0.0 0.0 0.0 -0.25", + ], + "x", + ), + ( + [ + # exact same transform, makes no sense to concat + "-50.0 5 0.0 0.0 0.0 -0.5", + "-50.0 5 0.0 0.0 0.0 -0.5", + ], + "x", + ), + ], +) +def test_concat_errors(transforms, concat_dim): + """Models two side-by-side tiles in""" + crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() + + datasets = [ + xr.Dataset( + {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, + coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transform})}, + ) + for transform in transforms + ] + datasets = list(map(assign_index, datasets)) + + with pytest.raises(ValueError): + xr.combine_nested(datasets, concat_dim=concat_dim, combine_attrs="override") + with pytest.raises(ValueError): + xr.concat(datasets, dim=concat_dim, combine_attrs="override") + + +def test_concat_different_shape_compatible_transform_error(): + crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() + concat_dim = "x" + + ds1 = xr.Dataset( + {"foo": (("y", "x"), np.ones((4, 3)), {"grid_mapping": "spatial_ref"})}, + coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": "-50.0 5 0.0 0.0 0.0 -0.5"})}, + ) + ds2 = xr.Dataset( + {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, + coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": "-40.0 5 0.0 0.0 0.0 -0.5"})}, + ) + + datasets = list(map(assign_index, [ds1, ds2])) + with pytest.raises(ValueError): + xr.combine_nested(datasets, concat_dim=concat_dim, combine_attrs="override") + with pytest.raises(ValueError): + xr.concat(datasets, dim=concat_dim, combine_attrs="override") def test_concat_new_dim(): + """models concat along `time` for two tiles with same transform.""" transforms = [ "-50.0 0.5 0.0 0.0 0.0 -0.25", "-50.0 0.5 0.0 0.0 0.0 -0.25", From 06af30a6f0a5216074f2fdccab85b713cc441caa Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 12 Jun 2025 17:12:02 -0600 Subject: [PATCH 19/25] Better errors --- src/rasterix/raster_index.py | 30 +++++++++++++++++++++++++----- tests/test_raster_index.py | 3 +-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index f3767d5..5623485 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -38,11 +38,11 @@ def _assert_transforms_are_compatible(*affines) -> None: # TODO: offsets could be multiples # offset should be compatible too A1 = affines[0] - for A2 in affines[1:]: - assert A1.a == A2.a - assert A1.b == A2.b - assert A1.d == A2.d - assert A1.e == A2.e + for index, A2 in enumerate(affines[1:]): + if A1.a != A2.a or A1.b != A2.b or A1.d != A2.d or A1.e != A2.e: + raise ValueError( + f"Transform parameters are not compatible for affine 0: {A1}, and affine {index + 1} {A2}" + ) class AffineTransform(CoordinateTransform): @@ -573,4 +573,24 @@ def bbox_to_affine(bbox: BoundingBox, rx, ry) -> Affine: def as_compatible_bboxes(*indexes: RasterIndex) -> tuple[BoundingBox, ...]: transforms = tuple(i.transform() for i in indexes) _assert_transforms_are_compatible(*transforms) + + expected_off_x = tuple(t.c + i._shape["x"] * t.a for i, t in zip(indexes, transforms)) + expected_off_y = tuple(t.f + i._shape["y"] * t.d for i, t in zip(indexes, transforms)) + + off_x = tuple(t.c for t in transforms) + off_y = tuple(t.c for t in transforms) + + if all(o == off_x[0] for o in off_x[1:]) and all(o == off_y[0] for o in off_y[1:]): + raise ValueError("Attempting to concatenate arrays with same transform along X or Y.") + + if any(a != b for a, b in zip(off_x, expected_off_x)): + raise ValueError( + f"X offsets are incompatible. Provided offsets {off_x}, expected offsets: {expected_off_x}" + ) + + if any(a != b for a, b in zip(off_y, expected_off_y)): + raise ValueError( + f"Y offsets are incompatible. Provided offsets {off_y}, expected offsets: {expected_off_y}" + ) + return tuple(i.bbox for i in indexes) diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index 93e28c4..90f2acc 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -68,7 +68,7 @@ def test_sel_slice(): ], ) def test_concat_and_combine_nested_1D(transforms, concat_dim): - """Models two side-by-side tiles in""" + """Models two side-by-side tiles""" crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() datasets = [ @@ -146,7 +146,6 @@ def test_concat_and_combine_nested_1D(transforms, concat_dim): ], ) def test_concat_errors(transforms, concat_dim): - """Models two side-by-side tiles in""" crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() datasets = [ From 2320fed018212155c77b37da5ab34c3a52ba8b7f Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 12 Jun 2025 17:16:24 -0600 Subject: [PATCH 20/25] Add 2D combine nested test --- src/rasterix/raster_index.py | 45 ++++++++++++++++++++++-------------- tests/test_raster_index.py | 26 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 5623485..2777a69 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -490,7 +490,7 @@ def concat( # Note: I am assuming that xarray has calling `align(..., exclude="x" [or 'y'])` already # and checked for equality along "y" [or 'x'] - new_bbox = bbox_union(as_compatible_bboxes(*indexes)) + new_bbox = bbox_union(as_compatible_bboxes(*indexes, concat_dim=dim)) return indexes[0]._new_with_bbox(new_bbox) def _new_with_bbox(self, bbox: BoundingBox) -> Self: @@ -508,13 +508,13 @@ def join(self, other: RasterIndex, how) -> RasterIndex: "Alignment is only supported between RasterIndexes, when both contain compatible transforms." ) - ours, theirs = as_compatible_bboxes(self, other) + ours, theirs = as_compatible_bboxes(self, other, concat_dim=None) if how == "outer": new_bbox = ours | theirs elif how == "inner": new_bbox = ours & theirs else: - raise NotImplementedError + raise NotImplementedError(f"{how=!r} not implemented yet for RasterIndex.") return self._new_with_bbox(new_bbox) @@ -570,27 +570,38 @@ def bbox_to_affine(bbox: BoundingBox, rx, ry) -> Affine: return affine, nx, ny -def as_compatible_bboxes(*indexes: RasterIndex) -> tuple[BoundingBox, ...]: +def as_compatible_bboxes(*indexes: RasterIndex, concat_dim: Hashable | None) -> tuple[BoundingBox, ...]: transforms = tuple(i.transform() for i in indexes) _assert_transforms_are_compatible(*transforms) - expected_off_x = tuple(t.c + i._shape["x"] * t.a for i, t in zip(indexes, transforms)) - expected_off_y = tuple(t.f + i._shape["y"] * t.d for i, t in zip(indexes, transforms)) + expected_off_x = (transforms[0].c,) + tuple( + t.c + i._shape["x"] * t.a for i, t in zip(indexes[:-1], transforms[:-1]) + ) + expected_off_y = (transforms[0].f,) + tuple( + t.f + i._shape["y"] * t.e for i, t in zip(indexes[:-1], transforms[:-1]) + ) off_x = tuple(t.c for t in transforms) - off_y = tuple(t.c for t in transforms) + off_y = tuple(t.f for t in transforms) - if all(o == off_x[0] for o in off_x[1:]) and all(o == off_y[0] for o in off_y[1:]): - raise ValueError("Attempting to concatenate arrays with same transform along X or Y.") + if concat_dim is not None: + if all(o == off_x[0] for o in off_x[1:]) and all(o == off_y[0] for o in off_y[1:]): + raise ValueError("Attempting to concatenate arrays with same transform along X or Y.") - if any(a != b for a, b in zip(off_x, expected_off_x)): - raise ValueError( - f"X offsets are incompatible. Provided offsets {off_x}, expected offsets: {expected_off_x}" - ) + if concat_dim == "x": + if any(off_y[0] != o for o in off_y[1:]): + raise ValueError("offsets must be identical in X when concatenating along Y") + if any(a != b for a, b in zip(off_x, expected_off_x)): + raise ValueError( + f"X offsets are incompatible. Provided offsets {off_x}, expected offsets: {expected_off_x}" + ) + elif concat_dim == "y": + if any(off_x[0] != o for o in off_x[1:]): + raise ValueError("offsets must be identical in X when concatenating along Y") - if any(a != b for a, b in zip(off_y, expected_off_y)): - raise ValueError( - f"Y offsets are incompatible. Provided offsets {off_y}, expected offsets: {expected_off_y}" - ) + if any(a != b for a, b in zip(off_y, expected_off_y)): + raise ValueError( + f"Y offsets are incompatible. Provided offsets {off_y}, expected offsets: {expected_off_y}" + ) return tuple(i.bbox for i in indexes) diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index 90f2acc..d3cb1a0 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -200,3 +200,29 @@ def test_concat_new_dim(): ] datasets = list(map(assign_index, datasets)) xr.concat(datasets, dim="time", join="exact") + + +def test_combine_nested_2d(): + """models 2d tiling""" + transforms = [ + # row 1 + "-50.0 5 0.0 0.0 0.0 -0.25", + "-40.0 5 0.0 0.0 0.0 -0.25", + "-30.0 5 0.0 0.0 0.0 -0.25", + # row 2 + "-50.0 5 0.0 -1 0.0 -0.25", + "-40.0 5 0.0 -1 0.0 -0.25", + "-30.0 5 0.0 -1 0.0 -0.25", + ] + crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() + + datasets = [ + xr.Dataset( + {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, + coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transform})}, + ) + for transform in transforms + ] + datasets = list(map(assign_index, datasets)) + datasets = [datasets[:3], datasets[3:]] + xr.combine_nested(datasets, concat_dim=["y", "x"]) From b2cbf896748a4493df3e6effac46c5fade704fb9 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 12 Jun 2025 22:40:22 -0600 Subject: [PATCH 21/25] cleanup --- src/rasterix/raster_index.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index 2777a69..d25a902 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -35,8 +35,6 @@ def assign_index(obj: T_Xarray, *, x_dim: str | None = None, y_dim: str | None = def _assert_transforms_are_compatible(*affines) -> None: - # TODO: offsets could be multiples - # offset should be compatible too A1 = affines[0] for index, A2 in enumerate(affines[1:]): if A1.a != A2.a or A1.b != A2.b or A1.d != A2.d or A1.e != A2.e: From e1d3623cad815b92b7db834096fd9d452c3a1aaf Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 12 Jun 2025 22:54:41 -0600 Subject: [PATCH 22/25] Cleanup --- tests/test_raster_index.py | 55 ++++++++++---------------------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index d3cb1a0..06a6006 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -8,6 +8,15 @@ from rasterix import RasterIndex, assign_index +CRS_ATTRS = pyproj.CRS.from_epsg(4326).to_cf() + + +def dataset_from_transform(transform: str) -> xr.Dataset: + return xr.Dataset( + {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, + coords={"spatial_ref": ((), 0, CRS_ATTRS | {"GeoTransform": transform})}, + ).pipe(assign_index) + def test_rectilinear(): source = "/vsicurl/https://noaadata.apps.nsidc.org/NOAA/G02135/south/daily/geotiff/2024/01_Jan/S_20240101_concentration_v3.0.tif" @@ -69,16 +78,7 @@ def test_sel_slice(): ) def test_concat_and_combine_nested_1D(transforms, concat_dim): """Models two side-by-side tiles""" - crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() - - datasets = [ - xr.Dataset( - {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, - coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transform})}, - ) - for transform in transforms - ] - datasets = list(map(assign_index, datasets)) + datasets = list(map(dataset_from_transform, transforms)) if concat_dim == "x": new_data = np.ones((4, 2 * len(transforms))) @@ -86,7 +86,7 @@ def test_concat_and_combine_nested_1D(transforms, concat_dim): new_data = np.ones((4 * len(transforms), 2)) expected = xr.Dataset( {"foo": (("y", "x"), new_data, {"grid_mapping": "spatial_ref"})}, - coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transforms[0]})}, + coords={"spatial_ref": ((), 0, CRS_ATTRS | {"GeoTransform": transforms[0]})}, ).pipe(assign_index) for actual in [ @@ -146,17 +146,7 @@ def test_concat_and_combine_nested_1D(transforms, concat_dim): ], ) def test_concat_errors(transforms, concat_dim): - crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() - - datasets = [ - xr.Dataset( - {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, - coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transform})}, - ) - for transform in transforms - ] - datasets = list(map(assign_index, datasets)) - + datasets = list(map(dataset_from_transform, transforms)) with pytest.raises(ValueError): xr.combine_nested(datasets, concat_dim=concat_dim, combine_attrs="override") with pytest.raises(ValueError): @@ -189,16 +179,7 @@ def test_concat_new_dim(): "-50.0 0.5 0.0 0.0 0.0 -0.25", "-50.0 0.5 0.0 0.0 0.0 -0.25", ] - crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() - - datasets = [ - xr.Dataset( - {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, - coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transform})}, - ) - for transform in transforms - ] - datasets = list(map(assign_index, datasets)) + datasets = list(map(dataset_from_transform, transforms)) xr.concat(datasets, dim="time", join="exact") @@ -214,15 +195,7 @@ def test_combine_nested_2d(): "-40.0 5 0.0 -1 0.0 -0.25", "-30.0 5 0.0 -1 0.0 -0.25", ] - crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() - datasets = [ - xr.Dataset( - {"foo": (("y", "x"), np.ones((4, 2)), {"grid_mapping": "spatial_ref"})}, - coords={"spatial_ref": ((), 0, crs_attrs | {"GeoTransform": transform})}, - ) - for transform in transforms - ] - datasets = list(map(assign_index, datasets)) + datasets = list(map(dataset_from_transform, transforms)) datasets = [datasets[:3], datasets[3:]] xr.combine_nested(datasets, concat_dim=["y", "x"]) From 1fdb66e9d627c3aade42c8123f5e84458b830a8d Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Thu, 12 Jun 2025 23:11:07 -0600 Subject: [PATCH 23/25] Add align test --- src/rasterix/odc_compat.py | 2 ++ src/rasterix/raster_index.py | 2 +- tests/test_raster_index.py | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/rasterix/odc_compat.py b/src/rasterix/odc_compat.py index 60cdf8a..53c8552 100644 --- a/src/rasterix/odc_compat.py +++ b/src/rasterix/odc_compat.py @@ -255,6 +255,8 @@ def snap_grid( off = off_pix * abs(res) _tx, nx = _snap_edge(x0 - off, x1 - off, res, tol) + if x1 == x0: + nx = 0 return _tx + off, nx diff --git a/src/rasterix/raster_index.py b/src/rasterix/raster_index.py index d25a902..e89bde7 100644 --- a/src/rasterix/raster_index.py +++ b/src/rasterix/raster_index.py @@ -518,7 +518,7 @@ def join(self, other: RasterIndex, how) -> RasterIndex: def reindex_like(self, other: Self, method=None, tolerance=None) -> dict[Hashable, Any]: affine = self.transform() - ours, theirs = as_compatible_bboxes(self, other) + ours, theirs = as_compatible_bboxes(self, other, concat_dim=None) inter = bbox_intersection([ours, theirs]) dx = affine.a dy = affine.e diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index 06a6006..e522974 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -199,3 +199,22 @@ def test_combine_nested_2d(): datasets = list(map(dataset_from_transform, transforms)) datasets = [datasets[:3], datasets[3:]] xr.combine_nested(datasets, concat_dim=["y", "x"]) + + +def test_align(): + transforms = [ + "-50.0 5 0.0 0.0 0.0 -0.25", + "-40.0 5 0.0 0.0 0.0 -0.25", + ] + expected_affine = Affine(5, 0, -50, 0, -0.25, 0) + datasets = list(map(dataset_from_transform, transforms)) + + aligned = xr.align(*datasets, join="outer") + assert all(a.sizes == {"x": 4, "y": 4} for a in aligned) + assert all(a.xindexes["x"].transform() == expected_affine for a in aligned) + + aligned = xr.align(*datasets, join="inner") + assert all(a.sizes["x"] == 0 for a in aligned) + + with pytest.raises(xr.AlignmentError): + aligned = xr.align(*datasets, join="exact") From b468b659cd171e3b6181e16f4cca0124e8db1fe8 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 13 Jun 2025 08:43:47 -0600 Subject: [PATCH 24/25] Switch to AssertionError --- tests/test_raster_index.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index e522974..8f2c398 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -111,14 +111,6 @@ def test_concat_and_combine_nested_1D(transforms, concat_dim): ], "y", ), - ( - [ - # incompatible, different origins - "-50.0 5 0.0 -2.0 0.0 -0.5", - "-40.0 5 0.0 0.0 0.0 -0.5", - ], - "x", - ), ( [ # incompatible, different Δx @@ -137,7 +129,7 @@ def test_concat_and_combine_nested_1D(transforms, concat_dim): ), ( [ - # exact same transform, makes no sense to concat + # exact same transform, makes no sense to concat in X or Y "-50.0 5 0.0 0.0 0.0 -0.5", "-50.0 5 0.0 0.0 0.0 -0.5", ], @@ -153,6 +145,23 @@ def test_concat_errors(transforms, concat_dim): xr.concat(datasets, dim=concat_dim, combine_attrs="override") +def test_concat_error_alignment(): + transforms, concat_dim = ( + [ + # incompatible, different origins + "-50.0 5 0.0 -2.0 0.0 -0.5", + "-40.0 5 0.0 0.0 0.0 -0.5", + ], + "x", + ) + + datasets = list(map(dataset_from_transform, transforms)) + with pytest.raises(AssertionError): + xr.combine_nested(datasets, concat_dim=concat_dim, combine_attrs="override") + with pytest.raises(AssertionError): + xr.concat(datasets, dim=concat_dim, combine_attrs="override") + + def test_concat_different_shape_compatible_transform_error(): crs_attrs = pyproj.CRS.from_epsg(4326).to_cf() concat_dim = "x" From 8b9b155c48a701e801dafcd8232658bf63d08576 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 13 Jun 2025 08:45:13 -0600 Subject: [PATCH 25/25] Add combine_by_coords skipped test --- tests/test_raster_index.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_raster_index.py b/tests/test_raster_index.py index 8f2c398..1bfe331 100644 --- a/tests/test_raster_index.py +++ b/tests/test_raster_index.py @@ -210,6 +210,24 @@ def test_combine_nested_2d(): xr.combine_nested(datasets, concat_dim=["y", "x"]) +@pytest.mark.skip(reason="xarray converts to PandasIndex") +def test_combine_by_coords(): + """models 2d tiling""" + transforms = [ + # row 1 + "-50.0 5 0.0 0.0 0.0 -0.25", + "-40.0 5 0.0 0.0 0.0 -0.25", + "-30.0 5 0.0 0.0 0.0 -0.25", + # row 2 + "-50.0 5 0.0 -1 0.0 -0.25", + "-40.0 5 0.0 -1 0.0 -0.25", + "-30.0 5 0.0 -1 0.0 -0.25", + ] + + datasets = list(map(dataset_from_transform, transforms)) + xr.combine_by_coords(datasets) + + def test_align(): transforms = [ "-50.0 5 0.0 0.0 0.0 -0.25", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy