You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by pa...@apache.org on 2026/06/16 04:10:00 UTC

(sedona-db) branch main updated: feat(rust/sedona-raster-zarr): derive georeferencing from coordinate arrays (#954)

This is an automated email from the ASF dual-hosted git repository.

paleolimbot pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git


The following commit(s) were added to refs/heads/main by this push:
     new 88c255ec4 feat(rust/sedona-raster-zarr): derive georeferencing from coordinate arrays (#954)
88c255ec4 is described below

commit 88c255ec45ccf665b2a459c5da28a46bc811057f
Author: James Willis <ja...@users.noreply.github.com>
AuthorDate: Mon Jun 15 21:09:55 2026 -0700

    feat(rust/sedona-raster-zarr): derive georeferencing from coordinate arrays (#954)
---
 rust/sedona-raster-zarr/src/coords.rs           | 218 ++++++++++++++++++++++++
 rust/sedona-raster-zarr/src/lib.rs              |   1 +
 rust/sedona-raster-zarr/src/loader.rs           | 108 +++++++++---
 rust/sedona-raster-zarr/tests/zarr_roundtrip.rs | 114 +++++++++++++
 4 files changed, 416 insertions(+), 25 deletions(-)

diff --git a/rust/sedona-raster-zarr/src/coords.rs b/rust/sedona-raster-zarr/src/coords.rs
new file mode 100644
index 000000000..fdc99c164
--- /dev/null
+++ b/rust/sedona-raster-zarr/src/coords.rs
@@ -0,0 +1,218 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+
+//! Deriving a 2-D geotransform from a Zarr group's 1-D spatial coordinate
+//! arrays, for CF-style datasets that carry coordinate variables but no
+//! explicit `spatial:transform`.
+//!
+//! An affine transform can only represent a *uniform* grid, so the only
+//! usable coordinate arrays are numeric ones with a constant step. We read the
+//! values, confirm regular spacing, and synthesize the transform — treating
+//! the coordinate values as cell centers (a half-step shift gives the cell-edge
+//! origin GDAL expects). `latitude`/`longitude`-named axes additionally imply
+//! geographic `EPSG:4326`; generic `y`/`x` get the transform but no CRS (the
+//! coordinate space is unlabeled — attach a CRS with `RS_SetCRS`).
+
+use arrow_schema::ArrowError;
+use sedona_common::sedona_internal_datafusion_err;
+use zarrs::array::data_type::{
+    Float32DataType, Float64DataType, Int16DataType, Int32DataType, Int64DataType, Int8DataType,
+    UInt16DataType, UInt32DataType, UInt64DataType, UInt8DataType,
+};
+use zarrs::array::{Array, ArraySubset};
+use zarrs::storage::AsyncReadableListableStorage;
+
+/// Relative tolerance for the regular-spacing check, generous enough to absorb
+/// float32 coordinate rounding (e.g. a 0.25° step stored as `f32`).
+const STEP_REL_TOL: f64 = 1e-3;
+
+/// Read a 1-D coordinate array's values as `f64`.
+///
+/// Returns `Ok(None)` — not an error — when the array is absent, not 1-D, or
+/// has a non-numeric dtype, so the caller can fall back gracefully.
+pub(crate) async fn read_coord_values(
+    storage: &AsyncReadableListableStorage,
+    name: &str,
+) -> Result<Option<Vec<f64>>, ArrowError> {
+    let path = format!("/{}", name.trim_start_matches('/'));
+    let Ok(array) = Array::async_open(storage.clone(), &path).await else {
+        return Ok(None);
+    };
+    if array.shape().len() != 1 {
+        return Ok(None);
+    }
+    let subset = ArraySubset::new_with_shape(array.shape().to_vec());
+
+    macro_rules! retrieve_as {
+        ($t:ty) => {{
+            let values: Vec<$t> = array
+                .async_retrieve_array_subset::<Vec<$t>>(&subset)
+                .await
+                .map_err(|e| {
+                    ArrowError::ExternalError(Box::new(sedona_internal_datafusion_err!(
+                        "failed to read coordinate array {path}: {e}"
+                    )))
+                })?;
+            // Lossless for the float dtypes and exact for the small-magnitude
+            // integer coordinates seen in practice; `as f64` can lose precision
+            // only for i64/u64 magnitudes above 2^53, which a coordinate axis
+            // would never reach.
+            values.into_iter().map(|v| v as f64).collect::<Vec<f64>>()
+        }};
+    }
+
+    let dt = array.data_type();
+    let values = if dt.is::<Float64DataType>() {
+        retrieve_as!(f64)
+    } else if dt.is::<Float32DataType>() {
+        retrieve_as!(f32)
+    } else if dt.is::<Int64DataType>() {
+        retrieve_as!(i64)
+    } else if dt.is::<Int32DataType>() {
+        retrieve_as!(i32)
+    } else if dt.is::<Int16DataType>() {
+        retrieve_as!(i16)
+    } else if dt.is::<Int8DataType>() {
+        retrieve_as!(i8)
+    } else if dt.is::<UInt64DataType>() {
+        retrieve_as!(u64)
+    } else if dt.is::<UInt32DataType>() {
+        retrieve_as!(u32)
+    } else if dt.is::<UInt16DataType>() {
+        retrieve_as!(u16)
+    } else if dt.is::<UInt8DataType>() {
+        retrieve_as!(u8)
+    } else {
+        // Non-numeric coordinate (e.g. fixed-length string, datetime): not
+        // usable for a numeric affine, so fall back rather than erroring.
+        return Ok(None);
+    };
+    Ok(Some(values))
+}
+
+/// The uniform step of a regularly-spaced coordinate array, or `None` if it is
+/// too short, zero-width, or irregular (beyond [`STEP_REL_TOL`]).
+pub(crate) fn regular_step(values: &[f64]) -> Option<f64> {
+    if values.len() < 2 {
+        return None;
+    }
+    // An interior NaN/inf would slip past the per-gap check below (every
+    // comparison against NaN is false), so reject non-finite values up front.
+    if values.iter().any(|v| !v.is_finite()) {
+        return None;
+    }
+    // Average step, so float rounding doesn't bias the result toward the first
+    // gap; then confirm every gap matches it within tolerance.
+    let step = (values[values.len() - 1] - values[0]) / (values.len() - 1) as f64;
+    if step == 0.0 || !step.is_finite() {
+        return None;
+    }
+    let tol = step.abs() * STEP_REL_TOL;
+    if values
+        .windows(2)
+        .any(|w| ((w[1] - w[0]) - step).abs() > tol)
+    {
+        return None;
+    }
+    Some(step)
+}
+
+/// Synthesize a GDAL geotransform
+/// `[origin_x, scale_x, skew_x, origin_y, skew_y, scale_y]` from regular 1-D
+/// `x` (column) and `y` (row) coordinate *cell-center* values.
+///
+/// Coordinates are cell centers, so each origin is shifted half a step back to
+/// the cell edge. 1-D rectilinear coordinates carry no rotation, so the skews
+/// are zero. Returns `None` if either axis is not regularly spaced.
+pub(crate) fn transform_from_coords(x: &[f64], y: &[f64]) -> Option<[f64; 6]> {
+    let scale_x = regular_step(x)?;
+    let scale_y = regular_step(y)?;
+    Some([
+        x[0] - scale_x / 2.0,
+        scale_x,
+        0.0,
+        y[0] - scale_y / 2.0,
+        0.0,
+        scale_y,
+    ])
+}
+
+/// Infer a geographic CRS from the spatial dimension names: `lat`/`latitude`
+/// paired with `lon`/`longitude` implies `EPSG:4326`. Generic `y`/`x` carry no
+/// implied CRS — the derived transform is still valid, just in an unlabeled
+/// coordinate space (attach a CRS with `RS_SetCRS`).
+pub(crate) fn infer_geographic_crs(y_name: &str, x_name: &str) -> Option<&'static str> {
+    let is_lat = |s: &str| matches!(s.to_ascii_lowercase().as_str(), "lat" | "latitude");
+    let is_lon = |s: &str| matches!(s.to_ascii_lowercase().as_str(), "lon" | "longitude");
+    (is_lat(y_name) && is_lon(x_name)).then_some("EPSG:4326")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn regular_step_detects_uniform_spacing() {
+        assert_eq!(regular_step(&[0.0, 0.25, 0.5, 0.75]), Some(0.25));
+        // Descending (e.g. north-to-south latitude) yields a negative step.
+        assert_eq!(regular_step(&[45.0, 44.75, 44.5]), Some(-0.25));
+        // float32-style rounding within tolerance still reads as regular; the
+        // returned step is the average, so compare approximately.
+        let step = regular_step(&[0.0, 0.1, 0.2000001, 0.3]).unwrap();
+        assert!((step - 0.1).abs() < 1e-6, "step={step}");
+    }
+
+    #[test]
+    fn regular_step_rejects_irregular_or_degenerate() {
+        assert_eq!(regular_step(&[0.0, 1.0, 3.0]), None); // irregular
+        assert_eq!(regular_step(&[5.0]), None); // too short
+        assert_eq!(regular_step(&[2.0, 2.0, 2.0]), None); // zero step
+    }
+
+    #[test]
+    fn regular_step_rejects_non_finite() {
+        // A non-finite value anywhere (incl. an interior one, which the per-gap
+        // check can't catch) must be rejected.
+        assert_eq!(regular_step(&[0.0, f64::NAN, 2.0]), None);
+        assert_eq!(regular_step(&[0.0, 1.0, f64::INFINITY]), None);
+        assert_eq!(regular_step(&[f64::NAN, 1.0, 2.0]), None);
+    }
+
+    #[test]
+    fn transform_from_coords_shifts_to_cell_edge() {
+        // Hurricane-style: lon 265..320 step 0.25, lat 45..15 step -0.25.
+        let lon: Vec<f64> = (0..221).map(|i| 265.0 + i as f64 * 0.25).collect();
+        let lat: Vec<f64> = (0..121).map(|i| 45.0 - i as f64 * 0.25).collect();
+        let t = transform_from_coords(&lon, &lat).unwrap();
+        assert_eq!(t, [264.875, 0.25, 0.0, 45.125, 0.0, -0.25]);
+    }
+
+    #[test]
+    fn transform_from_coords_none_when_irregular() {
+        assert_eq!(transform_from_coords(&[0.0, 1.0, 3.0], &[0.0, 1.0]), None);
+    }
+
+    #[test]
+    fn infer_crs_only_for_lat_lon() {
+        assert_eq!(
+            infer_geographic_crs("latitude", "longitude"),
+            Some("EPSG:4326")
+        );
+        assert_eq!(infer_geographic_crs("lat", "lon"), Some("EPSG:4326"));
+        assert_eq!(infer_geographic_crs("y", "x"), None);
+    }
+}
diff --git a/rust/sedona-raster-zarr/src/lib.rs b/rust/sedona-raster-zarr/src/lib.rs
index fdee437e3..20178041d 100644
--- a/rust/sedona-raster-zarr/src/lib.rs
+++ b/rust/sedona-raster-zarr/src/lib.rs
@@ -27,6 +27,7 @@
 //!
 //! Local filesystem stores only — `file://` URIs or bare paths.
 
+mod coords;
 mod dtype;
 mod geozarr;
 mod loader;
diff --git a/rust/sedona-raster-zarr/src/loader.rs b/rust/sedona-raster-zarr/src/loader.rs
index b70bb145e..f2514b882 100644
--- a/rust/sedona-raster-zarr/src/loader.rs
+++ b/rust/sedona-raster-zarr/src/loader.rs
@@ -40,6 +40,7 @@ use zarrs::group::Group;
 use zarrs::node::NodeMetadata;
 use zarrs::storage::{AsyncReadableListableStorage, AsyncReadableListableStorageTraits};
 
+use crate::coords;
 use crate::dtype::zarr_to_band_data_type;
 use crate::geozarr::GroupGeoMetadata;
 use crate::source_uri::build_chunk_anchor;
@@ -252,6 +253,10 @@ struct ArrayInfo {
     /// position — ragged final chunks are not emitted as separate short
     /// rows.
     chunk_shape: Vec<u64>,
+    /// Full array shape (extent per dim). Used to validate that a spatial
+    /// coordinate array's length matches the data extent before deriving a
+    /// geotransform from it.
+    shape: Vec<u64>,
     /// Encoded fill value in native-endian byte representation, for the
     /// `nodata` field. None when the array has no fill value declared.
     nodata: Option<Vec<u8>>,
@@ -279,21 +284,7 @@ async fn open_and_validate(
         )))
     })?;
 
