Skip to main content

netcdf_reader/nc4/
variables.rs

1//! Map HDF5 datasets to NetCDF-4 variables.
2//!
3//! Each user-visible HDF5 dataset becomes an NcVariable. Dimension-only scale
4//! datasets are internal metadata, but coordinate variables are also dimension
5//! scales and must remain visible. Non-scale variable dimensions are determined
6//! from the `DIMENSION_LIST` attribute, which contains object references to the
7//! corresponding dimension-scale datasets.
8
9use std::collections::HashMap;
10
11use hdf5_reader::group::Group;
12
13use crate::error::{Error, Result};
14use crate::types::{NcDimension, NcVariable};
15
16use super::attributes;
17use super::types::hdf5_to_nc_type;
18
19fn leaf_name(name: &str) -> &str {
20    name.rsplit('/').next().unwrap_or(name)
21}
22
23/// Extract variables from an HDF5 group.
24///
25/// Dimension-only scale datasets are internal metadata. Coordinate variables are
26/// also dimension scales, so they are preserved as NcVariables.
27///
28/// `dim_addr_map` maps dimension-scale dataset addresses to their `NcDimension`,
29/// used to resolve `DIMENSION_LIST` object references.
30pub fn extract_variables(
31    group: &Group,
32    dimensions: &[NcDimension],
33    dim_addr_map: &HashMap<u64, NcDimension>,
34    metadata_mode: crate::NcMetadataMode,
35) -> Result<Vec<NcVariable>> {
36    let datasets = group.datasets()?;
37    extract_variables_from_datasets(&datasets, group, dimensions, dim_addr_map, metadata_mode)
38}
39
40pub fn extract_variables_from_datasets(
41    datasets: &[hdf5_reader::Dataset],
42    group: &Group,
43    dimensions: &[NcDimension],
44    dim_addr_map: &HashMap<u64, NcDimension>,
45    metadata_mode: crate::NcMetadataMode,
46) -> Result<Vec<NcVariable>> {
47    let mut variables = Vec::new();
48
49    for ds in datasets {
50        if let Some(variable) =
51            extract_variable(ds, group, dimensions, dim_addr_map, metadata_mode)?
52        {
53            variables.push(variable);
54        }
55    }
56
57    Ok(variables)
58}
59
60pub fn extract_variable(
61    ds: &hdf5_reader::Dataset,
62    group: &Group,
63    dimensions: &[NcDimension],
64    dim_addr_map: &HashMap<u64, NcDimension>,
65    metadata_mode: crate::NcMetadataMode,
66) -> Result<Option<NcVariable>> {
67    let strict = metadata_mode == crate::NcMetadataMode::Strict;
68
69    let is_dim_scale = match ds.attribute("CLASS") {
70        Ok(attr) => match attr.read_string() {
71            Ok(value) => value == "DIMENSION_SCALE",
72            Err(err) if strict => {
73                return Err(Error::InvalidData(format!(
74                    "dataset '{}' has unreadable CLASS attribute: {err}",
75                    ds.name()
76                )))
77            }
78            Err(_) => false,
79        },
80        Err(_) => false,
81    };
82
83    if is_dim_scale && is_dimension_only_scale(ds, strict)? {
84        return Ok(None);
85    }
86
87    let nc_type = match hdf5_to_nc_type(ds.dtype()) {
88        Ok(t) => t,
89        Err(err) if strict => {
90            return Err(Error::InvalidData(format!(
91                "dataset '{}' uses unsupported NetCDF-4 type: {err}",
92                ds.name()
93            )))
94        }
95        Err(_) => return Ok(None),
96    };
97
98    let var_dims = if is_dim_scale {
99        resolve_coordinate_variable_dimensions(ds, dim_addr_map, metadata_mode)?
100    } else {
101        resolve_variable_dimensions(ds, group, dimensions, dim_addr_map, metadata_mode)?
102    };
103    let is_unlimited = var_dims.iter().any(|d| d.is_unlimited);
104    let shape = ds.shape();
105    let elem_size = u64::try_from(nc_type.size()?)
106        .map_err(|_| Error::InvalidData("NetCDF-4 type size exceeds u64 capacity".to_string()))?;
107    let (data_size, record_size) = compute_storage_sizes(shape, elem_size, is_unlimited)?;
108    let var_attrs = attributes::extract_variable_attributes(ds, metadata_mode)?;
109
110    Ok(Some(NcVariable {
111        name: leaf_name(ds.name()).to_string(),
112        dimensions: var_dims,
113        dtype: nc_type,
114        attributes: var_attrs,
115        data_offset: ds.address(),
116        _data_size: data_size,
117        is_record_var: is_unlimited,
118        record_size,
119    }))
120}
121
122fn is_dimension_only_scale(ds: &hdf5_reader::Dataset, strict: bool) -> Result<bool> {
123    match ds.attribute("NAME") {
124        Ok(attr) => match attr.read_string() {
125            Ok(value) => Ok(super::dimensions::is_dimension_without_variable_name(
126                &value,
127            )),
128            Err(err) if strict => Err(Error::InvalidData(format!(
129                "dimension scale '{}' has unreadable NAME attribute: {err}",
130                ds.name()
131            ))),
132            Err(_) => Ok(false),
133        },
134        Err(_) => Ok(false),
135    }
136}
137
138fn resolve_coordinate_variable_dimensions(
139    ds: &hdf5_reader::Dataset,
140    dim_addr_map: &HashMap<u64, NcDimension>,
141    metadata_mode: crate::NcMetadataMode,
142) -> Result<Vec<NcDimension>> {
143    if let Some(dim) = dim_addr_map.get(&ds.address()) {
144        Ok(vec![dim.clone()])
145    } else if metadata_mode == crate::NcMetadataMode::Lossy {
146        Ok(vec![NcDimension {
147            name: leaf_name(ds.name()).to_string(),
148            size: ds.shape().first().copied().unwrap_or(0),
149            is_unlimited: ds
150                .max_dims()
151                .is_some_and(|md| !md.is_empty() && md[0] == u64::MAX),
152        }])
153    } else {
154        Err(Error::InvalidData(format!(
155            "coordinate variable '{}' is not registered as a dimension scale",
156            ds.name()
157        )))
158    }
159}
160
161/// Resolve variable dimensions via the `DIMENSION_LIST` attribute.
162///
163/// `DIMENSION_LIST` is a VLen-of-object-reference attribute. Each entry is a
164/// variable-length sequence of object references pointing to dimension-scale
165/// datasets. We parse the raw attribute data to extract these references.
166///
167fn resolve_variable_dimensions(
168    ds: &hdf5_reader::Dataset,
169    group: &Group,
170    dimensions: &[NcDimension],
171    dim_addr_map: &HashMap<u64, NcDimension>,
172    metadata_mode: crate::NcMetadataMode,
173) -> Result<Vec<NcDimension>> {
174    resolve_variable_dimensions_with_mode(ds, group, dimensions, dim_addr_map, metadata_mode)
175}
176
177fn resolve_variable_dimensions_with_mode(
178    ds: &hdf5_reader::Dataset,
179    group: &Group,
180    dimensions: &[NcDimension],
181    dim_addr_map: &HashMap<u64, NcDimension>,
182    metadata_mode: crate::NcMetadataMode,
183) -> Result<Vec<NcDimension>> {
184    match resolve_variable_dimensions_from_dimlist(ds, group, dim_addr_map) {
185        Ok(dims) => Ok(dims),
186        Err(_err) if metadata_mode == crate::NcMetadataMode::Lossy => {
187            Ok(resolve_variable_dimensions_by_size(ds, dimensions))
188        }
189        Err(err) => Err(err),
190    }
191}
192
193/// Resolve variable dimensions via the `DIMENSION_LIST` attribute.
194fn resolve_variable_dimensions_from_dimlist(
195    ds: &hdf5_reader::Dataset,
196    group: &Group,
197    dim_addr_map: &HashMap<u64, NcDimension>,
198) -> Result<Vec<NcDimension>> {
199    let dim_addrs = resolve_dimension_scale_addresses(ds, group)?;
200    let mut var_dims = Vec::with_capacity(dim_addrs.len());
201    for dim_addr in dim_addrs {
202        if let Some(dim) = dim_addr_map.get(&dim_addr) {
203            var_dims.push(dim.clone());
204        } else {
205            return Err(Error::InvalidData(format!(
206                "dataset '{}' references unknown dimension scale address {dim_addr:#x}",
207                ds.name()
208            )));
209        }
210    }
211
212    // Apply unlimited status from the dataset's max_dims.
213    if let Some(max_dims) = ds.max_dims() {
214        for (i, md) in max_dims.iter().enumerate() {
215            if *md == u64::MAX && i < var_dims.len() {
216                var_dims[i].is_unlimited = true;
217            }
218        }
219    }
220
221    Ok(var_dims)
222}
223
224pub(crate) fn resolve_dimension_scale_addresses(
225    ds: &hdf5_reader::Dataset,
226    group: &Group,
227) -> Result<Vec<u64>> {
228    // A rank-0 variable has no dimensions to reference.
229    let ndim = ds.ndim();
230    if ndim == 0 {
231        return Ok(Vec::new());
232    }
233
234    let attr = ds.attribute("DIMENSION_LIST").map_err(|_| {
235        Error::InvalidData(format!(
236            "dataset '{}' is missing required DIMENSION_LIST metadata",
237            ds.name()
238        ))
239    })?;
240    let raw_data = &attr.raw_data;
241    let offset_size = group.offset_size();
242
243    if raw_data.is_empty() {
244        return Err(Error::InvalidData(format!(
245            "dataset '{}' has empty DIMENSION_LIST metadata",
246            ds.name()
247        )));
248    }
249
250    let entry_size = 4 + usize::from(offset_size) + 4;
251    if raw_data.len() < ndim * entry_size {
252        return Err(Error::InvalidData(format!(
253            "dataset '{}' has truncated DIMENSION_LIST metadata",
254            ds.name()
255        )));
256    }
257
258    let mut dim_addrs = Vec::with_capacity(ndim);
259    let mut cursor = hdf5_reader::io::Cursor::new(raw_data);
260
261    for _ in 0..ndim {
262        let seq_len = cursor.read_u32_le().map_err(|err| {
263            Error::InvalidData(format!(
264                "dataset '{}' has invalid DIMENSION_LIST entry count: {err}",
265                ds.name()
266            ))
267        })? as usize;
268        let heap_addr = cursor.read_offset(offset_size).map_err(|err| {
269            Error::InvalidData(format!(
270                "dataset '{}' has invalid DIMENSION_LIST heap address: {err}",
271                ds.name()
272            ))
273        })?;
274        let heap_idx = cursor.read_u32_le().map_err(|err| {
275            Error::InvalidData(format!(
276                "dataset '{}' has invalid DIMENSION_LIST heap index: {err}",
277                ds.name()
278            ))
279        })? as u16;
280
281        if seq_len == 0 || hdf5_reader::io::Cursor::is_undefined_offset(heap_addr, offset_size) {
282            return Err(Error::InvalidData(format!(
283                "dataset '{}' has an unresolved DIMENSION_LIST reference",
284                ds.name()
285            )));
286        }
287
288        let collection = hdf5_reader::global_heap::GlobalHeapCollection::parse_at_storage(
289            group.storage(),
290            heap_addr,
291            offset_size,
292            group.length_size(),
293        )
294        .map_err(|err| {
295            Error::InvalidData(format!(
296                "dataset '{}' has unreadable DIMENSION_LIST heap object: {err}",
297                ds.name()
298            ))
299        })?;
300
301        let heap_obj = collection.get_object(heap_idx).ok_or_else(|| {
302            Error::InvalidData(format!(
303                "dataset '{}' references missing DIMENSION_LIST heap object {}",
304                ds.name(),
305                heap_idx
306            ))
307        })?;
308
309        let refs = hdf5_reader::reference::read_object_references(&heap_obj.data, offset_size)
310            .map_err(|err| {
311                Error::InvalidData(format!(
312                    "dataset '{}' has invalid DIMENSION_LIST references: {err}",
313                    ds.name()
314                ))
315            })?;
316
317        if refs.is_empty() {
318            return Err(Error::InvalidData(format!(
319                "dataset '{}' has empty DIMENSION_LIST references",
320                ds.name()
321            )));
322        }
323
324        dim_addrs.push(refs[0]);
325    }
326
327    Ok(dim_addrs)
328}
329
330fn compute_storage_sizes(shape: &[u64], elem_size: u64, is_unlimited: bool) -> Result<(u64, u64)> {
331    let total_elements =
332        crate::types::checked_shape_elements(shape, "NetCDF-4 variable element count")?;
333    let data_size = crate::types::checked_mul_u64(
334        total_elements,
335        elem_size,
336        "NetCDF-4 variable size in bytes",
337    )?;
338
339    let record_elements = if is_unlimited && shape.len() > 1 {
340        crate::types::checked_shape_elements(&shape[1..], "NetCDF-4 record element count")?
341    } else {
342        1
343    };
344    let record_size =
345        crate::types::checked_mul_u64(record_elements, elem_size, "NetCDF-4 record size in bytes")?;
346
347    Ok((data_size, record_size))
348}
349
350/// Resolve dimensions for a variable by matching its shape against the
351/// group's dimensions. Falls back to anonymous dimensions from the shape.
352fn resolve_variable_dimensions_by_size(
353    ds: &hdf5_reader::Dataset,
354    dimensions: &[NcDimension],
355) -> Vec<NcDimension> {
356    let shape = ds.shape();
357
358    // Try to match dimensions by size (simple heuristic when DIMENSION_LIST
359    // isn't available or parseable). This matches dims in order.
360    let mut var_dims = Vec::with_capacity(shape.len());
361    let mut used = vec![false; dimensions.len()];
362
363    for &dim_size in shape {
364        let mut matched = false;
365        for (i, dim) in dimensions.iter().enumerate() {
366            if !used[i] && dim.size == dim_size {
367                var_dims.push(dim.clone());
368                used[i] = true;
369                matched = true;
370                break;
371            }
372        }
373        if !matched {
374            // Create an anonymous dimension
375            var_dims.push(NcDimension {
376                name: format!("dim_{}", dim_size),
377                size: dim_size,
378                is_unlimited: false,
379            });
380        }
381    }
382
383    // Check if any matched dimension is unlimited and update dataspace info
384    if let Some(max_dims) = ds.max_dims() {
385        for (i, md) in max_dims.iter().enumerate() {
386            if *md == u64::MAX && i < var_dims.len() {
387                var_dims[i].is_unlimited = true;
388            }
389        }
390    }
391
392    var_dims
393}
394
395#[cfg(test)]
396mod tests {
397    use super::compute_storage_sizes;
398
399    #[test]
400    fn compute_storage_sizes_detects_overflow() {
401        let err = compute_storage_sizes(&[u64::MAX, 2], 8, false).unwrap_err();
402        assert!(matches!(err, crate::Error::InvalidData(_)));
403    }
404
405    #[test]
406    fn compute_storage_sizes_record_dims() {
407        let (data_size, record_size) = compute_storage_sizes(&[10, 3, 4], 4, true).unwrap();
408        assert_eq!(data_size, 480);
409        assert_eq!(record_size, 48);
410    }
411}