Skip to main content

netcdf_reader/
types.rs

1use crate::error::{Error, Result};
2
3/// A NetCDF dimension.
4#[derive(Debug, Clone)]
5pub struct NcDimension {
6    pub name: String,
7    pub size: u64,
8    pub is_unlimited: bool,
9}
10
11/// A field within a compound (struct) type.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct NcCompoundField {
14    pub name: String,
15    pub offset: u64,
16    pub dtype: NcType,
17}
18
19/// A typed integer value used by NetCDF-4 enum definitions and values.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum NcIntegerValue {
22    I8(i8),
23    U8(u8),
24    I16(i16),
25    U16(u16),
26    I32(i32),
27    U32(u32),
28    I64(i64),
29    U64(u64),
30}
31
32impl NcIntegerValue {
33    /// Return the value as `i128` when it is lossless for the signed domain.
34    pub fn as_i128(self) -> Option<i128> {
35        match self {
36            NcIntegerValue::I8(value) => Some(value as i128),
37            NcIntegerValue::U8(value) => Some(value as i128),
38            NcIntegerValue::I16(value) => Some(value as i128),
39            NcIntegerValue::U16(value) => Some(value as i128),
40            NcIntegerValue::I32(value) => Some(value as i128),
41            NcIntegerValue::U32(value) => Some(value as i128),
42            NcIntegerValue::I64(value) => Some(value as i128),
43            NcIntegerValue::U64(value) => Some(i128::from(value)),
44        }
45    }
46
47    /// Return the value as `u128` when it is non-negative.
48    pub fn as_u128(self) -> Option<u128> {
49        match self {
50            NcIntegerValue::I8(value) => u128::try_from(value).ok(),
51            NcIntegerValue::U8(value) => Some(value as u128),
52            NcIntegerValue::I16(value) => u128::try_from(value).ok(),
53            NcIntegerValue::U16(value) => Some(value as u128),
54            NcIntegerValue::I32(value) => u128::try_from(value).ok(),
55            NcIntegerValue::U32(value) => Some(value as u128),
56            NcIntegerValue::I64(value) => u128::try_from(value).ok(),
57            NcIntegerValue::U64(value) => Some(value as u128),
58        }
59    }
60}
61
62/// A named member of a NetCDF-4 enum type.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct NcEnumMember {
65    pub name: String,
66    pub value: NcIntegerValue,
67}
68
69/// NetCDF data types.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum NcType {
72    /// NC_BYTE (i8)
73    Byte,
74    /// NC_CHAR (u8/char)
75    Char,
76    /// NC_SHORT (i16)
77    Short,
78    /// NC_INT (i32)
79    Int,
80    /// NC_FLOAT (f32)
81    Float,
82    /// NC_DOUBLE (f64)
83    Double,
84    /// NC_UBYTE (u8, CDF-5)
85    UByte,
86    /// NC_USHORT (u16, CDF-5)
87    UShort,
88    /// NC_UINT (u32, CDF-5)
89    UInt,
90    /// NC_INT64 (i64, CDF-5)
91    Int64,
92    /// NC_UINT64 (u64, CDF-5)
93    UInt64,
94    /// NetCDF-4 only (variable-length string)
95    String,
96    /// NetCDF-4 enum type with an integer base type.
97    Enum {
98        base: Box<NcType>,
99        members: Vec<NcEnumMember>,
100    },
101    /// NetCDF-4 compound type (struct with named fields).
102    Compound {
103        size: u32,
104        fields: Vec<NcCompoundField>,
105    },
106    /// NetCDF-4 opaque type (uninterpreted byte blob).
107    Opaque { size: u32, tag: String },
108    /// NetCDF-4 array type (fixed-size array of a base type).
109    Array { base: Box<NcType>, dims: Vec<u64> },
110    /// NetCDF-4 variable-length type.
111    VLen { base: Box<NcType> },
112}
113
114impl NcType {
115    /// Size of a single element in bytes.
116    pub fn size(&self) -> Result<usize> {
117        match self {
118            NcType::Byte | NcType::Char | NcType::UByte => Ok(1),
119            NcType::Short | NcType::UShort => Ok(2),
120            NcType::Int | NcType::UInt | NcType::Float => Ok(4),
121            NcType::Int64 | NcType::UInt64 | NcType::Double => Ok(8),
122            // Variable-length string; no fixed element size, but pointer-sized in memory.
123            NcType::String => Ok(std::mem::size_of::<usize>()),
124            NcType::Enum { base, .. } => base.size(),
125            NcType::Compound { size, .. } => Ok(*size as usize),
126            NcType::Opaque { size, .. } => Ok(*size as usize),
127            NcType::Array { base, dims } => {
128                let base_size = base.size()?;
129                let count = dims.iter().try_fold(1usize, |acc, &dim| {
130                    let dim = usize::try_from(dim).map_err(|_| {
131                        Error::InvalidData(
132                            "NetCDF array type dimension exceeds platform usize capacity"
133                                .to_string(),
134                        )
135                    })?;
136                    acc.checked_mul(dim).ok_or_else(|| {
137                        Error::InvalidData(
138                            "NetCDF array type element count exceeds platform usize capacity"
139                                .to_string(),
140                        )
141                    })
142                })?;
143                base_size.checked_mul(count).ok_or_else(|| {
144                    Error::InvalidData(
145                        "NetCDF array type byte size exceeds platform usize capacity".to_string(),
146                    )
147                })
148            }
149            NcType::VLen { .. } => Ok(std::mem::size_of::<usize>()), // pointer-sized
150        }
151    }
152
153    /// The numeric type code used in CDF-1/2/5 headers.
154    pub fn classic_type_code(&self) -> Option<u32> {
155        match self {
156            NcType::Byte => Some(1),
157            NcType::Char => Some(2),
158            NcType::Short => Some(3),
159            NcType::Int => Some(4),
160            NcType::Float => Some(5),
161            NcType::Double => Some(6),
162            NcType::UByte => Some(7),
163            NcType::UShort => Some(8),
164            NcType::UInt => Some(9),
165            NcType::Int64 => Some(10),
166            NcType::UInt64 => Some(11),
167            // Extended types are not valid in classic format.
168            NcType::String
169            | NcType::Enum { .. }
170            | NcType::Compound { .. }
171            | NcType::Opaque { .. }
172            | NcType::Array { .. }
173            | NcType::VLen { .. } => None,
174        }
175    }
176
177    /// Returns true if this is a primitive numeric or string type.
178    pub fn is_primitive(&self) -> bool {
179        matches!(
180            self,
181            NcType::Byte
182                | NcType::Char
183                | NcType::Short
184                | NcType::Int
185                | NcType::Float
186                | NcType::Double
187                | NcType::UByte
188                | NcType::UShort
189                | NcType::UInt
190                | NcType::Int64
191                | NcType::UInt64
192                | NcType::String
193        )
194    }
195}
196
197/// A NetCDF attribute value.
198#[derive(Debug, Clone)]
199pub enum NcAttrValue {
200    Bytes(Vec<i8>),
201    Chars(String),
202    Shorts(Vec<i16>),
203    Ints(Vec<i32>),
204    Floats(Vec<f32>),
205    Doubles(Vec<f64>),
206    UBytes(Vec<u8>),
207    UShorts(Vec<u16>),
208    UInts(Vec<u32>),
209    Int64s(Vec<i64>),
210    UInt64s(Vec<u64>),
211    Strings(Vec<String>),
212}
213
214impl NcAttrValue {
215    /// Get the value as a string (for Chars or single-element Strings).
216    pub fn as_string(&self) -> Option<String> {
217        match self {
218            NcAttrValue::Chars(s) => Some(s.clone()),
219            NcAttrValue::Strings(v) if v.len() == 1 => Some(v[0].clone()),
220            _ => None,
221        }
222    }
223
224    /// Get the value as f64 (with numeric promotion from the first element).
225    pub fn as_f64(&self) -> Option<f64> {
226        match self {
227            NcAttrValue::Bytes(v) => v.first().map(|&x| x as f64),
228            NcAttrValue::Shorts(v) => v.first().map(|&x| x as f64),
229            NcAttrValue::Ints(v) => v.first().map(|&x| x as f64),
230            NcAttrValue::Floats(v) => v.first().map(|&x| x as f64),
231            NcAttrValue::Doubles(v) => v.first().copied(),
232            NcAttrValue::UBytes(v) => v.first().map(|&x| x as f64),
233            NcAttrValue::UShorts(v) => v.first().map(|&x| x as f64),
234            NcAttrValue::UInts(v) => v.first().map(|&x| x as f64),
235            NcAttrValue::Int64s(v) => v.first().map(|&x| x as f64),
236            NcAttrValue::UInt64s(v) => v.first().map(|&x| x as f64),
237            NcAttrValue::Chars(_) | NcAttrValue::Strings(_) => None,
238        }
239    }
240
241    /// Get the value as a vector of f64 (with numeric promotion).
242    pub fn as_f64_vec(&self) -> Option<Vec<f64>> {
243        match self {
244            NcAttrValue::Bytes(v) => Some(v.iter().map(|&x| x as f64).collect()),
245            NcAttrValue::Shorts(v) => Some(v.iter().map(|&x| x as f64).collect()),
246            NcAttrValue::Ints(v) => Some(v.iter().map(|&x| x as f64).collect()),
247            NcAttrValue::Floats(v) => Some(v.iter().map(|&x| x as f64).collect()),
248            NcAttrValue::Doubles(v) => Some(v.clone()),
249            NcAttrValue::UBytes(v) => Some(v.iter().map(|&x| x as f64).collect()),
250            NcAttrValue::UShorts(v) => Some(v.iter().map(|&x| x as f64).collect()),
251            NcAttrValue::UInts(v) => Some(v.iter().map(|&x| x as f64).collect()),
252            NcAttrValue::Int64s(v) => Some(v.iter().map(|&x| x as f64).collect()),
253            NcAttrValue::UInt64s(v) => Some(v.iter().map(|&x| x as f64).collect()),
254            NcAttrValue::Chars(_) | NcAttrValue::Strings(_) => None,
255        }
256    }
257}
258
259/// A NetCDF attribute.
260#[derive(Debug, Clone)]
261pub struct NcAttribute {
262    pub name: String,
263    pub value: NcAttrValue,
264}
265
266/// A NetCDF variable (metadata only -- data is read on demand).
267#[derive(Debug, Clone)]
268pub struct NcVariable {
269    pub name: String,
270    pub dimensions: Vec<NcDimension>,
271    pub dtype: NcType,
272    pub attributes: Vec<NcAttribute>,
273    /// For classic: file byte offset to the start of this variable's data.
274    /// For nc4: HDF5 dataset object header address.
275    pub(crate) data_offset: u64,
276    /// Total data size in bytes (for non-record variables).
277    pub(crate) _data_size: u64,
278    /// Whether this variable uses the unlimited (record) dimension.
279    pub(crate) is_record_var: bool,
280    /// Size of one record slice in bytes (only meaningful for record variables).
281    pub(crate) record_size: u64,
282}
283
284impl NcVariable {
285    /// Variable name.
286    pub fn name(&self) -> &str {
287        &self.name
288    }
289
290    /// Variable dimensions.
291    pub fn dimensions(&self) -> &[NcDimension] {
292        &self.dimensions
293    }
294
295    /// Returns the dimension for a CF/NetCDF coordinate variable.
296    ///
297    /// A coordinate variable is one-dimensional and has the same name as its
298    /// dimension. NetCDF-4 stores these as HDF5 dimension scales, but they are
299    /// exposed here with the same shape as classic NetCDF coordinate variables.
300    pub fn coordinate_dimension(&self) -> Option<&NcDimension> {
301        match self.dimensions.as_slice() {
302            [dim] if dim.name == self.name => Some(dim),
303            _ => None,
304        }
305    }
306
307    /// Returns true when this variable is a CF/NetCDF coordinate variable.
308    pub fn is_coordinate_variable(&self) -> bool {
309        self.coordinate_dimension().is_some()
310    }
311
312    /// Returns true when this variable is the coordinate variable for a named
313    /// dimension.
314    pub fn is_coordinate_variable_for(&self, dimension_name: &str) -> bool {
315        self.coordinate_dimension()
316            .is_some_and(|dim| dim.name == dimension_name)
317    }
318
319    /// Variable data type.
320    pub fn dtype(&self) -> &NcType {
321        &self.dtype
322    }
323
324    /// Shape of the variable as a vector of dimension sizes.
325    pub fn shape(&self) -> Vec<u64> {
326        self.dimensions.iter().map(|d| d.size).collect()
327    }
328
329    /// Variable attributes.
330    pub fn attributes(&self) -> &[NcAttribute] {
331        &self.attributes
332    }
333
334    /// Find an attribute by name.
335    pub fn attribute(&self, name: &str) -> Option<&NcAttribute> {
336        self.attributes.iter().find(|a| a.name == name)
337    }
338
339    /// Number of dimensions.
340    pub fn ndim(&self) -> usize {
341        self.dimensions.len()
342    }
343
344    /// Total number of elements.
345    pub fn num_elements(&self) -> Result<u64> {
346        match self.dimensions.as_slice() {
347            [] => Ok(1), // scalar
348            [dim] => Ok(dim.size),
349            dimensions => {
350                let mut total = 1u64;
351                for dim in dimensions {
352                    total = total.checked_mul(dim.size).ok_or_else(|| {
353                        Error::InvalidData(
354                            "NetCDF variable element count overflows u64".to_string(),
355                        )
356                    })?;
357                }
358                Ok(total)
359            }
360        }
361    }
362}
363
364/// A NetCDF group (NetCDF-4 only; classic files have one implicit root group).
365#[derive(Debug, Clone)]
366pub struct NcGroup {
367    pub name: String,
368    pub dimensions: Vec<NcDimension>,
369    pub variables: Vec<NcVariable>,
370    pub attributes: Vec<NcAttribute>,
371    pub groups: Vec<NcGroup>,
372}
373
374impl NcGroup {
375    /// Find a variable by name in this group.
376    pub fn variable(&self, name: &str) -> Option<&NcVariable> {
377        let (group_path, variable_name) = split_parent_path(name)?;
378        let group = self.group(group_path)?;
379        group.variables.iter().find(|v| v.name == variable_name)
380    }
381
382    /// Find a dimension by name in this group.
383    pub fn dimension(&self, name: &str) -> Option<&NcDimension> {
384        let (group_path, dimension_name) = split_parent_path(name)?;
385        let group = self.group(group_path)?;
386        group.dimensions.iter().find(|d| d.name == dimension_name)
387    }
388
389    /// Find the coordinate variable for a dimension in this group.
390    ///
391    /// `name` may be a local dimension name or a path relative to this group,
392    /// for example `time` or `forecast/time`.
393    pub fn coordinate_variable(&self, name: &str) -> Option<&NcVariable> {
394        let (group_path, dimension_name) = split_parent_path(name)?;
395        let group = self.group(group_path)?;
396        group
397            .variables
398            .iter()
399            .find(|var| var.is_coordinate_variable_for(dimension_name))
400    }
401
402    /// Iterate over coordinate variables declared in this group.
403    pub fn coordinate_variables(&self) -> impl Iterator<Item = &NcVariable> {
404        self.variables
405            .iter()
406            .filter(|var| var.is_coordinate_variable())
407    }
408
409    /// Find an attribute by name in this group.
410    pub fn attribute(&self, name: &str) -> Option<&NcAttribute> {
411        let (group_path, attribute_name) = split_parent_path(name)?;
412        let group = self.group(group_path)?;
413        group.attributes.iter().find(|a| a.name == attribute_name)
414    }
415
416    /// Find a child group by relative path.
417    pub fn group(&self, name: &str) -> Option<&NcGroup> {
418        let trimmed = name.trim_matches('/');
419        if trimmed.is_empty() {
420            return Some(self);
421        }
422
423        let mut group = self;
424        for component in trimmed.split('/').filter(|part| !part.is_empty()) {
425            group = group.groups.iter().find(|child| child.name == component)?;
426        }
427
428        Some(group)
429    }
430}
431
432fn split_parent_path(path: &str) -> Option<(&str, &str)> {
433    let trimmed = path.trim_matches('/');
434    if trimmed.is_empty() {
435        return None;
436    }
437
438    match trimmed.rsplit_once('/') {
439        Some((group_path, leaf_name)) if !leaf_name.is_empty() => Some((group_path, leaf_name)),
440        Some(_) => None,
441        None => Some(("", trimmed)),
442    }
443}
444
445pub(crate) fn checked_usize_from_u64(value: u64, context: &str) -> crate::Result<usize> {
446    usize::try_from(value)
447        .map_err(|_| crate::Error::InvalidData(format!("{context} exceeds platform usize")))
448}
449
450pub(crate) fn checked_mul_u64(lhs: u64, rhs: u64, context: &str) -> crate::Result<u64> {
451    lhs.checked_mul(rhs)
452        .ok_or_else(|| crate::Error::InvalidData(format!("{context} exceeds u64 capacity")))
453}
454
455pub(crate) fn checked_shape_elements(shape: &[u64], context: &str) -> crate::Result<u64> {
456    shape
457        .iter()
458        .try_fold(1u64, |acc, &dim| checked_mul_u64(acc, dim, context))
459}
460
461/// Hyperslab selection for reading slices of NetCDF variables.
462///
463/// Each element corresponds to one dimension of the variable.
464#[derive(Debug, Clone)]
465pub struct NcSliceInfo {
466    pub selections: Vec<NcSliceInfoElem>,
467}
468
469/// A single dimension's selection within a hyperslab.
470#[derive(Debug, Clone)]
471pub enum NcSliceInfoElem {
472    /// Select a single index (reduces dimensionality).
473    Index(u64),
474    /// Select a range with stride.
475    Slice { start: u64, end: u64, step: u64 },
476}
477
478impl NcSliceInfo {
479    /// Create a selection that reads everything for an `ndim`-dimensional variable.
480    pub fn all(ndim: usize) -> Self {
481        NcSliceInfo {
482            selections: vec![
483                NcSliceInfoElem::Slice {
484                    start: 0,
485                    end: u64::MAX,
486                    step: 1,
487                };
488                ndim
489            ],
490        }
491    }
492}
493
494#[cfg(feature = "netcdf4")]
495impl NcSliceInfo {
496    /// Convert to hdf5_reader::SliceInfo for NC4 delegation.
497    pub(crate) fn to_hdf5_slice_info(&self) -> hdf5_reader::SliceInfo {
498        hdf5_reader::SliceInfo {
499            selections: self
500                .selections
501                .iter()
502                .map(|s| match s {
503                    NcSliceInfoElem::Index(idx) => hdf5_reader::SliceInfoElem::Index(*idx),
504                    NcSliceInfoElem::Slice { start, end, step } => {
505                        hdf5_reader::SliceInfoElem::Slice {
506                            start: *start,
507                            end: *end,
508                            step: *step,
509                        }
510                    }
511                })
512                .collect(),
513        }
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    fn sample_group_tree() -> NcGroup {
522        NcGroup {
523            name: "/".to_string(),
524            dimensions: vec![NcDimension {
525                name: "root_dim".to_string(),
526                size: 2,
527                is_unlimited: false,
528            }],
529            variables: vec![NcVariable {
530                name: "root_var".to_string(),
531                dimensions: vec![],
532                dtype: NcType::Int,
533                attributes: vec![],
534                data_offset: 0,
535                _data_size: 0,
536                is_record_var: false,
537                record_size: 4,
538            }],
539            attributes: vec![NcAttribute {
540                name: "title".to_string(),
541                value: NcAttrValue::Chars("root".to_string()),
542            }],
543            groups: vec![NcGroup {
544                name: "obs".to_string(),
545                dimensions: vec![NcDimension {
546                    name: "time".to_string(),
547                    size: 3,
548                    is_unlimited: false,
549                }],
550                variables: vec![NcVariable {
551                    name: "temperature".to_string(),
552                    dimensions: vec![],
553                    dtype: NcType::Float,
554                    attributes: vec![],
555                    data_offset: 0,
556                    _data_size: 0,
557                    is_record_var: false,
558                    record_size: 4,
559                }],
560                attributes: vec![],
561                groups: vec![NcGroup {
562                    name: "surface".to_string(),
563                    dimensions: vec![],
564                    variables: vec![NcVariable {
565                        name: "pressure".to_string(),
566                        dimensions: vec![],
567                        dtype: NcType::Double,
568                        attributes: vec![],
569                        data_offset: 0,
570                        _data_size: 0,
571                        is_record_var: false,
572                        record_size: 8,
573                    }],
574                    attributes: vec![NcAttribute {
575                        name: "units".to_string(),
576                        value: NcAttrValue::Chars("hPa".to_string()),
577                    }],
578                    groups: vec![],
579                }],
580            }],
581        }
582    }
583
584    #[test]
585    fn group_path_lookup() {
586        let root = sample_group_tree();
587
588        let surface = root.group("obs/surface").unwrap();
589        assert_eq!(surface.name, "surface");
590        assert!(root.group("/obs/surface").is_some());
591        assert!(root.group("missing").is_none());
592    }
593
594    #[test]
595    fn variable_path_lookup() {
596        let root = sample_group_tree();
597
598        assert_eq!(root.variable("root_var").unwrap().name(), "root_var");
599        assert_eq!(
600            root.variable("obs/temperature").unwrap().dtype(),
601            &NcType::Float
602        );
603        assert_eq!(
604            root.variable("/obs/surface/pressure").unwrap().dtype(),
605            &NcType::Double
606        );
607        assert!(root.variable("pressure").is_none());
608    }
609
610    #[test]
611    fn dimension_and_attribute_path_lookup() {
612        let root = sample_group_tree();
613
614        assert_eq!(root.dimension("root_dim").unwrap().size, 2);
615        assert_eq!(root.dimension("obs/time").unwrap().size, 3);
616        assert_eq!(
617            root.attribute("title").unwrap().value.as_string().unwrap(),
618            "root"
619        );
620        assert_eq!(
621            root.attribute("obs/surface/units")
622                .unwrap()
623                .value
624                .as_string()
625                .unwrap(),
626            "hPa"
627        );
628    }
629
630    #[test]
631    fn coordinate_variable_detection_and_lookup() {
632        let time_dim = NcDimension {
633            name: "time".to_string(),
634            size: 3,
635            is_unlimited: false,
636        };
637        let lat_dim = NcDimension {
638            name: "lat".to_string(),
639            size: 2,
640            is_unlimited: false,
641        };
642        let time = NcVariable {
643            name: "time".to_string(),
644            dimensions: vec![time_dim.clone()],
645            dtype: NcType::Double,
646            attributes: vec![],
647            data_offset: 0,
648            _data_size: 0,
649            is_record_var: false,
650            record_size: 8,
651        };
652        let temperature = NcVariable {
653            name: "temperature".to_string(),
654            dimensions: vec![time_dim.clone(), lat_dim.clone()],
655            dtype: NcType::Float,
656            attributes: vec![],
657            data_offset: 0,
658            _data_size: 0,
659            is_record_var: false,
660            record_size: 4,
661        };
662        let group = NcGroup {
663            name: "/".to_string(),
664            dimensions: vec![time_dim, lat_dim],
665            variables: vec![time.clone(), temperature],
666            attributes: vec![],
667            groups: vec![],
668        };
669
670        assert!(time.is_coordinate_variable());
671        assert_eq!(time.coordinate_dimension().unwrap().name, "time");
672        assert_eq!(group.coordinate_variable("time").unwrap().name(), "time");
673        assert!(group.coordinate_variable("lat").is_none());
674
675        let names: Vec<&str> = group.coordinate_variables().map(NcVariable::name).collect();
676        assert_eq!(names, vec!["time"]);
677    }
678
679    #[test]
680    fn checked_shape_elements_overflow() {
681        let err = checked_shape_elements(&[u64::MAX, 2], "test overflow").unwrap_err();
682        assert!(matches!(err, crate::Error::InvalidData(_)));
683    }
684
685    #[test]
686    fn array_type_size_rejects_overflow() {
687        let ty = NcType::Array {
688            base: Box::new(NcType::UInt64),
689            dims: vec![u64::MAX, 2],
690        };
691
692        let err = ty.size().unwrap_err();
693        assert!(err.to_string().contains("array type"));
694    }
695
696    #[test]
697    fn variable_num_elements_rejects_overflow() {
698        let var = NcVariable {
699            name: "huge".to_string(),
700            dimensions: vec![
701                NcDimension {
702                    name: "x".to_string(),
703                    size: u64::MAX,
704                    is_unlimited: false,
705                },
706                NcDimension {
707                    name: "y".to_string(),
708                    size: 2,
709                    is_unlimited: false,
710                },
711            ],
712            dtype: NcType::Float,
713            attributes: vec![],
714            data_offset: 0,
715            _data_size: 0,
716            is_record_var: false,
717            record_size: 4,
718        };
719
720        let err = var.num_elements().unwrap_err();
721        assert!(err.to_string().contains("element count"));
722    }
723}