-    let geo = GroupGeoMetadata::from_attributes(group.attributes())?;
-
-    // CRS-without-transform is almost always a malformed-metadata bug —
-    // the user thinks they have full georef but downstream spatial joins
-    // will silently use the identity-pixel-coords default. Error loudly
-    // so they fix the metadata rather than getting empty result sets.
-    if geo.crs.is_some() && geo.transform.is_none() {
-        return Err(ArrowError::InvalidArgumentError(format!(
-            "Zarr group at {group_uri} declares a CRS but no `spatial:transform` \
-             attribute; refusing to fall back to the identity transform because \
-             that would silently produce wrong results in spatial joins. Declare \
-             `spatial:transform` on the group or remove the CRS to read this as \
-             a non-georeferenced datacube."
-        )));
-    }
+    let mut geo = GroupGeoMetadata::from_attributes(group.attributes())?;
 
     let arrays = match arrays_filter {
         // Explicit filter: open each named array directly, skipping the
@@ -358,19 +349,84 @@ async fn open_and_validate(
     let spatial_dim_indices =
         resolve_spatial_dim_indices(&array_infos[0].dim_names, geo.spatial_dims.as_deref())?;
 
+    // Effective transform + CRS. An explicit GeoZarr `spatial:transform` wins;
+    // failing that, derive one from the spatial coordinate arrays (the common
+    // CF / non-GeoZarr case); failing that, fall back to identity pixel coords.
     let group_transform = match geo.transform {
         Some(t) => t,
         None => {
-            // Both `spatial:transform` and `proj:*` are absent (the
-            // CRS-only case errored above). Fall back to identity pixel
-            // coordinates and breadcrumb a warning so spatial-join
-            // surprises are debuggable.
-            log::warn!(
-                "Zarr group at {group_uri} has no `spatial:transform`; falling back \
-                 to the identity pixel-coordinate transform [0, 1, 0, 0, 0, -1]. \
-                 Spatial operations against this raster will use pixel coordinates."
-            );
-            [0.0, 1.0, 0.0, 0.0, 0.0, -1.0]
+            let y_axis = spatial_dim_indices[0];
+            let x_axis = spatial_dim_indices[1];
+            let y_name = array_infos[0].dim_names[y_axis].clone();
+            let x_name = array_infos[0].dim_names[x_axis].clone();
+            let x_vals = coords::read_coord_values(&storage, &x_name).await?;
+            let y_vals = coords::read_coord_values(&storage, &y_name).await?;
+
+            // A coordinate array must span the data extent along its axis; a
+            // length mismatch is a malformed coord variable that would yield a
+            // wrong scale, so refuse to derive a transform from it.
+            let x_ok = x_vals
+                .as_ref()
+                .is_none_or(|v| v.len() as u64 == array_infos[0].shape[x_axis]);
+            let y_ok = y_vals
+                .as_ref()
+                .is_none_or(|v| v.len() as u64 == array_infos[0].shape[y_axis]);
+            if !(x_ok && y_ok) {
+                log::warn!(
+                    "Zarr group at {group_uri}: a spatial coordinate array's length does \
+                     not match the data extent; ignoring coordinates for georeferencing"
+                );
+            }
+            let derived = match (x_ok && y_ok, x_vals, y_vals) {
+                (true, Some(x), Some(y)) => coords::transform_from_coords(&x, &y),
+                _ => None,
+            };
+            match derived {
+                Some(t) => {
+                    // Regular CF coordinate arrays imply geographic lon/lat;
+                    // infer the CRS from the dim names only when none was
+                    // declared. Generic y/x stay CRS-less (attach via RS_SetCRS).
+                    let crs_note = if let Some(declared) = geo.crs.as_deref() {
+                        format!("keeping the declared CRS {declared:?}")
+                    } else if let Some(inferred) = coords::infer_geographic_crs(&y_name, &x_name) {
+                        geo.crs = Some(inferred.to_string());
+                        format!("inferred CRS {inferred} from the dim names")
+                    } else {
+                        "no CRS inferred (spatial dims are not lat/lon) — set one with RS_SetCRS"
+                            .to_string()
+                    };
+                    log::debug!(
+                        "Zarr group at {group_uri} has no `spatial:transform`; derived a \
+                         geotransform from the {x_name:?}/{y_name:?} coordinate arrays; {crs_note}"
+                    );
+                    t
+                }
+                // CRS-without-transform and no usable coordinate arrays is
+                // almost always malformed metadata; error rather than silently
+                // using pixel coordinates in spatial joins.
+                None if geo.crs.is_some() => {
+                    return Err(ArrowError::InvalidArgumentError(format!(
+                        "Zarr group at {group_uri} declares a CRS but has neither a \
+                         `spatial:transform` attribute nor regularly-spaced numeric \
+                         spatial coordinate arrays; refusing to fall back to the \
+                         identity transform because that would silently produce wrong \
+                         results in spatial joins. Declare `spatial:transform`, provide \
+                         regular coordinate arrays, or remove the CRS to read this as a \
+                         non-georeferenced datacube."
+                    )));
+                }
+                // No CRS and no usable coordinates: index space, with a
+                // breadcrumb so spatial-join surprises are debuggable.
+                None => {
+                    log::warn!(
+                        "Zarr group at {group_uri} has no `spatial:transform` and no \
+                         usable spatial coordinate arrays; falling back to the identity \
+                         pixel-coordinate transform [0, 1, 0, 0, 0, -1]. Spatial \
+                         operations against this raster will use pixel coordinates."
+                    );
+                    [0.0, 1.0, 0.0, 0.0, 0.0, -1.0]
+                }
+            }
         }
     };
 
@@ -498,6 +554,7 @@ fn collect_array_infos(
             .iter()
             .map(|n| n.get())
             .collect();
+        let shape = array.shape().to_vec();
         let fill_bytes = array.fill_value().as_ne_bytes();
         let nodata = if fill_bytes.is_empty() {
             None
@@ -510,6 +567,7 @@ fn collect_array_infos(
             dim_names,
             chunk_grid_shape,
             chunk_shape,
+            shape,
             nodata,
         });
     }
diff --git a/rust/sedona-raster-zarr/tests/zarr_roundtrip.rs b/rust/sedona-raster-zarr/tests/zarr_roundtrip.rs
index a7ee38863..beba12008 100644
--- a/rust/sedona-raster-zarr/tests/zarr_roundtrip.rs
+++ b/rust/sedona-raster-zarr/tests/zarr_roundtrip.rs
@@ -443,3 +443,117 @@ async fn errors_on_mismatched_chunk_grids() {
         "got: {err}"
     );
 }
+
+/// A CF-style group with no `spatial:transform`: a single-chunk 2-D data array
+/// `temperature` with dims `[y_name, x_name]` plus matching 1-D coordinate
+/// arrays. Georeferencing must be derived from the (regularly spaced)
+/// coordinate values. `&[f64]` implements `IntoArrayBytes`, so the element
+/// slice goes straight to `store_chunk` (typed `store_chunk_elements` is
+/// deprecated).
+fn build_coord_fixture(y_name: &str, y_vals: &[f64], x_name: &str, x_vals: &[f64]) -> TempDir {
+    let tmp = TempDir::new().unwrap();
+    let store = Arc::new(FilesystemStore::new(tmp.path()).unwrap());
+
+    // No spatial:transform, no proj:* — only coordinate arrays.
+    GroupBuilder::new()
+        .build(store.clone(), "/")
+        .unwrap()
+        .store_metadata()
+        .unwrap();
+
+    // 1-D coordinate arrays, single chunk each.
+    for (name, vals) in [(y_name, y_vals), (x_name, x_vals)] {
+        let n = vals.len() as u64;
+        let arr = ArrayBuilder::new(vec![n], vec![n], data_type::float64(), 0.0f64)
+            .dimension_names(Some([name]))
+            .build(store.clone(), &format!("/{name}"))
+            .unwrap();
+        arr.store_metadata().unwrap();
+        arr.store_chunk(&[0], vals).unwrap();
+    }
+
+    // 2-D data array, dims [y_name, x_name], single chunk.
+    let (ny, nx) = (y_vals.len() as u64, x_vals.len() as u64);
+    let data = ArrayBuilder::new(vec![ny, nx], vec![ny, nx], data_type::uint8(), 0u8)
+        .dimension_names(Some([y_name, x_name]))
+        .build(store.clone(), "/temperature")
+        .unwrap();
+    data.store_metadata().unwrap();
+    data.store_chunk(&[0, 0], vec![0u8; (ny * nx) as usize])
+        .unwrap();
+
+    tmp
+}
+
+#[tokio::test]
+async fn derives_geotransform_and_crs_from_coordinate_arrays() {
+    let tmp = build_coord_fixture("latitude", &[20.0, 19.0], "longitude", &[10.0, 11.0, 12.0]);
+    let uri = format!("file://{}", tmp.path().display());
+    let arr = read_all(&uri, None).await.unwrap();
+
+    let rasters = RasterStructArray::new(&arr);
+    assert_eq!(
+        rasters.len(),
+        1,
+        "single-chunk data array -> one raster row"
+    );
+    let raster = rasters.get(0).unwrap();
+
+    // lon [10,11,12] step +1 -> scale_x 1, origin_x 10 - 0.5 = 9.5
+    // lat [20,19]    step -1 -> scale_y -1, origin_y 20 - (-0.5) = 20.5
+    assert_eq!(
+        raster.transform().to_vec(),
+        vec![9.5, 1.0, 0.0, 20.5, 0.0, -1.0]
+    );
+    // latitude/longitude dim names imply geographic EPSG:4326.
+    assert_eq!(raster.crs(), Some("EPSG:4326"));
+}
+
+#[tokio::test]
+async fn derives_transform_without_crs_for_generic_xy_dims() {
+    // `y`/`x` (not lat/lon): transform is still derived from the coordinates,
+    // but no CRS is inferred — the user attaches one with RS_SetCRS.
+    let tmp = build_coord_fixture("y", &[20.0, 19.0], "x", &[10.0, 11.0, 12.0]);
+    let uri = format!("file://{}", tmp.path().display());
+    let arr = read_all(&uri, None).await.unwrap();
+
+    let raster = RasterStructArray::new(&arr).get(0).unwrap();
+    assert_eq!(
+        raster.transform().to_vec(),
+        vec![9.5, 1.0, 0.0, 20.5, 0.0, -1.0]
+    );
+    assert_eq!(raster.crs(), None);
+}
+
+#[tokio::test]
+async fn irregular_coordinate_arrays_fall_back_to_identity() {
+    // x is irregularly spaced (0, 1, 3), so no affine can represent it; with no
+    // declared CRS the reader falls back to the identity pixel transform.
+    let tmp = build_coord_fixture("y", &[0.0, 1.0], "x", &[0.0, 1.0, 3.0]);
+    let uri = format!("file://{}", tmp.path().display());
+    let arr = read_all(&uri, None).await.unwrap();
+
+    let raster = RasterStructArray::new(&arr).get(0).unwrap();
+    assert_eq!(
+        raster.transform().to_vec(),
+        vec![0.0, 1.0, 0.0, 0.0, 0.0, -1.0]
+    );
+    assert_eq!(raster.crs(), None);
+}
+
+#[tokio::test]
+async fn derives_transform_with_explicit_arrays_filter() {
+    // The coordinate arrays are not in the `arrays` filter, but the reader
+    // opens them by name, so derivation still works.
+    let tmp = build_coord_fixture("latitude", &[20.0, 19.0], "longitude", &[10.0, 11.0, 12.0]);
+    let uri = format!("file://{}", tmp.path().display());
+    let arrays = ["temperature".to_string()];
+    let arr = read_all(&uri, Some(&arrays)).await.unwrap();
+
+    let raster = RasterStructArray::new(&arr).get(0).unwrap();
+    assert_eq!(
+        raster.transform().to_vec(),
+        vec![9.5, 1.0, 0.0, 20.5, 0.0, -1.0]
+    );
+    assert_eq!(raster.crs(), Some("EPSG:4326"));
+}