From 1cb02f2c612cad5f5d8f9789f8d29b70fddb5358 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Mon, 1 Jun 2026 10:48:07 -0500 Subject: [PATCH] feat: lazycogs interoperable with rioxarray resolves #70 --- .gitignore | 2 + ARCHITECTURE.md | 2 +- README.md | 12 +- docs/guides/dtype-nodata.md | 8 +- docs/notebooks/rioxarray.ipynb | 1525 ++++++++++++++++++++++++++ mkdocs.yml | 1 + pyproject.toml | 1 + src/lazycogs/_core.py | 12 +- tests/benchmarks/test_regressions.py | 13 +- tests/conftest.py | 73 ++ tests/test_core.py | 46 +- tests/test_rioxarray_interop.py | 71 ++ uv.lock | 18 + 13 files changed, 1728 insertions(+), 56 deletions(-) create mode 100644 docs/notebooks/rioxarray.ipynb create mode 100644 tests/test_rioxarray_interop.py diff --git a/.gitignore b/.gitignore index c961afc..b2a5a88 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ slides/lightning-talk/index_files/ docs/notebooks/data/ dev-docs/plans/ + +issue-drafts/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d700a7f..318d808 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,7 +55,7 @@ src/lazycogs/ 7. Calls `compute_output_grid()` to get the output affine transform and dimensions (width, height). No eager coordinate arrays are produced. 8. Creates a single `MultiBandStacBackendArray` (a dataclass) with shape `(band, time, y, x)` holding all the parameters needed to materialise any chunk later, then wraps it in one `xarray.core.indexing.LazilyIndexedArray`. This avoids `xr.concat` (used internally by `ds.to_array()`), which would eagerly load `LazilyIndexedArray`-backed objects. 9. Uses `rasterix.RasterIndex` for spatial indexing, but materialises the x/y coordinate variables eagerly as numpy arrays so chunked scalar spatial selections compute reliably. -10. Constructs the `xr.DataArray` directly from the 4-D variable. If `chunks` is provided, calls `.chunk(chunks)` to convert to a dask-backed array; otherwise the `LazilyIndexedArray` remains in play so narrow slices (e.g. a single pixel) translate to minimal I/O. When output nodata is known, the returned array sets `da.attrs["_FillValue"]` and `da.encoding["_FillValue"]` for downstream serialization. When unknown, no `_FillValue` metadata is attached. +10. Constructs the `xr.DataArray` directly from the 4-D variable. If `chunks` is provided, calls `.chunk(chunks)` to convert to a dask-backed array; otherwise the `LazilyIndexedArray` remains in play so narrow slices (e.g. a single pixel) translate to minimal I/O. Spatial metadata is serialized for both GeoZarr-style consumers and GDAL/rioxarray consumers: `spatial:transform` stays in affine coefficient order, while `spatial_ref.attrs["GeoTransform"]` uses GDAL geotransform order. When output nodata is known, the returned array sets `da.attrs["_FillValue"]` without duplicating it in `da.encoding`, which keeps rioxarray export paths compatible with xarray CF encoding. When unknown, no `_FillValue` metadata is attached. 11. Keeps lazy runtime state on the backing array rather than in `da.attrs`. This lets xarray operations such as `sortby()` and deep copies clone metadata safely without trying to pickle live objects like `DuckdbClient`. ## Explain: dry-run read estimator diff --git a/README.md b/README.md index 4ffc11c..83ae8ed 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,11 @@ asks you to pass `dtype=` explicitly. When you omit `nodata=`: - if sampled bands all agree on one scalar nodata sentinel, the returned - `DataArray` sets `attrs["_FillValue"]` and `encoding["_FillValue"]`, and - masked mosaic output materializes with that same sentinel instead of zero + `DataArray` sets `attrs["_FillValue"]`, and masked mosaic output materializes + with that same sentinel instead of zero - if sampled bands disagree, `open()` raises `ValueError` and asks you to pass `nodata=` explicitly -- if sampled bands have no nodata sentinel, no `_FillValue` encoding is +- if sampled bands have no nodata sentinel, no `_FillValue` metadata is attached and `0` remains only an implementation fill value for uncovered regions - if later chunk reads encounter a conflicting source nodata value, compute @@ -131,6 +131,12 @@ When you omit `nodata=`: Explicit `dtype=` and `nodata=` stay authoritative even when source assets are heterogeneous. +`lazycogs.open()` also attaches CF/rioxarray-compatible spatial metadata. The +GeoZarr-style `spatial:transform` attribute stays in affine coefficient order, +while `spatial_ref.attrs["GeoTransform"]` is written in GDAL geotransform order +so sliced 2D images and 3D band stacks can be read by rioxarray without repairing +the transform metadata. + Float-only mosaic methods such as `MeanMethod`, `MedianMethod`, and `StdevMethod` auto-promote inferred integer outputs to `float32`. If you pass an explicit integer `dtype=` with one of those methods, `open()` raises and diff --git a/docs/guides/dtype-nodata.md b/docs/guides/dtype-nodata.md index d28d9da..2ed6759 100644 --- a/docs/guides/dtype-nodata.md +++ b/docs/guides/dtype-nodata.md @@ -40,10 +40,12 @@ When you omit `nodata=`: - if sampled bands all have `nodata=None`, lazycogs leaves output nodata unknown - if sampled bands disagree, `open()` raises and asks you to pass `nodata=` explicitly -When nodata is known, the returned `DataArray` advertises it in both: +When nodata is known, the returned `DataArray` advertises it with: - `da.attrs["_FillValue"]` -- `da.encoding["_FillValue"]` + +lazycogs intentionally does not duplicate `_FillValue` into `da.encoding` because +that collides with xarray's CF encoding step during rioxarray exports. ## What output nodata means @@ -102,7 +104,7 @@ If a later asset conflicts with the inferred output contract, compute raises ins ```python da.dtype -da.encoding.get("_FillValue") +da.attrs.get("_FillValue") ``` Those two values tell you most of what lazycogs has promised about the returned array. diff --git a/docs/notebooks/rioxarray.ipynb b/docs/notebooks/rioxarray.ipynb new file mode 100644 index 0000000..2b31e62 --- /dev/null +++ b/docs/notebooks/rioxarray.ipynb @@ -0,0 +1,1525 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9f730340-3855-4312-bd39-0f62fdb7b3ff", + "metadata": {}, + "source": [ + "# rioxarray interoperability\n", + "\n", + "lazycogs generates DataArrays that are interoperable with rioxarray. This notebook demonstrates how you can use rioxarray's `to_raster` function to export a 3D lazycogs array `(band, y, x)` to a COG." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a498a820-ad12-43d2-8346-22934802f526", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import rioxarray # noqa: F401 - registers the .rio accessor\n", + "import rustac\n", + "from pyproj import Transformer\n", + "\n", + "import lazycogs\n", + "\n", + "dst_crs = \"epsg:5070\"\n", + "dst_bbox = (250_000, 2_600_000, 350_000, 2_700_000)\n", + "\n", + "# transform to epsg:4326 for STAC search\n", + "transformer = Transformer.from_crs(dst_crs, \"epsg:4326\", always_xy=True)\n", + "bbox_4326 = transformer.transform_bounds(*dst_bbox)\n", + "\n", + "PARQUET = \"data/midwest_summer_2025.parquet\"\n", + "\n", + "if not Path(PARQUET).exists():\n", + " Path(PARQUET).parent.mkdir(exist_ok=True)\n", + " await rustac.search_to(\n", + " PARQUET,\n", + " href=\"https://earth-search.aws.element84.com/v1\",\n", + " collections=[\"sentinel-2-c1-l2a\"],\n", + " datetime=\"2025-06-01/2025-09-30\",\n", + " bbox=bbox_4326,\n", + " limit=100,\n", + " )\n", + "\n", + "print(f\"Using {PARQUET}\")\n", + "\n", + "store = lazycogs.store_for(PARQUET, skip_signature=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "5a78fdb0-e346-4f6e-a771-81b7dca89fdb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.DataArray (band: 3, time: 54, y: 1000, x: 1000)> Size: 324MB\n",
+       "[162000000 values with dtype=int16]\n",
+       "Coordinates:\n",
+       "  * band         (band) <U5 60B 'red' 'green' 'blue'\n",
+       "  * time         (time) datetime64[s] 432B 2025-06-02 2025-06-04 ... 2025-09-27\n",
+       "  * y            (y) float64 8kB 2.7e+06 2.7e+06 2.7e+06 ... 2.6e+06 2.6e+06\n",
+       "  * x            (x) float64 8kB 2.5e+05 2.502e+05 ... 3.498e+05 3.5e+05\n",
+       "    spatial_ref  int64 8B 0\n",
+       "Indexes:\n",
+       "  ┌ x        RasterIndex (crs=EPSG:5070)\n",
+       "  └ y\n",
+       "Attributes:\n",
+       "    grid_mapping:            spatial_ref\n",
+       "    zarr_conventions:        [{'schema_url': 'https://raw.githubusercontent.c...\n",
+       "    spatial:dimensions:      ['y', 'x']\n",
+       "    spatial:bbox:            (250000, 2600000, 350000, 2700000)\n",
+       "    spatial:transform_type:  affine\n",
+       "    spatial:transform:       [100.0, 0.0, 250000.0, 0.0, -100.0, 2700000.0]\n",
+       "    spatial:shape:           [1000, 1000]\n",
+       "    spatial:registration:    pixel\n",
+       "    proj:code:               EPSG:5070\n",
+       "    _FillValue:              0.0
" + ], + "text/plain": [ + " Size: 324MB\n", + "[162000000 values with dtype=int16]\n", + "Coordinates:\n", + " * band (band) \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.DataArray (band: 3, y: 1000, x: 1000)> Size: 6MB\n",
+       "[3000000 values with dtype=int16]\n",
+       "Coordinates:\n",
+       "  * band         (band) <U5 60B 'red' 'green' 'blue'\n",
+       "  * y            (y) float64 8kB 2.7e+06 2.7e+06 2.7e+06 ... 2.6e+06 2.6e+06\n",
+       "  * x            (x) float64 8kB 2.5e+05 2.502e+05 ... 3.498e+05 3.5e+05\n",
+       "    time         datetime64[s] 8B 2025-06-04\n",
+       "    spatial_ref  int64 8B 0\n",
+       "Indexes:\n",
+       "  ┌ x        RasterIndex (crs=EPSG:5070)\n",
+       "  └ y\n",
+       "Attributes:\n",
+       "    grid_mapping:            spatial_ref\n",
+       "    zarr_conventions:        [{'schema_url': 'https://raw.githubusercontent.c...\n",
+       "    spatial:dimensions:      ['y', 'x']\n",
+       "    spatial:bbox:            (250000, 2600000, 350000, 2700000)\n",
+       "    spatial:transform_type:  affine\n",
+       "    spatial:transform:       [100.0, 0.0, 250000.0, 0.0, -100.0, 2700000.0]\n",
+       "    spatial:shape:           [1000, 1000]\n",
+       "    spatial:registration:    pixel\n",
+       "    proj:code:               EPSG:5070\n",
+       "    _FillValue:              0.0
" + ], + "text/plain": [ + " Size: 6MB\n", + "[3000000 values with dtype=int16]\n", + "Coordinates:\n", + " * band (band) =1.5.0", "pre-commit>=4.6.0", "ruff>=0.15.12", + "rioxarray>=0.22.0", ] docs = [ "mkdocs-jupyter>=0.26.2", diff --git a/src/lazycogs/_core.py b/src/lazycogs/_core.py index d04e371..146a38b 100644 --- a/src/lazycogs/_core.py +++ b/src/lazycogs/_core.py @@ -510,7 +510,7 @@ def _build_dataarray( time_coord = np.array(time_coords, dtype="datetime64[D]") - gt = [ + affine_transform = [ dst_affine.a, dst_affine.b, dst_affine.c, @@ -518,12 +518,15 @@ def _build_dataarray( dst_affine.e, dst_affine.f, ] + gdal_transform = dst_affine.to_gdal() + crs_wkt = dst_crs.to_wkt() spatial_ref = DataArray( np.array(0), attrs={ - "crs_wkt": dst_crs.to_wkt(), - "GeoTransform": " ".join(str(v) for v in gt), + "crs_wkt": crs_wkt, + "spatial_ref": crs_wkt, + "GeoTransform": " ".join(str(v) for v in gdal_transform), }, ) @@ -550,7 +553,7 @@ def _build_dataarray( "spatial:dimensions": ["y", "x"], "spatial:bbox": bbox, "spatial:transform_type": "affine", - "spatial:transform": gt, + "spatial:transform": affine_transform, "spatial:shape": [dst_height, dst_width], "spatial:registration": "pixel", } @@ -573,7 +576,6 @@ def _build_dataarray( if nodata is not None: data_array.attrs["_FillValue"] = nodata - data_array.encoding["_FillValue"] = nodata return data_array diff --git a/tests/benchmarks/test_regressions.py b/tests/benchmarks/test_regressions.py index 8736713..06aeb7a 100644 --- a/tests/benchmarks/test_regressions.py +++ b/tests/benchmarks/test_regressions.py @@ -116,7 +116,7 @@ def test_open_infers_uint16_and_coherent_nodata_from_benchmark_data( assert "nodata" not in da.attrs assert "missing_value" not in da.attrs assert da.attrs["_FillValue"] == 0 - assert da.encoding["_FillValue"] == 0 + assert "_FillValue" not in da.encoding def test_open_explicit_overrides_win_over_benchmark_inference( @@ -137,7 +137,7 @@ def test_open_explicit_overrides_win_over_benchmark_inference( assert "nodata" not in da.attrs assert "missing_value" not in da.attrs assert da.attrs["_FillValue"] == -9999 - assert da.encoding["_FillValue"] == -9999 + assert "_FillValue" not in da.encoding def test_explain_fetch_headers_uses_local_benchmark_data( @@ -210,7 +210,7 @@ def test_open_accepts_conflicting_sampled_nodata_with_explicit_override( assert "nodata" not in da.attrs assert "missing_value" not in da.attrs assert da.attrs["_FillValue"] == -9999 - assert da.encoding["_FillValue"] == -9999 + assert "_FillValue" not in da.encoding def test_compute_raises_on_later_incompatible_auto_inferred_dtype( @@ -383,7 +383,7 @@ def test_compute_accepts_conflicting_nodata_with_explicit_override( assert "nodata" not in da.attrs assert "missing_value" not in da.attrs assert da.attrs["_FillValue"] == 0 - assert da.encoding["_FillValue"] == 0 + assert "_FillValue" not in da.encoding def test_compute_preserves_explicit_nodata_for_fully_masked_pixels( @@ -415,7 +415,8 @@ def test_compute_preserves_explicit_nodata_for_fully_masked_pixels( assert data.shape == (1, 1, 1, 1) assert data.dtype == np.dtype("uint16") assert data.item() == 255 - assert da.encoding["_FillValue"] == 255 + assert da.attrs["_FillValue"] == 255 + assert "_FillValue" not in da.encoding def test_chunk_reads_still_mask_per_cog_nodata_with_explicit_override( @@ -475,5 +476,5 @@ def test_chunk_reads_still_mask_per_cog_nodata_with_explicit_override( assert "nodata" not in da.attrs assert "missing_value" not in da.attrs assert da.attrs["_FillValue"] == 0 - assert da.encoding["_FillValue"] == 0 + assert "_FillValue" not in da.encoding assert value == 10 diff --git a/tests/conftest.py b/tests/conftest.py index c78a4a9..21e1853 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,15 +5,19 @@ import tempfile from collections.abc import Iterator from pathlib import Path +from unittest.mock import patch import numpy as np import pytest import rasterio import rasterio.enums import rasterio.shutil +import rustac from affine import Affine +from obstore.store import MemoryStore from pyproj import CRS +import lazycogs from lazycogs import _store @@ -23,6 +27,42 @@ def clear_store_cache_for_tests() -> None: _store._STORE_CACHE.clear() +def _fake_open_item() -> dict: + return { + "id": "test-item", + "stac_extensions": [], + "properties": {"datetime": "2023-01-15T10:00:00Z"}, + "assets": { + "B04": { + "href": "s3://bucket/B04.tif", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + }, + }, + } + + +def _items_to_arrow(items: list[dict]) -> rustac.DuckdbClient: + if not items: + return None + full_items = [] + for i, item in enumerate(items): + props = dict(item.get("properties", {})) + full_items.append( + { + "type": "Feature", + "stac_version": "1.0.0", + "id": f"fake-{i}", + "geometry": {"type": "Point", "coordinates": [0.0, 0.0]}, + "bbox": [-0.1, -0.1, 0.1, 0.1], + "properties": props, + "links": [], + "assets": {}, + }, + ) + return rustac.to_arrow(full_items) + + @pytest.fixture def clear_store_cache() -> Iterator[None]: """Reset the shared store cache around a test.""" @@ -31,6 +71,39 @@ def clear_store_cache() -> Iterator[None]: clear_store_cache_for_tests() +@pytest.fixture +def opened_dataarray(tmp_path): + """Return a small DataArray from open() with DuckDB calls patched.""" + parquet = tmp_path / "items.parquet" + parquet.write_bytes(b"") + + store = MemoryStore() + store.put("B04.tif", b"dummy") + + table = _items_to_arrow([{"properties": {"datetime": "2023-01-15T10:00:00Z"}}]) + + class _FakeGeoTIFF: + dtype = np.dtype("uint16") + nodata = 0 + + async def fake_open(path: str, *, store): + return _FakeGeoTIFF() + + with ( + patch("rustac.DuckdbClient.search", return_value=[_fake_open_item()]), + patch("rustac.DuckdbClient.search_to_arrow", return_value=table), + patch("lazycogs._core.GeoTIFF.open", side_effect=fake_open), + ): + return lazycogs.open( + str(parquet), + bbox=(0.0, 0.0, 100.0, 100.0), + crs="EPSG:32632", + resolution=10.0, + store=store, + path_from_href=lambda href: href.split("/", 3)[-1], + ) + + @pytest.fixture(scope="session") def synthetic_cog(tmp_path_factory) -> Path: """Write a small synthetic COG with four overview levels to a temp file. diff --git a/tests/test_core.py b/tests/test_core.py index bf59e08..72a977a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -309,40 +309,6 @@ def _fake_open_item() -> dict: } -@pytest.fixture -def opened_dataarray(tmp_path): - """Return a small DataArray from open() with DuckDB calls patched.""" - - parquet = tmp_path / "items.parquet" - parquet.write_bytes(b"") - - store = MemoryStore() - store.put("B04.tif", b"dummy") - - table = _items_to_arrow([{"properties": {"datetime": "2023-01-15T10:00:00Z"}}]) - - class _FakeGeoTIFF: - dtype = np.dtype("uint16") - nodata = 0 - - async def fake_open(path: str, *, store): - return _FakeGeoTIFF() - - with ( - patch("rustac.DuckdbClient.search", return_value=[_fake_open_item()]), - patch("rustac.DuckdbClient.search_to_arrow", return_value=table), - patch("lazycogs._core.GeoTIFF.open", side_effect=fake_open), - ): - return lazycogs.open( - str(parquet), - bbox=(0.0, 0.0, 100.0, 100.0), - crs="EPSG:32632", - resolution=10.0, - store=store, - path_from_href=lambda href: href.split("/", 3)[-1], - ) - - def test_open_sets_expected_dataarray_attributes(opened_dataarray): """open() attaches all expected extra attributes to the returned DataArray.""" da = opened_dataarray @@ -364,9 +330,12 @@ def test_open_sets_expected_dataarray_attributes(opened_dataarray): assert da.attrs["spatial:registration"] == "pixel" assert da.attrs["proj:code"] == "EPSG:32632" - # spatial:transform should be the 6-element GeoTransform list - assert isinstance(da.attrs["spatial:transform"], list) - assert len(da.attrs["spatial:transform"]) == 6 + # spatial:transform remains affine-order metadata. + assert da.attrs["spatial:transform"] == [10.0, 0.0, 0.0, 0.0, -10.0, 100.0] + + spatial_ref_attrs = da.coords["spatial_ref"].attrs + assert spatial_ref_attrs["crs_wkt"] == spatial_ref_attrs["spatial_ref"] + assert spatial_ref_attrs["GeoTransform"] == "0.0 10.0 0.0 100.0 0.0 -10.0" # zarr_conventions assert isinstance(da.attrs["zarr_conventions"], list) @@ -378,7 +347,8 @@ def test_open_sets_expected_dataarray_attributes(opened_dataarray): assert da.dtype == np.dtype("uint16") assert "nodata" not in da.attrs assert "missing_value" not in da.attrs - assert da.encoding["_FillValue"] == da.attrs["_FillValue"] == 0 + assert da.attrs["_FillValue"] == 0 + assert "_FillValue" not in da.encoding # Internal runtime state is kept off attrs so xarray can deep-copy safely. assert "_stac_backend" not in da.attrs diff --git a/tests/test_rioxarray_interop.py b/tests/test_rioxarray_interop.py new file mode 100644 index 0000000..2a78fdf --- /dev/null +++ b/tests/test_rioxarray_interop.py @@ -0,0 +1,71 @@ +"""Tests for rioxarray interoperability.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest +import rasterio +from affine import Affine + + +def test_rioxarray_reads_lazycogs_spatial_metadata(opened_dataarray): + """rioxarray reads 2D slice metadata without repair.""" + pytest.importorskip("rioxarray") + + band = opened_dataarray.isel(time=0, band=0, drop=True) + + assert band.rio.x_dim == "x" + assert band.rio.y_dim == "y" + assert band.rio.crs.to_epsg() == 32632 + assert band.rio.transform() == Affine(10.0, 0.0, 0.0, 0.0, -10.0, 100.0) + assert band.rio.nodata == 0 + assert band.rio.encoded_nodata is None + assert "_FillValue" not in band.encoding + + +def _assert_raster_metadata(path, *, count): + with rasterio.open(path) as dataset: + assert dataset.count == count + assert dataset.width == 10 + assert dataset.height == 10 + assert dataset.crs.to_epsg() == 32632 + assert dataset.transform == Affine(10.0, 0.0, 0.0, 0.0, -10.0, 100.0) + assert dataset.nodata == 0 + + +def test_rioxarray_writes_2d_image_slice_without_metadata_repair( + opened_dataarray, + tmp_path, +): + """A 2D image slice can be exported through rioxarray as a one-band raster.""" + pytest.importorskip("rioxarray") + + band = opened_dataarray.isel(time=0, band=0, drop=True) + exportable_band = band.copy(data=np.ones(band.shape, dtype=band.dtype)) + output_path = tmp_path / "band.tif" + + with warnings.catch_warnings(): + warnings.filterwarnings("error", category=UserWarning) + exportable_band.rio.to_raster(output_path) + + _assert_raster_metadata(output_path, count=1) + + +def test_rioxarray_writes_3d_band_stack_without_metadata_repair( + opened_dataarray, + tmp_path, +): + """A 3D band stack can be exported through rioxarray as a multiband raster.""" + pytest.importorskip("rioxarray") + + scene = opened_dataarray.isel(time=0, drop=True) + exportable_scene = scene.copy(data=np.ones(scene.shape, dtype=scene.dtype)) + output_path = tmp_path / "scene.tif" + + with warnings.catch_warnings(): + warnings.filterwarnings("error", category=UserWarning) + exportable_scene.rio.to_raster(output_path) + + _assert_raster_metadata(output_path, count=scene.sizes["band"]) diff --git a/uv.lock b/uv.lock index 46537dd..05afd82 100644 --- a/uv.lock +++ b/uv.lock @@ -1315,6 +1315,7 @@ dev = [ { name = "pytest" }, { name = "pytest-benchmark" }, { name = "rasterio" }, + { name = "rioxarray" }, { name = "ruff" }, ] docs = [ @@ -1350,6 +1351,7 @@ dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-benchmark", specifier = ">=5.2.3" }, { name = "rasterio", specifier = ">=1.5.0" }, + { name = "rioxarray", specifier = ">=0.22.0" }, { name = "ruff", specifier = ">=0.15.12" }, ] docs = [ @@ -2617,6 +2619,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, ] +[[package]] +name = "rioxarray" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pyproj" }, + { name = "rasterio" }, + { name = "xarray" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/04/9e43477ab0fce7c4c949e1131bfae55ec5228da4ba30f55760660db224b2/rioxarray-0.22.0.tar.gz", hash = "sha256:3f55f23a632ffd9eff13463634227f4afbbcf298947536e161f6cf2ce88d4373", size = 61337, upload-time = "2026-03-06T17:11:00.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/dd/0b2c68495331ba36af783139baaa94693ef310d484d458c11dfa1357287d/rioxarray-0.22.0-py3-none-any.whl", hash = "sha256:db0aa55cd36a95060968f2e6574107829def29d43a563560b90bc642d0bd6a3b", size = 72018, upload-time = "2026-03-06T17:10:58.965Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0"