Skip to main content

oxigdal_core/buffer/
arrow_convert.rs

1// Allow unsafe blocks inherited from the parent module (low-level typed slice conversions)
2#![allow(unsafe_code)]
3
4//! Arrow [`RecordBatch`] ↔ [`RasterBuffer`] conversions
5//!
6//! This module provides [`TryFrom`] implementations for converting between
7//! [`RasterBuffer`] and Apache Arrow [`RecordBatch`].
8//!
9//! # Feature gate
10//!
11//! These conversions are only available when the `arrow` Cargo feature is enabled.
12//!
13//! # Layout
14//!
15//! A `RecordBatch` produced from a `RasterBuffer` has exactly **one column** named
16//! `"pixel_values"`, with `width * height` rows in row-major order (row 0 first).
17//! The schema carries three metadata entries:
18//!
19//! | Key         | Value                        |
20//! |-------------|------------------------------|
21//! | `"width"`   | decimal string of pixel width |
22//! | `"height"`  | decimal string of pixel height |
23//! | `"data_type"` | [`RasterDataType::name`] string |
24//!
25//! # NoData handling
26//!
27//! If the source buffer carries a `NoData` value, pixels whose value equals the
28//! no-data sentinel are emitted as Arrow nulls.  When converting from a
29//! `RecordBatch` back to a `RasterBuffer`, nulls are silently replaced by the
30//! zero representation of the target type; the output buffer carries
31//! `NoDataValue::None`.
32//!
33//! # Unsupported types
34//!
35//! `CFloat32` and `CFloat64` have no standard Arrow equivalent.  Attempting to
36//! convert a buffer of either type returns
37//! [`OxiGdalError::NotSupported`].
38
39use std::collections::HashMap;
40use std::sync::Arc;
41
42use arrow_array::{
43    Array, ArrayRef, Float32Array, Float64Array, Int8Array, Int16Array, Int32Array, Int64Array,
44    RecordBatch, UInt8Array, UInt16Array, UInt32Array, UInt64Array,
45};
46use arrow_schema::{DataType, Field, Schema};
47
48use super::{OxiGdalError, RasterBuffer, Result};
49use crate::types::{NoDataValue, RasterDataType};
50
51// ─── RasterBuffer → RecordBatch ──────────────────────────────────────────────
52
53impl TryFrom<&RasterBuffer> for RecordBatch {
54    type Error = OxiGdalError;
55
56    /// Converts a [`RasterBuffer`] into an Arrow [`RecordBatch`].
57    ///
58    /// # Errors
59    ///
60    /// - [`OxiGdalError::NotSupported`] – if the buffer's data type is `CFloat32`
61    ///   or `CFloat64`, which have no standard Arrow equivalent.
62    /// - [`OxiGdalError::Internal`] – if the underlying Arrow builder rejects the
63    ///   constructed schema or column (should not happen in practice).
64    fn try_from(buf: &RasterBuffer) -> Result<Self> {
65        let n = (buf.width() * buf.height()) as usize;
66        let nodata = buf.nodata();
67
68        /// Returns `true` when the given `f64` pixel value should be treated as
69        /// null (i.e. it matches the buffer's no-data sentinel).
70        #[inline]
71        fn is_nd(nodata: NoDataValue, v: f64) -> bool {
72            match nodata.as_f64() {
73                None => false,
74                Some(nd) => {
75                    if nd.is_nan() && v.is_nan() {
76                        true
77                    } else {
78                        (nd - v).abs() < f64::EPSILON
79                    }
80                }
81            }
82        }
83
84        let (array, arrow_dt): (ArrayRef, DataType) = match buf.data_type() {
85            RasterDataType::UInt8 => {
86                let vals: &[u8] = buf.as_slice::<u8>().map_err(|e| OxiGdalError::Internal {
87                    message: e.to_string(),
88                })?;
89                let arr: UInt8Array = vals
90                    .iter()
91                    .map(|&v| {
92                        if is_nd(nodata, f64::from(v)) {
93                            None
94                        } else {
95                            Some(v)
96                        }
97                    })
98                    .collect();
99                (Arc::new(arr), DataType::UInt8)
100            }
101            RasterDataType::Int8 => {
102                let vals: &[i8] = buf.as_slice::<i8>().map_err(|e| OxiGdalError::Internal {
103                    message: e.to_string(),
104                })?;
105                let arr: Int8Array = vals
106                    .iter()
107                    .map(|&v| {
108                        if is_nd(nodata, f64::from(v)) {
109                            None
110                        } else {
111                            Some(v)
112                        }
113                    })
114                    .collect();
115                (Arc::new(arr), DataType::Int8)
116            }
117            RasterDataType::UInt16 => {
118                let vals: &[u16] = buf.as_slice::<u16>().map_err(|e| OxiGdalError::Internal {
119                    message: e.to_string(),
120                })?;
121                let arr: UInt16Array = vals
122                    .iter()
123                    .map(|&v| {
124                        if is_nd(nodata, f64::from(v)) {
125                            None
126                        } else {
127                            Some(v)
128                        }
129                    })
130                    .collect();
131                (Arc::new(arr), DataType::UInt16)
132            }
133            RasterDataType::Int16 => {
134                let vals: &[i16] = buf.as_slice::<i16>().map_err(|e| OxiGdalError::Internal {
135                    message: e.to_string(),
136                })?;
137                let arr: Int16Array = vals
138                    .iter()
139                    .map(|&v| {
140                        if is_nd(nodata, f64::from(v)) {
141                            None
142                        } else {
143                            Some(v)
144                        }
145                    })
146                    .collect();
147                (Arc::new(arr), DataType::Int16)
148            }
149            RasterDataType::UInt32 => {
150                let vals: &[u32] = buf.as_slice::<u32>().map_err(|e| OxiGdalError::Internal {
151                    message: e.to_string(),
152                })?;
153                let arr: UInt32Array = vals
154                    .iter()
155                    .map(|&v| {
156                        if is_nd(nodata, f64::from(v)) {
157                            None
158                        } else {
159                            Some(v)
160                        }
161                    })
162                    .collect();
163                (Arc::new(arr), DataType::UInt32)
164            }
165            RasterDataType::Int32 => {
166                let vals: &[i32] = buf.as_slice::<i32>().map_err(|e| OxiGdalError::Internal {
167                    message: e.to_string(),
168                })?;
169                let arr: Int32Array = vals
170                    .iter()
171                    .map(|&v| {
172                        if is_nd(nodata, f64::from(v)) {
173                            None
174                        } else {
175                            Some(v)
176                        }
177                    })
178                    .collect();
179                (Arc::new(arr), DataType::Int32)
180            }
181            RasterDataType::UInt64 => {
182                let vals: &[u64] = buf.as_slice::<u64>().map_err(|e| OxiGdalError::Internal {
183                    message: e.to_string(),
184                })?;
185                let arr: UInt64Array = vals
186                    .iter()
187                    .map(|&v| {
188                        // u64 → f64 may lose precision for very large values, but nodata
189                        // matching uses the same cast so the comparison is consistent.
190                        if is_nd(nodata, v as f64) {
191                            None
192                        } else {
193                            Some(v)
194                        }
195                    })
196                    .collect();
197                (Arc::new(arr), DataType::UInt64)
198            }
199            RasterDataType::Int64 => {
200                let vals: &[i64] = buf.as_slice::<i64>().map_err(|e| OxiGdalError::Internal {
201                    message: e.to_string(),
202                })?;
203                let arr: Int64Array = vals
204                    .iter()
205                    .map(|&v| {
206                        if is_nd(nodata, v as f64) {
207                            None
208                        } else {
209                            Some(v)
210                        }
211                    })
212                    .collect();
213                (Arc::new(arr), DataType::Int64)
214            }
215            RasterDataType::Float32 => {
216                let vals: &[f32] = buf.as_slice::<f32>().map_err(|e| OxiGdalError::Internal {
217                    message: e.to_string(),
218                })?;
219                let arr: Float32Array = vals
220                    .iter()
221                    .map(|&v| {
222                        if is_nd(nodata, f64::from(v)) {
223                            None
224                        } else {
225                            Some(v)
226                        }
227                    })
228                    .collect();
229                (Arc::new(arr), DataType::Float32)
230            }
231            RasterDataType::Float64 => {
232                let vals: &[f64] = buf.as_slice::<f64>().map_err(|e| OxiGdalError::Internal {
233                    message: e.to_string(),
234                })?;
235                let arr: Float64Array = vals
236                    .iter()
237                    .map(|&v| if is_nd(nodata, v) { None } else { Some(v) })
238                    .collect();
239                (Arc::new(arr), DataType::Float64)
240            }
241            RasterDataType::CFloat32 | RasterDataType::CFloat64 => {
242                return Err(OxiGdalError::NotSupported {
243                    operation: format!(
244                        "Arrow conversion of complex type {}",
245                        buf.data_type().name()
246                    ),
247                });
248            }
249        };
250
251        debug_assert_eq!(array.len(), n, "array length must equal pixel count");
252
253        let mut metadata: HashMap<String, String> = HashMap::with_capacity(3);
254        metadata.insert("width".to_string(), buf.width().to_string());
255        metadata.insert("height".to_string(), buf.height().to_string());
256        metadata.insert("data_type".to_string(), buf.data_type().name().to_string());
257
258        let field = Field::new("pixel_values", arrow_dt, true);
259        let schema = Arc::new(Schema::new_with_metadata(vec![field], metadata));
260
261        RecordBatch::try_new(schema, vec![array]).map_err(|e| OxiGdalError::Internal {
262            message: format!("Arrow RecordBatch construction failed: {e}"),
263        })
264    }
265}
266
267// ─── RecordBatch → RasterBuffer ──────────────────────────────────────────────
268
269impl TryFrom<RecordBatch> for RasterBuffer {
270    type Error = OxiGdalError;
271
272    /// Converts an Arrow [`RecordBatch`] back into a [`RasterBuffer`].
273    ///
274    /// The `RecordBatch` must have been produced by the `TryFrom<&RasterBuffer>`
275    /// impl (or be schema-compatible): exactly one column named `"pixel_values"`
276    /// and schema metadata containing `"width"`, `"height"`, and `"data_type"`.
277    ///
278    /// Arrow nulls in the column are replaced by the zero representation of the
279    /// target type.  The returned buffer carries [`NoDataValue::None`].
280    ///
281    /// # Errors
282    ///
283    /// - [`OxiGdalError::InvalidParameter`] – wrong number of columns, wrong
284    ///   column name, or missing / malformed schema metadata.
285    /// - [`OxiGdalError::Internal`] – unexpected Arrow type mismatch (should not
286    ///   occur for batches produced by this module).
287    fn try_from(batch: RecordBatch) -> Result<Self> {
288        // ── Validate schema ──────────────────────────────────────────────────
289
290        if batch.num_columns() != 1 {
291            return Err(OxiGdalError::InvalidParameter {
292                parameter: "batch",
293                message: format!("Expected exactly 1 column, got {}", batch.num_columns()),
294            });
295        }
296
297        let schema = batch.schema();
298        let field = schema.field(0);
299        if field.name() != "pixel_values" {
300            return Err(OxiGdalError::InvalidParameter {
301                parameter: "batch",
302                message: format!(
303                    "Expected column name 'pixel_values', got '{}'",
304                    field.name()
305                ),
306            });
307        }
308
309        let meta = schema.metadata();
310
311        let width: u64 = meta
312            .get("width")
313            .ok_or(OxiGdalError::InvalidParameter {
314                parameter: "batch",
315                message: "Schema metadata missing 'width' key".to_string(),
316            })?
317            .parse::<u64>()
318            .map_err(|e| OxiGdalError::InvalidParameter {
319                parameter: "batch",
320                message: format!("Schema metadata 'width' is not a valid u64: {e}"),
321            })?;
322
323        let height: u64 = meta
324            .get("height")
325            .ok_or(OxiGdalError::InvalidParameter {
326                parameter: "batch",
327                message: "Schema metadata missing 'height' key".to_string(),
328            })?
329            .parse::<u64>()
330            .map_err(|e| OxiGdalError::InvalidParameter {
331                parameter: "batch",
332                message: format!("Schema metadata 'height' is not a valid u64: {e}"),
333            })?;
334
335        let dt_name = meta
336            .get("data_type")
337            .ok_or(OxiGdalError::InvalidParameter {
338                parameter: "batch",
339                message: "Schema metadata missing 'data_type' key".to_string(),
340            })?;
341
342        let data_type = parse_data_type(dt_name)?;
343
344        // ── Convert column to bytes ───────────────────────────────────────────
345
346        let column = batch.column(0);
347        let bytes = arrow_column_to_bytes(column, data_type)?;
348
349        RasterBuffer::new(bytes, width, height, data_type, NoDataValue::None)
350    }
351}
352
353// ─── Private helpers ─────────────────────────────────────────────────────────
354
355/// Parses a `RasterDataType` from its canonical name string.
356fn parse_data_type(name: &str) -> Result<RasterDataType> {
357    match name {
358        "UInt8" => Ok(RasterDataType::UInt8),
359        "Int8" => Ok(RasterDataType::Int8),
360        "UInt16" => Ok(RasterDataType::UInt16),
361        "Int16" => Ok(RasterDataType::Int16),
362        "UInt32" => Ok(RasterDataType::UInt32),
363        "Int32" => Ok(RasterDataType::Int32),
364        "UInt64" => Ok(RasterDataType::UInt64),
365        "Int64" => Ok(RasterDataType::Int64),
366        "Float32" => Ok(RasterDataType::Float32),
367        "Float64" => Ok(RasterDataType::Float64),
368        "CFloat32" | "CFloat64" => Err(OxiGdalError::NotSupported {
369            operation: format!("Arrow conversion of complex type {name}"),
370        }),
371        other => Err(OxiGdalError::InvalidParameter {
372            parameter: "data_type",
373            message: format!("Unknown data type '{other}' in schema metadata"),
374        }),
375    }
376}
377
378/// Converts an Arrow array column to a raw byte `Vec<u8>` suitable for
379/// constructing a `RasterBuffer`.  Nulls are replaced by the zero bytes of the
380/// element type.
381fn arrow_column_to_bytes(column: &dyn Array, data_type: RasterDataType) -> Result<Vec<u8>> {
382    macro_rules! downcast_to_bytes {
383        ($ArrowArray:ty, $native:ty, $column:expr) => {{
384            let arr = $column
385                .as_any()
386                .downcast_ref::<$ArrowArray>()
387                .ok_or_else(|| OxiGdalError::Internal {
388                    message: format!(
389                        "Expected {} array, got {:?}",
390                        stringify!($ArrowArray),
391                        $column.data_type()
392                    ),
393                })?;
394            let mut bytes = Vec::with_capacity(arr.len() * core::mem::size_of::<$native>());
395            for i in 0..arr.len() {
396                let v: $native = if arr.is_null(i) {
397                    <$native as Default>::default()
398                } else {
399                    arr.value(i)
400                };
401                bytes.extend_from_slice(&v.to_ne_bytes());
402            }
403            Ok(bytes)
404        }};
405    }
406
407    match data_type {
408        RasterDataType::UInt8 => downcast_to_bytes!(UInt8Array, u8, column),
409        RasterDataType::Int8 => downcast_to_bytes!(Int8Array, i8, column),
410        RasterDataType::UInt16 => downcast_to_bytes!(UInt16Array, u16, column),
411        RasterDataType::Int16 => downcast_to_bytes!(Int16Array, i16, column),
412        RasterDataType::UInt32 => downcast_to_bytes!(UInt32Array, u32, column),
413        RasterDataType::Int32 => downcast_to_bytes!(Int32Array, i32, column),
414        RasterDataType::UInt64 => downcast_to_bytes!(UInt64Array, u64, column),
415        RasterDataType::Int64 => downcast_to_bytes!(Int64Array, i64, column),
416        RasterDataType::Float32 => downcast_to_bytes!(Float32Array, f32, column),
417        RasterDataType::Float64 => downcast_to_bytes!(Float64Array, f64, column),
418        RasterDataType::CFloat32 | RasterDataType::CFloat64 => Err(OxiGdalError::NotSupported {
419            operation: format!("Arrow conversion of complex type {}", data_type.name()),
420        }),
421    }
422}
423
424// ─── Tests ───────────────────────────────────────────────────────────────────
425
426#[cfg(test)]
427mod tests {
428    #![allow(clippy::expect_used)]
429
430    use super::*;
431    use crate::buffer::RasterBuffer;
432    use crate::types::{NoDataValue, RasterDataType};
433
434    // Helper: create a simple 4×4 UInt8 buffer filled with sequential values 0..15
435    fn make_u8_4x4() -> RasterBuffer {
436        let data: Vec<u8> = (0u8..16).collect();
437        RasterBuffer::new(data, 4, 4, RasterDataType::UInt8, NoDataValue::None)
438            .expect("valid buffer")
439    }
440
441    // Helper: create a 2×3 Float32 buffer filled with distinct values
442    fn make_f32_2x3() -> RasterBuffer {
443        let data: Vec<f32> = (0..6).map(|i| i as f32 * 1.5_f32).collect();
444        let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
445        RasterBuffer::new(bytes, 2, 3, RasterDataType::Float32, NoDataValue::None)
446            .expect("valid buffer")
447    }
448
449    // Helper: create a Float64 buffer with known values
450    fn make_f64_2x2() -> RasterBuffer {
451        let data: Vec<f64> = vec![1.1, 2.2, 3.3, 4.4];
452        let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
453        RasterBuffer::new(bytes, 2, 2, RasterDataType::Float64, NoDataValue::None)
454            .expect("valid buffer")
455    }
456
457    // ── Forward conversion tests ──────────────────────────────────────────────
458
459    /// TryFrom<&RasterBuffer> for RecordBatch: 4×4 UInt8 buffer
460    #[test]
461    fn test_raster_buffer_to_record_batch_u8() {
462        let buf = make_u8_4x4();
463        let batch = RecordBatch::try_from(&buf).expect("conversion should succeed");
464
465        // Row count = width * height
466        assert_eq!(batch.num_rows(), 16);
467        assert_eq!(batch.num_columns(), 1);
468
469        // Column type
470        assert_eq!(batch.schema().field(0).data_type(), &DataType::UInt8);
471        assert_eq!(batch.schema().field(0).name(), "pixel_values");
472
473        // Spot-check values
474        let col = batch
475            .column(0)
476            .as_any()
477            .downcast_ref::<UInt8Array>()
478            .expect("UInt8Array");
479        for i in 0u8..16 {
480            assert_eq!(col.value(i as usize), i, "value at index {i}");
481            assert!(!col.is_null(i as usize));
482        }
483    }
484
485    /// TryFrom<&RasterBuffer> for RecordBatch: schema metadata carries correct width/height
486    #[test]
487    fn test_raster_buffer_to_record_batch_f32_metadata() {
488        let buf = make_f32_2x3();
489        let batch = RecordBatch::try_from(&buf).expect("conversion should succeed");
490
491        let meta = batch.schema().metadata().clone();
492        assert_eq!(meta.get("width").map(String::as_str), Some("2"));
493        assert_eq!(meta.get("height").map(String::as_str), Some("3"));
494        assert_eq!(meta.get("data_type").map(String::as_str), Some("Float32"));
495
496        // 2 * 3 rows
497        assert_eq!(batch.num_rows(), 6);
498        assert_eq!(batch.schema().field(0).data_type(), &DataType::Float32);
499    }
500
501    /// Roundtrip: RasterBuffer → RecordBatch → RasterBuffer preserves f64 pixels
502    #[test]
503    fn test_record_batch_roundtrip_f64() {
504        let original = make_f64_2x2();
505        let batch = RecordBatch::try_from(&original).expect("forward conversion");
506        let recovered = RasterBuffer::try_from(batch).expect("reverse conversion");
507
508        assert_eq!(recovered.width(), original.width());
509        assert_eq!(recovered.height(), original.height());
510        assert_eq!(recovered.data_type(), RasterDataType::Float64);
511
512        for y in 0..original.height() {
513            for x in 0..original.width() {
514                let orig_val = original.get_pixel(x, y).expect("get_pixel original");
515                let rcvd_val = recovered.get_pixel(x, y).expect("get_pixel recovered");
516                assert!(
517                    (orig_val - rcvd_val).abs() < f64::EPSILON,
518                    "pixel ({x},{y}): expected {orig_val}, got {rcvd_val}"
519                );
520            }
521        }
522    }
523
524    /// NoData pixels are emitted as Arrow nulls
525    #[test]
526    fn test_nodata_becomes_null() {
527        // 3×1 Float32 buffer: values = [0.0, 1.5, 0.0], nodata = 0.0
528        let data_f32: Vec<f32> = vec![0.0_f32, 1.5_f32, 0.0_f32];
529        let bytes: Vec<u8> = data_f32.iter().flat_map(|v| v.to_ne_bytes()).collect();
530        let buf = RasterBuffer::new(
531            bytes,
532            3,
533            1,
534            RasterDataType::Float32,
535            NoDataValue::Float(0.0),
536        )
537        .expect("valid buffer");
538
539        let batch = RecordBatch::try_from(&buf).expect("conversion should succeed");
540        let col = batch
541            .column(0)
542            .as_any()
543            .downcast_ref::<Float32Array>()
544            .expect("Float32Array");
545
546        assert_eq!(col.len(), 3);
547        assert!(col.is_null(0), "pixel 0 (nodata) should be null");
548        assert!(!col.is_null(1), "pixel 1 (1.5) should not be null");
549        assert!(col.is_null(2), "pixel 2 (nodata) should be null");
550        assert!((col.value(1) - 1.5_f32).abs() < f32::EPSILON);
551    }
552
553    // ── Error-path tests ──────────────────────────────────────────────────────
554
555    /// CFloat32 buffer → TryFrom returns NotSupported error
556    #[test]
557    fn test_complex_type_returns_error() {
558        // CFloat32 is 8 bytes per pixel (real + imaginary, each f32)
559        let buf = RasterBuffer::zeros(2, 2, RasterDataType::CFloat32);
560        let result = RecordBatch::try_from(&buf);
561
562        assert!(result.is_err(), "CFloat32 conversion should fail");
563        let err = result.expect_err("expected error");
564        assert!(
565            matches!(err, OxiGdalError::NotSupported { .. }),
566            "expected NotSupported, got {err:?}"
567        );
568    }
569
570    /// CFloat64 buffer → TryFrom returns NotSupported error
571    #[test]
572    fn test_complex_type_cfloat64_returns_error() {
573        let buf = RasterBuffer::zeros(2, 2, RasterDataType::CFloat64);
574        let result = RecordBatch::try_from(&buf);
575
576        assert!(result.is_err());
577        assert!(matches!(
578            result.expect_err("expected error"),
579            OxiGdalError::NotSupported { .. }
580        ));
581    }
582
583    // ── Reverse conversion error tests ───────────────────────────────────────
584
585    /// RecordBatch without required schema metadata → Err(InvalidParameter)
586    #[test]
587    fn test_record_batch_to_buffer_wrong_schema_missing_metadata() {
588        // Build a minimal RecordBatch with no metadata
589        let field = Field::new("pixel_values", DataType::UInt8, false);
590        let schema = Arc::new(Schema::new(vec![field]));
591        let array: ArrayRef = Arc::new(UInt8Array::from(vec![1u8, 2, 3, 4]));
592        let batch = RecordBatch::try_new(schema, vec![array]).expect("valid RecordBatch for test");
593
594        let result = RasterBuffer::try_from(batch);
595        assert!(result.is_err(), "missing metadata should fail");
596        assert!(
597            matches!(
598                result.expect_err("expected error"),
599                OxiGdalError::InvalidParameter { .. }
600            ),
601            "expected InvalidParameter error"
602        );
603    }
604
605    /// RecordBatch with wrong column name → Err(InvalidParameter)
606    #[test]
607    fn test_record_batch_to_buffer_wrong_column_name() {
608        let mut metadata = HashMap::new();
609        metadata.insert("width".to_string(), "2".to_string());
610        metadata.insert("height".to_string(), "2".to_string());
611        metadata.insert("data_type".to_string(), "UInt8".to_string());
612
613        let field = Field::new("wrong_name", DataType::UInt8, false);
614        let schema = Arc::new(Schema::new_with_metadata(vec![field], metadata));
615        let array: ArrayRef = Arc::new(UInt8Array::from(vec![1u8, 2, 3, 4]));
616        let batch = RecordBatch::try_new(schema, vec![array]).expect("valid RecordBatch for test");
617
618        let result = RasterBuffer::try_from(batch);
619        assert!(result.is_err());
620        assert!(matches!(
621            result.expect_err("expected error"),
622            OxiGdalError::InvalidParameter { .. }
623        ));
624    }
625
626    /// RecordBatch with too many columns → Err(InvalidParameter)
627    #[test]
628    fn test_record_batch_to_buffer_too_many_columns() {
629        let mut metadata = HashMap::new();
630        metadata.insert("width".to_string(), "2".to_string());
631        metadata.insert("height".to_string(), "2".to_string());
632        metadata.insert("data_type".to_string(), "UInt8".to_string());
633
634        let field1 = Field::new("pixel_values", DataType::UInt8, false);
635        let field2 = Field::new("extra_column", DataType::UInt8, false);
636        let schema = Arc::new(Schema::new_with_metadata(vec![field1, field2], metadata));
637        let array1: ArrayRef = Arc::new(UInt8Array::from(vec![1u8, 2, 3, 4]));
638        let array2: ArrayRef = Arc::new(UInt8Array::from(vec![5u8, 6, 7, 8]));
639        let batch =
640            RecordBatch::try_new(schema, vec![array1, array2]).expect("valid RecordBatch for test");
641
642        let result = RasterBuffer::try_from(batch);
643        assert!(result.is_err());
644        assert!(matches!(
645            result.expect_err("expected error"),
646            OxiGdalError::InvalidParameter { .. }
647        ));
648    }
649
650    // ── Additional type coverage ──────────────────────────────────────────────
651
652    /// Int16 buffer roundtrip
653    #[test]
654    fn test_roundtrip_i16() {
655        let data: Vec<i16> = vec![-1000_i16, 0, 1000, i16::MAX];
656        let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
657        let buf = RasterBuffer::new(bytes, 2, 2, RasterDataType::Int16, NoDataValue::None)
658            .expect("valid buffer");
659
660        let batch = RecordBatch::try_from(&buf).expect("forward");
661        let recovered = RasterBuffer::try_from(batch).expect("reverse");
662
663        assert_eq!(recovered.data_type(), RasterDataType::Int16);
664        for y in 0..2u64 {
665            for x in 0..2u64 {
666                let orig = buf.get_pixel(x, y).expect("orig pixel");
667                let rcvd = recovered.get_pixel(x, y).expect("rcvd pixel");
668                assert!((orig - rcvd).abs() < f64::EPSILON);
669            }
670        }
671    }
672
673    /// UInt64 buffer roundtrip
674    #[test]
675    fn test_roundtrip_u64() {
676        let data: Vec<u64> = vec![0u64, 1, u64::MAX / 2, 1_000_000];
677        let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
678        let buf = RasterBuffer::new(bytes, 2, 2, RasterDataType::UInt64, NoDataValue::None)
679            .expect("valid buffer");
680
681        let batch = RecordBatch::try_from(&buf).expect("forward");
682        let recovered = RasterBuffer::try_from(batch).expect("reverse");
683
684        assert_eq!(recovered.data_type(), RasterDataType::UInt64);
685        // Check raw bytes match (u64 is wider than f64 precision allows exact f64 comparison)
686        assert_eq!(recovered.as_bytes(), buf.as_bytes());
687    }
688
689    /// Integer nodata (NoDataValue::Integer) is handled correctly
690    #[test]
691    fn test_integer_nodata_becomes_null() {
692        // 1×4 Int32 buffer, nodata = -9999
693        let data: Vec<i32> = vec![-9999_i32, 100, -9999, 200];
694        let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
695        let buf = RasterBuffer::new(
696            bytes,
697            4,
698            1,
699            RasterDataType::Int32,
700            NoDataValue::Integer(-9999),
701        )
702        .expect("valid buffer");
703
704        let batch = RecordBatch::try_from(&buf).expect("conversion");
705        let col = batch
706            .column(0)
707            .as_any()
708            .downcast_ref::<Int32Array>()
709            .expect("Int32Array");
710
711        assert!(col.is_null(0), "index 0 (-9999) should be null");
712        assert!(!col.is_null(1), "index 1 (100) should not be null");
713        assert!(col.is_null(2), "index 2 (-9999) should be null");
714        assert!(!col.is_null(3), "index 3 (200) should not be null");
715        assert_eq!(col.value(1), 100);
716        assert_eq!(col.value(3), 200);
717    }
718}