feat(format): apply fill values to unallocated storage on read
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
81e8294048
commit
12847c6c66
@@ -362,15 +362,17 @@ pub fn generate_implicit_chunks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read a chunked dataset, decompressing chunks as needed.
|
/// 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],
|
file_data: &[u8],
|
||||||
layout: &DataLayout,
|
layout: &DataLayout,
|
||||||
dataspace: &Dataspace,
|
dataspace: &Dataspace,
|
||||||
datatype: &Datatype,
|
elem_size: usize,
|
||||||
pipeline: Option<&FilterPipeline>,
|
|
||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
) -> Result<Vec<u8>, FormatError> {
|
) -> Result<(Vec<ChunkInfo>, Vec<usize>), FormatError> {
|
||||||
let (
|
let (
|
||||||
chunk_dimensions,
|
chunk_dimensions,
|
||||||
version,
|
version,
|
||||||
@@ -404,8 +406,6 @@ pub fn read_chunked_data(
|
|||||||
let addr = addr_opt
|
let addr = addr_opt
|
||||||
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
|
.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)
|
// Both v3 and v4 include element size as last dim (rank+1)
|
||||||
let ndims = chunk_dimensions.len();
|
let ndims = chunk_dimensions.len();
|
||||||
let rank = ndims
|
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<Vec<u8>, 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<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect();
|
||||||
|
|
||||||
// Assemble output
|
// Assemble output
|
||||||
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
|
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
|
||||||
if total_bytes == 0 {
|
if total_bytes == 0 {
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ fn read_named_dataset_raw(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract selected elements from a full dataset buffer.
|
/// Extract selected elements from a full dataset buffer.
|
||||||
fn extract_selection_from_buffer(
|
pub fn extract_selection_from_buffer(
|
||||||
full_data: &[u8],
|
full_data: &[u8],
|
||||||
dims: &[u64],
|
dims: &[u64],
|
||||||
elem_size: usize,
|
elem_size: usize,
|
||||||
|
|||||||
@@ -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<Option<Vec<u8>>, FormatError> {
|
||||||
|
let data = msg.data.as_slice();
|
||||||
|
let value_at = |pos: usize| -> Result<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<Vec<u8>, 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<E: From<FormatError>>(
|
||||||
|
messages: &[HeaderMessage],
|
||||||
|
file_data: &[u8],
|
||||||
|
layout: &DataLayout,
|
||||||
|
dataspace: &Dataspace,
|
||||||
|
elem_size: usize,
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
read: impl FnOnce() -> Result<Vec<u8>, E>,
|
||||||
|
) -> Result<Vec<u8>, 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<usize> = 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<usize> = 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<usize> = (0..rank).map(|d| coord[d] * chunk_dims[d]).collect();
|
||||||
|
let end: Vec<usize> = (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<usize> = out
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, b)| **b == 7)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(filled, [2, 3, 7, 8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ pub mod ea_writer;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod extensible_array;
|
pub mod extensible_array;
|
||||||
pub mod file_writer;
|
pub mod file_writer;
|
||||||
|
pub mod fill_value;
|
||||||
pub mod filter_pipeline;
|
pub mod filter_pipeline;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
mod filters_szip;
|
mod filters_szip;
|
||||||
|
|||||||
@@ -480,15 +480,27 @@ impl<'f, R: HDF5Read> LazyDataset<'f, R> {
|
|||||||
let dl = self.data_layout()?;
|
let dl = self.data_layout()?;
|
||||||
let pipeline = self.filter_pipeline()?;
|
let pipeline = self.filter_pipeline()?;
|
||||||
let data = self.file.reader.as_bytes();
|
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,
|
data,
|
||||||
&dl,
|
&dl,
|
||||||
&ds,
|
&ds,
|
||||||
&dt,
|
dt.type_size() as usize,
|
||||||
pipeline.as_ref(),
|
|
||||||
self.file.offset_size(),
|
self.file.offset_size(),
|
||||||
self.file.length_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(),
|
||||||
|
)?)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -420,15 +420,27 @@ impl<'f> MmapDataset<'f> {
|
|||||||
let ds = self.dataspace()?;
|
let ds = self.dataspace()?;
|
||||||
let dl = self.data_layout()?;
|
let dl = self.data_layout()?;
|
||||||
let pipeline = self.filter_pipeline()?;
|
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(),
|
self.file.reader.as_bytes(),
|
||||||
&dl,
|
&dl,
|
||||||
&ds,
|
&ds,
|
||||||
&dt,
|
dt.type_size() as usize,
|
||||||
pipeline.as_ref(),
|
|
||||||
self.file.offset_size(),
|
self.file.offset_size(),
|
||||||
self.file.length_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(),
|
||||||
|
)?)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -448,6 +448,24 @@ impl<'f> Dataset<'f> {
|
|||||||
let ds = self.dataspace()?;
|
let ds = self.dataspace()?;
|
||||||
let dl = self.data_layout()?;
|
let dl = self.data_layout()?;
|
||||||
let pipeline = self.filter_pipeline()?;
|
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(
|
Ok(data_read::read_raw_data_selection(
|
||||||
self.file.data.as_bytes(),
|
self.file.data.as_bytes(),
|
||||||
&dl,
|
&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(),
|
self.file.data.as_bytes(),
|
||||||
&dl,
|
&dl,
|
||||||
&ds,
|
&ds,
|
||||||
&dt,
|
dt.type_size() as usize,
|
||||||
pipeline.as_ref(),
|
|
||||||
self.file.offset_size(),
|
self.file.offset_size(),
|
||||||
self.file.length_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,
|
||||||
|
)?)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<i32> {
|
||||||
|
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="<i4", chunks=(5,), fillvalue=-1)
|
||||||
|
d[0:5] = np.arange(5)
|
||||||
|
f.create_dataset("never", shape=(4,), dtype="<i4", fillvalue=25)
|
||||||
|
f.create_dataset("never_chunked", shape=(6,), dtype="<i4", chunks=(3,), fillvalue=9)
|
||||||
|
f.create_dataset("default_fill", shape=(3,), dtype="<i4")
|
||||||
|
g = f.create_dataset("gz", shape=(20,), dtype="<i4", chunks=(5,), fillvalue=7, compression="gzip")
|
||||||
|
g[10:15] = 1
|
||||||
|
s = f.create_dataset("sparse2d", shape=(5, 7), dtype="<i4", chunks=(2, 3), fillvalue=-3)
|
||||||
|
s[2:4, 3:6] = 8
|
||||||
|
s[4, 6] = 5
|
||||||
|
with h5py.File("{path_str}", "r") as f:
|
||||||
|
for name in ["partial", "never", "never_chunked", "default_fill", "gz", "sparse2d"]:
|
||||||
|
print(name, f[name][...].ravel().tolist())
|
||||||
|
print("slab", f["sparse2d"][1:5, 2:7].ravel().tolist())
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let expected: std::collections::HashMap<String, Vec<i32>> = 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user