From 12847c6c66b149b1bdc1f8740bfe7026dd4337e4 Mon Sep 17 00:00:00 2001 From: osobh Date: Sat, 19 Sep 2026 06:35:47 -0700 Subject: [PATCH] feat(format): apply fill values to unallocated storage on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HDF5 allocates lazily: a chunk nobody wrote doesn't exist in the file, and a dataset nobody wrote has no data address. Such regions must read as the dataset's fill value. There was no Fill Value message parser at all, so: - a sparse chunked dataset read its holes as zeros — silently wrong whenever the fill value isn't zero (h5py `fillvalue=-1` came back as 0); - a dataset that was created but never written failed with NoDataAllocated / "no address for chunked layout" where h5py returns a filled array. New clawhdf5_format::fill_value: parses Fill Value messages v1-v3 and the old 0x0004 message (validated against HDF5 2.0 output under default and latest libver), builds a fully filled dataset when there is no storage, and writes the fill value into exactly the chunk-grid cells absent from the chunk index — never mistaking a stored zero for a hole, clipping edge chunks, any rank. It is skipped entirely for the default (zero) fill value. The chunk index dispatch is extracted from read_chunked_data into a reusable list_chunks. The reader, lazy and mmap facades apply it on full reads; selection reads go through a fill-aware full read when the fill value matters. h5py interop test compares against h5py's own readback, including a sparse 2-D dataset and a hyperslab straddling allocated and unallocated chunks. Co-Authored-By: Claude Fable 5.1 --- crates/clawhdf5-format/src/chunked_read.rs | 36 +- crates/clawhdf5-format/src/data_read.rs | 2 +- crates/clawhdf5-format/src/fill_value.rs | 398 ++++++++++++++++++++ crates/clawhdf5-format/src/lib.rs | 1 + crates/clawhdf5/src/lazy.rs | 20 +- crates/clawhdf5/src/mmap_file.rs | 20 +- crates/clawhdf5/src/reader.rs | 40 +- crates/clawhdf5/tests/h5py_interop_tests.rs | 85 +++++ 8 files changed, 582 insertions(+), 20 deletions(-) create mode 100644 crates/clawhdf5-format/src/fill_value.rs diff --git a/crates/clawhdf5-format/src/chunked_read.rs b/crates/clawhdf5-format/src/chunked_read.rs index edb1705..8ba9332 100644 --- a/crates/clawhdf5-format/src/chunked_read.rs +++ b/crates/clawhdf5-format/src/chunked_read.rs @@ -362,15 +362,17 @@ pub fn generate_implicit_chunks( } /// Read a chunked dataset, decompressing chunks as needed. -pub fn read_chunked_data( +/// Every allocated chunk of a chunked dataset, for any supported chunk index, +/// plus the spatial chunk dimensions. Chunks the file never allocated (sparse +/// datasets) are simply absent from the list. +pub fn list_chunks( file_data: &[u8], layout: &DataLayout, dataspace: &Dataspace, - datatype: &Datatype, - pipeline: Option<&FilterPipeline>, + elem_size: usize, offset_size: u8, length_size: u8, -) -> Result, FormatError> { +) -> Result<(Vec, Vec), FormatError> { let ( chunk_dimensions, version, @@ -404,8 +406,6 @@ pub fn read_chunked_data( let addr = addr_opt .ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?; - let elem_size = datatype.type_size() as usize; - // Both v3 and v4 include element size as last dim (rank+1) let ndims = chunk_dimensions.len(); let rank = ndims @@ -494,6 +494,30 @@ pub fn read_chunked_data( } }; + Ok((chunks, chunk_dims)) +} + +pub fn read_chunked_data( + file_data: &[u8], + layout: &DataLayout, + dataspace: &Dataspace, + datatype: &Datatype, + pipeline: Option<&FilterPipeline>, + offset_size: u8, + length_size: u8, +) -> Result, FormatError> { + let elem_size = datatype.type_size() as usize; + let (chunks, chunk_dims) = list_chunks( + file_data, + layout, + dataspace, + elem_size, + offset_size, + length_size, + )?; + let rank = chunk_dims.len(); + let ds_dims: Vec = dataspace.dimensions.iter().map(|&d| d as usize).collect(); + // Assemble output let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?; if total_bytes == 0 { diff --git a/crates/clawhdf5-format/src/data_read.rs b/crates/clawhdf5-format/src/data_read.rs index 9f33175..e6c4271 100644 --- a/crates/clawhdf5-format/src/data_read.rs +++ b/crates/clawhdf5-format/src/data_read.rs @@ -600,7 +600,7 @@ fn read_named_dataset_raw( } /// Extract selected elements from a full dataset buffer. -fn extract_selection_from_buffer( +pub fn extract_selection_from_buffer( full_data: &[u8], dims: &[u64], elem_size: usize, diff --git a/crates/clawhdf5-format/src/fill_value.rs b/crates/clawhdf5-format/src/fill_value.rs new file mode 100644 index 0000000..f1b0c3b --- /dev/null +++ b/crates/clawhdf5-format/src/fill_value.rs @@ -0,0 +1,398 @@ +//! Fill Value messages (0x0005, and the old 0x0004) and applying them on read. +//! +//! HDF5 allocates storage lazily: a chunk nobody wrote to does not exist in the +//! file, and a contiguous dataset nobody wrote to has no data address at all. +//! Reading such a region must yield the dataset's *fill value* (zeros unless +//! the creator chose otherwise). The readers in [`crate::chunked_read`] leave +//! those regions zeroed; [`apply_to_unallocated_chunks`] then overwrites exactly +//! the chunk-grid cells that are absent from the chunk index — so it can never +//! mistake a stored zero for a hole — and is skipped entirely in the common +//! case of a zero fill value. + +#[cfg(not(feature = "std"))] +use alloc::{format, vec, vec::Vec}; + +use crate::chunked_read::{alloc_output, checked_byte_len, list_chunks}; +use crate::data_layout::DataLayout; +use crate::dataspace::Dataspace; +use crate::error::FormatError; +use crate::message_type::MessageType; +use crate::object_header::HeaderMessage; + +/// Largest fill value accepted. A fill value is one element of the dataset's +/// datatype; this only bounds the allocation driven by the message's size field. +const MAX_FILL_VALUE_SIZE: usize = 1 << 20; + +/// Parse a Fill Value message, returning the user-defined fill value bytes, or +/// `None` when the dataset uses the default (all zeros) or has the fill value +/// explicitly undefined. +pub fn parse_fill_value(msg: &HeaderMessage) -> Result>, FormatError> { + let data = msg.data.as_slice(); + let value_at = |pos: usize| -> Result>, FormatError> { + let size_bytes = data.get(pos..pos + 4).ok_or(FormatError::UnexpectedEof { + expected: pos + 4, + available: data.len(), + })?; + let size = u32::from_le_bytes([size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]]) + as usize; + if size == 0 { + return Ok(None); + } + if size > MAX_FILL_VALUE_SIZE { + return Err(FormatError::Overflow(format!( + "fill value of {size} bytes exceeds the {MAX_FILL_VALUE_SIZE}-byte limit" + ))); + } + let start = pos + 4; + let value = + data.get(start..start.saturating_add(size)) + .ok_or(FormatError::UnexpectedEof { + expected: start.saturating_add(size), + available: data.len(), + })?; + Ok(Some(value.to_vec())) + }; + + match msg.msg_type { + // Old fill value message: size(4), value. + MessageType::FillValueOld => value_at(0), + MessageType::FillValue => { + let version = *data.first().ok_or(FormatError::UnexpectedEof { + expected: 1, + available: 0, + })?; + match version { + // version, alloc time, write time, defined, [size, value] + 1 | 2 => { + let defined = *data.get(3).ok_or(FormatError::UnexpectedEof { + expected: 4, + available: data.len(), + })?; + if version == 2 && defined == 0 { + Ok(None) + } else if data.len() < 8 && version == 1 { + // v1 always carries a size, but tolerate its absence. + Ok(None) + } else { + value_at(4) + } + } + // version, flags (bit 4 = undefined, bit 5 = defined), [size, value] + 3 => { + let flags = *data.get(1).ok_or(FormatError::UnexpectedEof { + expected: 2, + available: data.len(), + })?; + if flags & 0x10 != 0 || flags & 0x20 == 0 { + Ok(None) + } else { + value_at(2) + } + } + v => Err(FormatError::UnsupportedVersion(v)), + } + } + _ => Ok(None), + } +} + +/// The fill value that applies to a dataset given its header messages. The new +/// message wins over the old one when both are present. +pub fn dataset_fill_value(messages: &[HeaderMessage]) -> Result>, FormatError> { + for wanted in [MessageType::FillValue, MessageType::FillValueOld] { + if let Some(msg) = messages.iter().find(|m| m.msg_type == wanted) { + if crate::shared_message::is_shared(msg.flags) { + // A shared fill value is legal but vanishingly rare; treat it + // as the default rather than misparsing the reference. + return Ok(None); + } + if let Some(value) = parse_fill_value(msg)? { + return Ok(Some(value)); + } + } + } + Ok(None) +} + +/// `true` when a fill value is absent or all zeros, i.e. identical to what the +/// readers already produce for unallocated storage. +pub fn is_default(fill: Option<&[u8]>) -> bool { + fill.is_none_or(|f| f.iter().all(|&b| b == 0)) +} + +/// A whole dataset's worth of fill value: what reading a dataset with no +/// allocated storage at all must return. +pub fn filled_dataset( + dataspace: &Dataspace, + elem_size: usize, + fill: Option<&[u8]>, +) -> Result, FormatError> { + let total = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?; + let mut out = alloc_output(total)?; + if let Some(fill) = fill.filter(|f| f.len() == elem_size && !is_default(Some(f))) { + for element in out.chunks_exact_mut(elem_size) { + element.copy_from_slice(fill); + } + } + Ok(out) +} + +/// Whether the layout has any storage in the file at all. A dataset that was +/// created but never written to has none. +pub fn has_storage(layout: &DataLayout) -> bool { + !matches!( + layout, + DataLayout::Contiguous { address: None, .. } + | DataLayout::Chunked { + btree_address: None, + .. + } + ) +} + +/// Run a full-dataset `read`, giving unallocated storage its fill value: a +/// dataset with no storage at all reads as entirely fill value (instead of +/// failing), and a chunked dataset has the fill value written into every +/// chunk the file never allocated. +#[allow(clippy::too_many_arguments)] +pub fn read_full_with_fill>( + messages: &[HeaderMessage], + file_data: &[u8], + layout: &DataLayout, + dataspace: &Dataspace, + elem_size: usize, + offset_size: u8, + length_size: u8, + read: impl FnOnce() -> Result, E>, +) -> Result, E> { + let fill = dataset_fill_value(messages)?; + if !has_storage(layout) { + return Ok(filled_dataset(dataspace, elem_size, fill.as_deref())?); + } + let mut output = read()?; + apply_to_unallocated_chunks( + &mut output, + file_data, + layout, + dataspace, + elem_size, + fill.as_deref(), + offset_size, + length_size, + )?; + Ok(output) +} + +/// Overwrite, in a fully read chunked dataset `output`, every region whose +/// chunk was never allocated with `fill`. No-op for non-chunked layouts, a +/// default fill value, or a fill value whose size doesn't match the element. +#[allow(clippy::too_many_arguments)] +pub fn apply_to_unallocated_chunks( + output: &mut [u8], + file_data: &[u8], + layout: &DataLayout, + dataspace: &Dataspace, + elem_size: usize, + fill: Option<&[u8]>, + offset_size: u8, + length_size: u8, +) -> Result<(), FormatError> { + let Some(fill) = fill.filter(|f| f.len() == elem_size && !is_default(Some(f))) else { + return Ok(()); + }; + if !matches!(layout, DataLayout::Chunked { .. }) || elem_size == 0 { + return Ok(()); + } + let (chunks, chunk_dims) = list_chunks( + file_data, + layout, + dataspace, + elem_size, + offset_size, + length_size, + )?; + let rank = chunk_dims.len(); + let ds_dims: Vec = dataspace.dimensions.iter().map(|&d| d as usize).collect(); + if rank == 0 || ds_dims.len() != rank || chunk_dims.contains(&0) { + return Ok(()); + } + + // Row-major strides over the dataset and over the chunk grid. + let mut ds_strides = vec![1usize; rank]; + for i in (0..rank - 1).rev() { + ds_strides[i] = ds_strides[i + 1].saturating_mul(ds_dims[i + 1]); + } + let grid: Vec = ds_dims + .iter() + .zip(&chunk_dims) + .map(|(&d, &c)| d.div_ceil(c)) + .collect(); + let cells = grid + .iter() + .try_fold(1usize, |acc, &g| acc.checked_mul(g)) + .ok_or_else(|| FormatError::Overflow("chunk grid size overflows".into()))?; + if cells == 0 { + return Ok(()); + } + + let mut allocated = vec![false; cells]; + for chunk in &chunks { + // Undefined address: the index has a slot for the chunk but no storage. + if chunk.address == u64::MAX || chunk.offsets.len() < rank { + continue; + } + let mut cell = 0usize; + let mut in_range = true; + for d in 0..rank { + let coord = chunk.offsets[d] as usize / chunk_dims[d]; + if coord >= grid[d] { + in_range = false; + break; + } + cell = cell * grid[d] + coord; + } + if in_range { + allocated[cell] = true; + } + } + + let mut coord = vec![0usize; rank]; + for (cell, is_allocated) in allocated.iter().enumerate() { + if *is_allocated { + continue; + } + // Decode the cell index into grid coordinates. + let mut rem = cell; + for d in (0..rank).rev() { + coord[d] = rem % grid[d]; + rem /= grid[d]; + } + fill_cell( + output, + &coord, + &chunk_dims, + &ds_dims, + &ds_strides, + elem_size, + fill, + ); + } + Ok(()) +} + +/// Fill the part of chunk-grid cell `coord` that lies inside the dataset. +fn fill_cell( + output: &mut [u8], + coord: &[usize], + chunk_dims: &[usize], + ds_dims: &[usize], + ds_strides: &[usize], + elem_size: usize, + fill: &[u8], +) { + let rank = coord.len(); + let start: Vec = (0..rank).map(|d| coord[d] * chunk_dims[d]).collect(); + let end: Vec = (0..rank) + .map(|d| (start[d] + chunk_dims[d]).min(ds_dims[d])) + .collect(); + if (0..rank).any(|d| start[d] >= end[d]) { + return; + } + // Walk every row (all dims but the last) and fill the run along the last. + let run = end[rank - 1] - start[rank - 1]; + let mut idx = start.clone(); + loop { + let first: usize = (0..rank).map(|d| idx[d] * ds_strides[d]).sum(); + let from = first * elem_size; + let to = from + run * elem_size; + if let Some(region) = output.get_mut(from..to) { + for element in region.chunks_exact_mut(elem_size) { + element.copy_from_slice(fill); + } + } + // Advance the odometer over dims 0..rank-1. + let mut d = rank - 1; + loop { + if d == 0 { + return; + } + d -= 1; + idx[d] += 1; + if idx[d] < end[d] { + break; + } + idx[d] = start[d]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(msg_type: MessageType, data: &[u8]) -> HeaderMessage { + HeaderMessage { + msg_type, + size: data.len(), + flags: 0, + creation_order: None, + data: data.to_vec(), + } + } + + #[test] + fn parses_v3_defined_undefined_and_default() { + // Real message for h5py `fillvalue=-1` on an i4 dataset (HDF5 2.0). + let defined = msg( + MessageType::FillValue, + &[3, 0x2b, 4, 0, 0, 0, 0xff, 0xff, 0xff, 0xff], + ); + assert_eq!(parse_fill_value(&defined).unwrap(), Some(vec![0xff; 4])); + let default = msg(MessageType::FillValue, &[3, 0x0a]); + assert_eq!(parse_fill_value(&default).unwrap(), None); + let undefined = msg(MessageType::FillValue, &[3, 0x19]); + assert_eq!(parse_fill_value(&undefined).unwrap(), None); + } + + #[test] + fn parses_v2_and_old_messages() { + let v2 = msg(MessageType::FillValue, &[2, 2, 2, 1, 2, 0, 0, 0, 7, 0]); + assert_eq!(parse_fill_value(&v2).unwrap(), Some(vec![7, 0])); + let v2_undefined = msg(MessageType::FillValue, &[2, 2, 2, 0]); + assert_eq!(parse_fill_value(&v2_undefined).unwrap(), None); + let old = msg(MessageType::FillValueOld, &[2, 0, 0, 0, 9, 9]); + assert_eq!(parse_fill_value(&old).unwrap(), Some(vec![9, 9])); + } + + #[test] + fn truncated_or_oversized_fill_is_an_error() { + let short = msg(MessageType::FillValue, &[3, 0x29, 4, 0, 0, 0, 0xff]); + assert!(parse_fill_value(&short).is_err()); + let huge = msg(MessageType::FillValue, &[3, 0x29, 0xff, 0xff, 0xff, 0x7f]); + assert!(matches!( + parse_fill_value(&huge), + Err(FormatError::Overflow(_)) + )); + } + + #[test] + fn fill_cell_clips_edge_chunks_in_2d() { + // 3x5 dataset, 2x2 chunks; fill grid cell (1, 2): rows 2..3, cols 4..5. + let mut out = vec![0u8; 15]; + fill_cell(&mut out, &[1, 2], &[2, 2], &[3, 5], &[5, 1], 1, &[9]); + let mut expected = vec![0u8; 15]; + expected[2 * 5 + 4] = 9; + assert_eq!(out, expected); + + // Interior cell (0, 1): rows 0..2, cols 2..4. + let mut out = vec![0u8; 15]; + fill_cell(&mut out, &[0, 1], &[2, 2], &[3, 5], &[5, 1], 1, &[7]); + let filled: Vec = out + .iter() + .enumerate() + .filter(|(_, b)| **b == 7) + .map(|(i, _)| i) + .collect(); + assert_eq!(filled, [2, 3, 7, 8]); + } +} diff --git a/crates/clawhdf5-format/src/lib.rs b/crates/clawhdf5-format/src/lib.rs index ae5623d..dae44b6 100644 --- a/crates/clawhdf5-format/src/lib.rs +++ b/crates/clawhdf5-format/src/lib.rs @@ -67,6 +67,7 @@ pub mod ea_writer; pub mod error; pub mod extensible_array; pub mod file_writer; +pub mod fill_value; pub mod filter_pipeline; pub mod filters; mod filters_szip; diff --git a/crates/clawhdf5/src/lazy.rs b/crates/clawhdf5/src/lazy.rs index 4484a4c..4a7421e 100644 --- a/crates/clawhdf5/src/lazy.rs +++ b/crates/clawhdf5/src/lazy.rs @@ -480,15 +480,27 @@ impl<'f, R: HDF5Read> LazyDataset<'f, R> { let dl = self.data_layout()?; let pipeline = self.filter_pipeline()?; let data = self.file.reader.as_bytes(); - Ok(data_read::read_raw_data_full( + // Unallocated storage reads as the dataset's fill value. + clawhdf5_format::fill_value::read_full_with_fill( + &self.header.messages, data, &dl, &ds, - &dt, - pipeline.as_ref(), + dt.type_size() as usize, self.file.offset_size(), self.file.length_size(), - )?) + || { + Ok(data_read::read_raw_data_full( + data, + &dl, + &ds, + &dt, + pipeline.as_ref(), + self.file.offset_size(), + self.file.length_size(), + )?) + }, + ) } } diff --git a/crates/clawhdf5/src/mmap_file.rs b/crates/clawhdf5/src/mmap_file.rs index c1886a4..9119cdf 100644 --- a/crates/clawhdf5/src/mmap_file.rs +++ b/crates/clawhdf5/src/mmap_file.rs @@ -420,15 +420,27 @@ impl<'f> MmapDataset<'f> { let ds = self.dataspace()?; let dl = self.data_layout()?; let pipeline = self.filter_pipeline()?; - Ok(data_read::read_raw_data_full( + // Unallocated storage reads as the dataset's fill value. + clawhdf5_format::fill_value::read_full_with_fill( + &self.header.messages, self.file.reader.as_bytes(), &dl, &ds, - &dt, - pipeline.as_ref(), + dt.type_size() as usize, self.file.offset_size(), self.file.length_size(), - )?) + || { + Ok(data_read::read_raw_data_full( + self.file.reader.as_bytes(), + &dl, + &ds, + &dt, + pipeline.as_ref(), + self.file.offset_size(), + self.file.length_size(), + )?) + }, + ) } } diff --git a/crates/clawhdf5/src/reader.rs b/crates/clawhdf5/src/reader.rs index 4bbe0cd..4c9da19 100644 --- a/crates/clawhdf5/src/reader.rs +++ b/crates/clawhdf5/src/reader.rs @@ -448,6 +448,24 @@ impl<'f> Dataset<'f> { let ds = self.dataspace()?; let dl = self.data_layout()?; let pipeline = self.filter_pipeline()?; + // The selection reader knows nothing about fill values. When they + // matter — no storage at all, or a non-zero fill on a chunked (possibly + // sparse) dataset — select from a fill-aware full read instead. (The + // selection reader currently decodes the full dataset too, so this + // costs nothing extra.) + let fill = clawhdf5_format::fill_value::dataset_fill_value(&self.header.messages)?; + let fill_matters = !clawhdf5_format::fill_value::has_storage(&dl) + || (matches!(dl, DataLayout::Chunked { .. }) + && !clawhdf5_format::fill_value::is_default(fill.as_deref())); + if fill_matters { + let full = self.read_raw()?; + return Ok(data_read::extract_selection_from_buffer( + &full, + &ds.dimensions, + dt.type_size() as usize, + selection, + )?); + } Ok(data_read::read_raw_data_selection( self.file.data.as_bytes(), &dl, @@ -808,16 +826,28 @@ impl<'f> Dataset<'f> { )?); } - Ok(data_read::read_raw_data_cached( + // Unallocated storage reads as the dataset's fill value. + clawhdf5_format::fill_value::read_full_with_fill( + &self.header.messages, self.file.data.as_bytes(), &dl, &ds, - &dt, - pipeline.as_ref(), + dt.type_size() as usize, self.file.offset_size(), self.file.length_size(), - &self.file.chunk_cache, - )?) + || { + Ok(data_read::read_raw_data_cached( + self.file.data.as_bytes(), + &dl, + &ds, + &dt, + pipeline.as_ref(), + self.file.offset_size(), + self.file.length_size(), + &self.file.chunk_cache, + )?) + }, + ) } } diff --git a/crates/clawhdf5/tests/h5py_interop_tests.rs b/crates/clawhdf5/tests/h5py_interop_tests.rs index a7154fd..ef6d01a 100644 --- a/crates/clawhdf5/tests/h5py_interop_tests.rs +++ b/crates/clawhdf5/tests/h5py_interop_tests.rs @@ -614,3 +614,88 @@ with h5py.File("{path_str}", "w"{kwargs}) as f: ); } } + +// --------------------------------------------------------------------------- +// h5py writes sparse / never-written datasets -> clawhdf5 applies fill values +// --------------------------------------------------------------------------- + +/// Parse h5py's `print(arr.ravel().tolist())` output for integer data. +fn parse_int_list(s: &str) -> Vec { + s.trim() + .trim_matches(|c| c == '[' || c == ']') + .split(',') + .filter(|t| !t.trim().is_empty()) + .map(|t| t.trim().parse().unwrap()) + .collect() +} + +/// Storage HDF5 never allocated must read as the dataset's fill value. These +/// used to read as zeros (silently wrong for a non-zero fill value) or fail +/// outright (`NoDataAllocated`) for a dataset that was never written. +#[test] +fn h5py_fill_values_clawhdf5_reads() { + skip_if_no_python!(); + let dir = tempfile::tempdir().unwrap(); + for (tag, kwargs) in [("default", ""), ("latest", ", libver='latest'")] { + let path = dir.path().join(format!("fill_{tag}.h5")); + let path_str = path.display().to_string(); + let script = format!( + r#" +import h5py, numpy as np +with h5py.File("{path_str}", "w"{kwargs}) as f: + d = f.create_dataset("partial", shape=(20,), dtype="> = run_python_output(&script) + .lines() + .map(|line| { + let (name, list) = line.split_once(' ').unwrap(); + (name.to_string(), parse_int_list(list)) + }) + .collect(); + + let file = File::open(&path).unwrap(); + for name in [ + "partial", + "never", + "never_chunked", + "default_fill", + "gz", + "sparse2d", + ] { + assert_eq!( + file.dataset(name).unwrap().read_i32().unwrap(), + expected[name], + "{tag}/{name}" + ); + } + // A hyperslab straddling allocated and unallocated chunks. + let slab = clawhdf5_format::selection::Selection::Hyperslab { + start: vec![1, 2], + stride: vec![1, 1], + count: vec![4, 5], + block: vec![1, 1], + }; + assert_eq!( + file.dataset("sparse2d") + .unwrap() + .read_i32_selection(&slab) + .unwrap(), + expected["slab"], + "{tag}/sparse2d hyperslab" + ); + } +}