rasterix 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rasterix/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from .options import get_options, set_options
1
2
  from .raster_index import RasterIndex, assign_index
2
3
 
3
4
 
@@ -12,4 +13,4 @@ def _get_version():
12
13
 
13
14
  __version__ = _get_version()
14
15
 
15
- __all__ = ["RasterIndex", "assign_index"]
16
+ __all__ = ["RasterIndex", "assign_index", "set_options", "get_options"]
rasterix/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.1'
32
- __version_tuple__ = version_tuple = (0, 1, 1)
31
+ __version__ = version = '0.2.0'
32
+ __version_tuple__ = version_tuple = (0, 2, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
rasterix/lib.py CHANGED
@@ -1,9 +1,14 @@
1
1
  """Shared library utilities for rasterix."""
2
2
 
3
3
  import logging
4
+ from typing import NotRequired, TypedDict
4
5
 
5
6
  from affine import Affine
6
7
 
8
+ # https://github.com/zarr-conventions/spatial
9
+ _ZARR_SPATIAL_CONVENTION_UUID = "689b58e2-cf7b-45e0-9fff-9cfc0883d6b4"
10
+
11
+
7
12
  # Define TRACE level (lower than DEBUG)
8
13
  TRACE = 5
9
14
  logging.addLevelName(TRACE, "TRACE")
@@ -104,3 +109,72 @@ def affine_from_stac_proj_metadata(metadata: dict) -> Affine | None:
104
109
 
105
110
  a, b, c, d, e, f = transform[:6]
106
111
  return Affine(a, b, c, d, e, f)
112
+
113
+
114
+ _ZarrConventionRegistration = TypedDict("_ZarrConventionRegistration", {"spatial:": str})
115
+
116
+ _ZarrSpatialMetadata = TypedDict(
117
+ "_ZarrSpatialMetadata",
118
+ {
119
+ "zarr_conventions": NotRequired[list[_ZarrConventionRegistration | dict]],
120
+ "spatial:transform": NotRequired[list[float]],
121
+ "spatial:transform_type": NotRequired[str],
122
+ "spatial:registration": NotRequired[str],
123
+ },
124
+ )
125
+
126
+
127
+ def _has_spatial_zarr_convention(metadata: _ZarrSpatialMetadata) -> bool:
128
+ zarr_conventions = metadata.get("zarr_conventions")
129
+ if not zarr_conventions:
130
+ return False
131
+ for entry in zarr_conventions:
132
+ if isinstance(entry, dict) and (
133
+ entry.get("uuid") == _ZARR_SPATIAL_CONVENTION_UUID or entry.get("name") == "spatial:"
134
+ ):
135
+ return True
136
+ return False
137
+
138
+
139
+ def affine_from_spatial_zarr_convention(metadata: dict) -> Affine | None:
140
+ """Extract Affine transform from Zarr spatial convention metadata.
141
+
142
+ See https://github.com/zarr-conventions/spatial for the full specification.
143
+
144
+ Parameters
145
+ ----------
146
+ metadata : dict
147
+ Dictionary containing Zarr spatial convention metadata.
148
+
149
+ Returns
150
+ -------
151
+ Affine or None
152
+ Affine transformation matrix if minimal Zarr spatial metadata is found, None otherwise.
153
+
154
+ Examples
155
+ --------
156
+ >>> ds: xr.Dataset = ...
157
+ >>> affine = affine_from_spatial_zarr_convention(ds.attrs)
158
+ """
159
+ possibly_spatial_metadata: _ZarrSpatialMetadata = metadata # type: ignore[assignment]
160
+
161
+ if _has_spatial_zarr_convention(possibly_spatial_metadata):
162
+ if transform := possibly_spatial_metadata.get("spatial:transform"):
163
+ if len(transform) < 6:
164
+ raise ValueError(f"spatial:transform must have at least 6 elements, got {len(transform)}")
165
+
166
+ transform_type = possibly_spatial_metadata.get("spatial:transform_type", "affine")
167
+ if transform_type != "affine":
168
+ raise NotImplementedError(
169
+ f"Unsupported spatial:transform_type {transform_type!r}; only 'affine' is supported."
170
+ )
171
+
172
+ registration = possibly_spatial_metadata.get("spatial:registration", "pixel")
173
+ if registration != "pixel":
174
+ raise NotImplementedError(
175
+ f"Unsupported spatial:registration {registration!r}; only 'pixel' is supported."
176
+ )
177
+
178
+ return Affine(*map(float, transform[:6]))
179
+
180
+ return None
rasterix/odc_compat.py CHANGED
@@ -340,6 +340,29 @@ class BoundingBox(Sequence[float]):
340
340
  def __hash__(self) -> int:
341
341
  return hash(self._box)
342
342
 
343
+ def isclose(self, other: "BoundingBox", rtol: float = 1e-12, atol: float = 0.0) -> bool:
344
+ """
345
+ Check if two bounding boxes are approximately equal.
346
+
347
+ Parameters
348
+ ----------
349
+ other : BoundingBox
350
+ The bounding box to compare against.
351
+ rtol : float, default 1e-12
352
+ Relative tolerance for comparison.
353
+ atol : float, default 0.0
354
+ Absolute tolerance for comparison.
355
+
356
+ Returns
357
+ -------
358
+ bool
359
+ True if all four bounds (left, bottom, right, top) are close
360
+ within the specified tolerances.
361
+ """
362
+ if not isinstance(other, BoundingBox):
363
+ return False
364
+ return all(math.isclose(a, b, rel_tol=rtol, abs_tol=atol) for a, b in zip(self._box, other._box))
365
+
343
366
  def __len__(self) -> int:
344
367
  return 4
345
368
 
rasterix/options.py ADDED
@@ -0,0 +1,75 @@
1
+ """Options for rasterix with context manager support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from typing import Any
7
+
8
+ OPTIONS: dict[str, Any] = {
9
+ "transform_rtol": 1e-12,
10
+ "transform_atol": 0.0,
11
+ }
12
+
13
+
14
+ def _validate_tolerance(value: Any) -> bool:
15
+ """Validate that value is a non-negative float."""
16
+ return isinstance(value, int | float) and value >= 0
17
+
18
+
19
+ _VALIDATORS = {
20
+ "transform_rtol": _validate_tolerance,
21
+ "transform_atol": _validate_tolerance,
22
+ }
23
+
24
+
25
+ @contextmanager
26
+ def set_options(**kwargs):
27
+ """
28
+ Set options for rasterix in a controlled context.
29
+
30
+ Parameters
31
+ ----------
32
+ transform_rtol : float, default: 1e-12
33
+ Relative tolerance for comparing affine transform parameters
34
+ during alignment and concatenation operations. This small default
35
+ handles typical floating-point representation noise.
36
+ transform_atol : float, default: 0.0
37
+ Absolute tolerance for comparing affine transform parameters.
38
+
39
+ Examples
40
+ --------
41
+ Use as a context manager:
42
+
43
+ >>> import rasterix
44
+ >>> import xarray as xr
45
+ >>> with rasterix.set_options(transform_rtol=1e-9):
46
+ ... result = xr.concat([ds1, ds2], dim="x")
47
+ """
48
+ old = {}
49
+ for k, v in kwargs.items():
50
+ if k not in OPTIONS:
51
+ raise ValueError(f"argument name {k!r} is not in the set of valid options {set(OPTIONS)!r}")
52
+ if k in _VALIDATORS and not _VALIDATORS[k](v):
53
+ raise ValueError(f"option {k!r} given an invalid value: {v!r}. Expected a non-negative number.")
54
+ old[k] = OPTIONS[k]
55
+ OPTIONS.update(kwargs)
56
+ try:
57
+ yield
58
+ finally:
59
+ OPTIONS.update(old)
60
+
61
+
62
+ def get_options() -> dict[str, Any]:
63
+ """
64
+ Get current options for rasterix.
65
+
66
+ Returns
67
+ -------
68
+ dict
69
+ Dictionary of current option values.
70
+
71
+ See Also
72
+ --------
73
+ set_options
74
+ """
75
+ return OPTIONS.copy()
rasterix/raster_index.py CHANGED
@@ -20,8 +20,9 @@ from xarray.core.types import JoinOptions
20
20
  from xproj.typing import CRSAwareIndex
21
21
 
22
22
  from rasterix.odc_compat import BoundingBox, bbox_intersection, bbox_union, maybe_int, snap_grid
23
+ from rasterix.options import get_options as get_rasterix_options
23
24
  from rasterix.rioxarray_compat import guess_dims
24
- from rasterix.utils import get_affine
25
+ from rasterix.utils import get_affine, get_crs_from_proj_zarr_convention
25
26
 
26
27
  T_Xarray = TypeVar("T_Xarray", "DataArray", "Dataset")
27
28
 
@@ -87,22 +88,34 @@ def assign_index(
87
88
 
88
89
  affine = get_affine(obj, x_dim=x_dim, y_dim=y_dim, clear_transform=True)
89
90
 
91
+ detected_crs = obj.proj.crs if crs else None
92
+ if detected_crs is None:
93
+ detected_crs = get_crs_from_proj_zarr_convention(obj)
94
+
90
95
  index = RasterIndex.from_transform(
91
96
  affine,
92
97
  width=obj.sizes[x_dim],
93
98
  height=obj.sizes[y_dim],
94
99
  x_dim=x_dim,
95
100
  y_dim=y_dim,
96
- crs=obj.proj.crs if crs else None,
101
+ crs=detected_crs,
97
102
  )
98
103
  coords = Coordinates.from_xindex(index)
99
104
  return obj.assign_coords(coords)
100
105
 
101
106
 
107
+ def _isclose(a: float, b: float) -> bool:
108
+ """Check if two floats are close using rasterix tolerance options."""
109
+ opts = get_rasterix_options()
110
+ return math.isclose(a, b, rel_tol=opts["transform_rtol"], abs_tol=opts["transform_atol"])
111
+
112
+
102
113
  def _assert_transforms_are_compatible(*affines) -> None:
103
114
  A1 = affines[0]
104
115
  for index, A2 in enumerate(affines[1:]):
105
- if A1.a != A2.a or A1.b != A2.b or A1.d != A2.d or A1.e != A2.e:
116
+ if not (
117
+ _isclose(A1.a, A2.a) and _isclose(A1.b, A2.b) and _isclose(A1.d, A2.d) and _isclose(A1.e, A2.e)
118
+ ):
106
119
  raise ValueError(
107
120
  f"Transform parameters are not compatible for affine 0: {A1}, and affine {index + 1} {A2}"
108
121
  )
@@ -219,9 +232,9 @@ class AxisAffineTransform(CoordinateTransform):
219
232
 
220
233
  # only compare the affine parameters of the relevant axis
221
234
  if self.is_xaxis:
222
- affine_match = self.affine.a == other.affine.a and self.affine.c == other.affine.c
235
+ affine_match = _isclose(self.affine.a, other.affine.a) and _isclose(self.affine.c, other.affine.c)
223
236
  else:
224
- affine_match = self.affine.e == other.affine.e and self.affine.f == other.affine.f
237
+ affine_match = _isclose(self.affine.e, other.affine.e) and _isclose(self.affine.f, other.affine.f)
225
238
 
226
239
  return affine_match and self.size == other.size
227
240
 
@@ -312,9 +325,12 @@ class AxisAffineTransformIndex(CoordinateTransformIndex):
312
325
  label = labels[coord_name]
313
326
  transform = self.axis_transform
314
327
  if isinstance(label, slice):
328
+ # Use 'is None' check instead of 'or' to correctly handle 0 values
315
329
  label = slice(
316
- label.start or transform.forward({coord_name: 0})[coord_name],
317
- label.stop or transform.forward({coord_name: transform.size})[coord_name],
330
+ transform.forward({coord_name: 0})[coord_name] if label.start is None else label.start,
331
+ transform.forward({coord_name: transform.size})[coord_name]
332
+ if label.stop is None
333
+ else label.stop,
318
334
  label.step,
319
335
  )
320
336
  if label.step is None:
@@ -322,8 +338,12 @@ class AxisAffineTransformIndex(CoordinateTransformIndex):
322
338
  pos = self.transform.reverse({coord_name: np.array([label.start, label.stop])})
323
339
  # np.round rounds to even, this way we round upwards
324
340
  pos = np.floor(pos[self.dim] + 0.5).astype("int")
325
- new_start = max(pos[0], 0)
326
- new_stop = min(pos[1] + 1, self.axis_transform.size)
341
+ size = self.axis_transform.size
342
+ # Clamp both start and stop to valid range [0, size]
343
+ new_start = max(min(pos[0], size), 0)
344
+ new_stop = max(min(pos[1] + 1, size), 0)
345
+ # Ensure stop >= start (empty slice if selection is completely outside bounds)
346
+ new_stop = max(new_stop, new_start)
327
347
  return IndexSelResult({self.dim: slice(new_start, new_stop)})
328
348
  else:
329
349
  # otherwise convert to basic (array) indexing
@@ -575,6 +595,89 @@ class RasterIndex(Index, xproj.ProjIndexMixin):
575
595
 
576
596
  return cls(index, crs=crs)
577
597
 
598
+ @classmethod
599
+ def from_geotransform(
600
+ cls,
601
+ geotransform: Sequence[float] | str,
602
+ *,
603
+ width: int,
604
+ height: int,
605
+ x_dim: str = "x",
606
+ y_dim: str = "y",
607
+ x_coord_name: str = "xc",
608
+ y_coord_name: str = "yc",
609
+ crs: CRS | Any | None = None,
610
+ ) -> RasterIndex:
611
+ """Create a RasterIndex from a GDAL-style GeoTransform.
612
+
613
+ Parameters
614
+ ----------
615
+ geotransform : sequence of float or str
616
+ GDAL GeoTransform as a 6-element sequence (c, a, b, f, d, e) or a
617
+ space-separated string of 6 numbers. The elements are:
618
+ - c: x-coordinate of the upper-left corner of the upper-left pixel
619
+ - a: pixel width (x-direction resolution)
620
+ - b: row rotation (typically 0)
621
+ - f: y-coordinate of the upper-left corner of the upper-left pixel
622
+ - d: column rotation (typically 0)
623
+ - e: pixel height (y-direction resolution, typically negative)
624
+ width : int
625
+ Number of pixels in the x direction.
626
+ height : int
627
+ Number of pixels in the y direction.
628
+ x_dim : str, optional
629
+ Name for the x dimension.
630
+ y_dim : str, optional
631
+ Name for the y dimension.
632
+ x_coord_name : str, optional
633
+ Name for the x coordinate. For non-rectilinear transforms only.
634
+ y_coord_name : str, optional
635
+ Name for the y coordinate. For non-rectilinear transforms only.
636
+ crs : :class:`pyproj.crs.CRS` or any, optional
637
+ The coordinate reference system. Any value accepted by
638
+ :meth:`pyproj.crs.CRS.from_user_input`.
639
+
640
+ Returns
641
+ -------
642
+ RasterIndex
643
+ A new RasterIndex object with appropriate internal structure.
644
+
645
+ See Also
646
+ --------
647
+ from_transform : Create from an Affine transform.
648
+ as_geotransform : Convert RasterIndex back to GeoTransform string.
649
+
650
+ References
651
+ ----------
652
+ - `GDAL GeoTransform tutorial <https://gdal.org/en/stable/tutorials/geotransforms_tut.html>`_
653
+
654
+ Examples
655
+ --------
656
+ Create from a sequence:
657
+
658
+ >>> geotransform = (323400.0, 30.0, 0.0, 4265400.0, 0.0, -30.0)
659
+ >>> index = RasterIndex.from_geotransform(geotransform, width=100, height=100)
660
+
661
+ Create from a string (as stored in netCDF attributes):
662
+
663
+ >>> geotransform = "323400.0 30.0 0.0 4265400.0 0.0 -30.0"
664
+ >>> index = RasterIndex.from_geotransform(geotransform, width=100, height=100)
665
+ """
666
+ if isinstance(geotransform, str):
667
+ geotransform = tuple(map(float, geotransform.split()))
668
+
669
+ affine = Affine.from_gdal(*geotransform)
670
+ return cls.from_transform(
671
+ affine,
672
+ width=width,
673
+ height=height,
674
+ x_dim=x_dim,
675
+ y_dim=y_dim,
676
+ x_coord_name=x_coord_name,
677
+ y_coord_name=y_coord_name,
678
+ crs=crs,
679
+ )
680
+
578
681
  @classmethod
579
682
  def from_tiepoint_and_scale(
580
683
  cls,
@@ -891,10 +994,16 @@ class RasterIndex(Index, xproj.ProjIndexMixin):
891
994
  new_affine, Nx, Ny = bbox_to_affine(bbox, rx=affine.a, ry=affine.e)
892
995
  # TODO: set xdim, ydim explicitly
893
996
  new_index = self.from_transform(new_affine, width=Nx, height=Ny)
894
- assert new_index.bbox == bbox
997
+ opts = get_rasterix_options()
998
+ assert new_index.bbox.isclose(bbox, rtol=opts["transform_rtol"], atol=opts["transform_atol"])
895
999
  return new_index
896
1000
 
897
1001
  def join(self, other: RasterIndex, how: JoinOptions = "inner") -> RasterIndex:
1002
+ """Join two RasterIndexes by computing the union or intersection of their bounding boxes.
1003
+
1004
+ Transform compatibility is checked using the tolerance configured via
1005
+ :py:func:`rasterix.set_options` (``transform_rtol`` and ``transform_atol``).
1006
+ """
898
1007
  if not self._proj_crs_equals(cast(CRSAwareIndex, other), allow_none=True):
899
1008
  raise ValueError(
900
1009
  "raster indexes on objects to align do not have the same CRS\n"
@@ -1006,24 +1115,24 @@ def as_compatible_bboxes(*indexes: RasterIndex, concat_dim: Hashable | None) ->
1006
1115
  off_y = tuple(t.f for t in transforms)
1007
1116
 
1008
1117
  if concat_dim is not None:
1009
- if all(o == off_x[0] for o in off_x[1:]) and all(o == off_y[0] for o in off_y[1:]):
1118
+ if all(_isclose(o, off_x[0]) for o in off_x[1:]) and all(_isclose(o, off_y[0]) for o in off_y[1:]):
1010
1119
  raise ValueError("Attempting to concatenate arrays with same transform along X or Y.")
1011
1120
 
1012
1121
  # note: Xarray alignment already ensures that the indexes dimensions are compatible.
1013
1122
  x_dim, y_dim = indexes[0].xy_dims
1014
1123
 
1015
1124
  if concat_dim == x_dim:
1016
- if any(off_y[0] != o for o in off_y[1:]):
1017
- raise ValueError("offsets must be identical in X when concatenating along Y")
1018
- if any(a != b for a, b in zip(off_x, expected_off_x)):
1125
+ if any(not _isclose(off_y[0], o) for o in off_y[1:]):
1126
+ raise ValueError("offsets must be identical in Y when concatenating along X")
1127
+ if any(not _isclose(a, b) for a, b in zip(off_x, expected_off_x)):
1019
1128
  raise ValueError(
1020
1129
  f"X offsets are incompatible. Provided offsets {off_x}, expected offsets: {expected_off_x}"
1021
1130
  )
1022
1131
  elif concat_dim == y_dim:
1023
- if any(off_x[0] != o for o in off_x[1:]):
1132
+ if any(not _isclose(off_x[0], o) for o in off_x[1:]):
1024
1133
  raise ValueError("offsets must be identical in X when concatenating along Y")
1025
1134
 
1026
- if any(a != b for a, b in zip(off_y, expected_off_y)):
1135
+ if any(not _isclose(a, b) for a, b in zip(off_y, expected_off_y)):
1027
1136
  raise ValueError(
1028
1137
  f"Y offsets are incompatible. Provided offsets {off_y}, expected offsets: {expected_off_y}"
1029
1138
  )
@@ -0,0 +1,4 @@
1
+ # Rasterization API
2
+ from .core import geometry_clip, geometry_mask, rasterize
3
+
4
+ __all__ = ["rasterize", "geometry_mask", "geometry_clip"]