use arrow::buffer::ScalarBuffer;
use itertools::Itertools as _;
use re_chunk::{EntityPath, Timeline};
use re_log_types::{EntityPathPart, TimeType};
use crate::config::{Hdf5Config, IndexType};
use crate::convert;
use crate::error::Hdf5Error;
use crate::walk::{self, DatasetDesc, H5Path, IgnoreSet, Walk};
const HDF5_PROPERTIES_PART: &str = "__hdf5_properties";
pub(crate) enum EmitUnit {
Attributes {
entity: EntityPath,
attrs: Vec<(String, hdf5_pure::AttrValue)>,
},
StaticScalars {
entity: EntityPath,
datasets: Vec<DatasetDesc>,
},
Data {
entity: EntityPath,
datasets: Vec<DatasetDesc>,
},
}
pub(crate) struct PlannedTimeline {
pub timeline: Timeline,
pub times: ScalarBuffer<i64>,
pub is_sorted: bool,
}
pub(crate) struct Hdf5Plan {
pub units: Vec<EmitUnit>,
pub timeline: Option<PlannedTimeline>,
}
struct RowResolution {
row_count: Option<u64>,
kind: &'static str,
index_path: Option<H5Path>,
}
pub(crate) fn build_plan(
file: &hdf5_pure::File,
config: &Hdf5Config,
) -> Result<Hdf5Plan, Hdf5Error> {
re_tracing::profile_function!();
let ignore = IgnoreSet::new(&config.ignore_datasets);
let walked = walk::walk(file, &H5Path::root(), &ignore)?;
warn_unsupported(&walked);
let resolution = resolve_rows(&walked, config)?;
check_alignment(&walked, &resolution)?;
let timeline = build_timeline(file, config, &resolution)?;
let units = build_units(walked, &resolution, &config.entity_path_prefix);
Ok(Hdf5Plan { units, timeline })
}
pub(crate) fn validate_with_file(
file: &hdf5_pure::File,
config: &Hdf5Config,
) -> Result<(), Hdf5Error> {
re_tracing::profile_function!();
let ignore = IgnoreSet::new(&config.ignore_datasets);
let walked = walk::walk(file, &H5Path::root(), &ignore)?;
let resolution = resolve_rows(&walked, config)?;
check_alignment(&walked, &resolution)
}
fn warn_unsupported(walked: &Walk) {
let unsupported = walked
.datasets
.iter()
.filter(|dataset| !convert::supported_dtype(&dataset.dtype))
.map(|dataset| format!("{} ({})", dataset.path, dataset.dtype))
.join(", ");
if !unsupported.is_empty() {
re_log::warn!("Ignoring HDF5 datasets with unsupported element types: {unsupported}");
}
}
fn is_aligned_data(dataset: &DatasetDesc, index_path: Option<&H5Path>) -> bool {
!dataset.shape.is_empty()
&& convert::supported_dtype(&dataset.dtype)
&& Some(&dataset.path) != index_path
}
fn resolve_rows(walked: &Walk, config: &Hdf5Config) -> Result<RowResolution, Hdf5Error> {
if let Some(index) = &config.index_column {
let target = H5Path::parse(&index.path);
let desc = walked
.datasets
.iter()
.find(|dataset| dataset.path == target)
.ok_or_else(|| Hdf5Error::IndexNotFound {
path: index.path.clone(),
})?;
if desc.shape.len() != 1 {
return Err(Hdf5Error::IndexNotOneDimensional {
path: index.path.clone(),
shape: desc.shape.clone(),
});
}
if !convert::is_numeric_dtype(&desc.dtype) {
return Err(Hdf5Error::IndexNotNumeric {
path: index.path.clone(),
dtype: desc.dtype.to_string(),
});
}
Ok(RowResolution {
row_count: Some(desc.shape[0]),
kind: "index column",
index_path: Some(desc.path.clone()),
})
} else {
let reference = walked
.datasets
.iter()
.find(|dataset| is_aligned_data(dataset, None));
Ok(RowResolution {
row_count: reference.map(|dataset| dataset.shape[0]),
kind: "row_index timeline",
index_path: None,
})
}
}
fn check_alignment(walked: &Walk, resolution: &RowResolution) -> Result<(), Hdf5Error> {
let Some(expected) = resolution.row_count else {
return Ok(());
};
let offenders = walked
.datasets
.iter()
.filter(|dataset| is_aligned_data(dataset, resolution.index_path.as_ref()))
.filter(|dataset| dataset.shape[0] != expected)
.map(|dataset| format!("{} (shape {:?})", dataset.path, dataset.shape))
.join(", ");
if offenders.is_empty() {
Ok(())
} else {
Err(Hdf5Error::RowAlignment {
kind: resolution.kind,
expected,
offenders,
})
}
}
fn build_timeline(
file: &hdf5_pure::File,
config: &Hdf5Config,
resolution: &RowResolution,
) -> Result<Option<PlannedTimeline>, Hdf5Error> {
if let (Some(index), Some(path)) = (&config.index_column, &resolution.index_path) {
let time_type = match index.index_type {
IndexType::Timestamp(_) => TimeType::TimestampNs,
IndexType::Duration(_) => TimeType::DurationNs,
IndexType::Sequence => TimeType::Sequence,
};
let leaf = path.leaf().expect("the index path is never the root");
let timeline_name = re_chunk::TimelineName::try_new(leaf)
.map_err(|source| Hdf5Error::invalid_timeline_name(leaf, source))?;
let times = convert::read_index_to_ns(file, path, index.index_type)?;
let is_sorted = times.windows(2).all(|times| times[0] <= times[1]);
Ok(Some(PlannedTimeline {
timeline: Timeline::new(timeline_name, time_type),
times,
is_sorted,
}))
} else if let Some(row_count) = resolution.row_count {
#[expect(clippy::cast_possible_wrap)]
let times: Vec<i64> = (0..row_count as i64).collect();
Ok(Some(PlannedTimeline {
timeline: Timeline::new("row_index", TimeType::Sequence),
times: ScalarBuffer::from(times),
is_sorted: true, }))
} else {
Ok(None)
}
}
fn build_units(
walked: Walk,
resolution: &RowResolution,
entity_path_prefix: &EntityPath,
) -> Vec<EmitUnit> {
let mut units = Vec::new();
for attr in walked.attrs {
units.push(EmitUnit::Attributes {
entity: props_entity(entity_path_prefix, &attr.path),
attrs: attr.attrs,
});
}
for (group_path, group_datasets) in &walked
.datasets
.into_iter()
.chunk_by(|dataset| dataset.path.parent())
{
let (scalars, data): (Vec<DatasetDesc>, Vec<DatasetDesc>) = group_datasets
.filter(|dataset| convert::supported_dtype(&dataset.dtype))
.filter(|dataset| Some(&dataset.path) != resolution.index_path.as_ref())
.partition(|dataset| dataset.shape.is_empty());
let entity = entity_for(entity_path_prefix, &group_path);
if !data.is_empty() {
units.push(EmitUnit::Data {
entity: entity.clone(),
datasets: data,
});
}
if !scalars.is_empty() {
units.push(EmitUnit::StaticScalars {
entity,
datasets: scalars,
});
}
}
units
}
fn entity_for(prefix: &EntityPath, path: &H5Path) -> EntityPath {
prefix.join(&EntityPath::new(
path.segments().map(EntityPathPart::new).collect(),
))
}
fn props_entity(prefix: &EntityPath, path: &H5Path) -> EntityPath {
let mut parts = vec![EntityPathPart::new(HDF5_PROPERTIES_PART)];
parts.extend(path.segments().map(EntityPathPart::new));
prefix.join(&EntityPath::new(parts))
}