Skip to main content

oxigdal_netcdf/
reader.rs

1//! NetCDF file reader implementation.
2//!
3//! This module reads NetCDF files and exposes their dimensions, variables,
4//! attributes, and array data.
5//!
6//! # Backends
7//!
8//! * **NetCDF-4 / HDF5 (Pure Rust)** — the primary backend. Real NetCDF-4
9//!   files (which are HDF5 files carrying the NetCDF-4 conventions) are read
10//!   with the Pure-Rust [`oxinetcdf`] crate atop `oxih5`. No `libnetcdf`,
11//!   no `libhdf5`, no FFI. This backend honours the NetCDF-4 conventions:
12//!   dimension scales, coordinate variables, `DIMENSION_LIST` axis linkage,
13//!   and user attributes (`units`, `_FillValue`, `scale_factor`, …).
14//! * **NetCDF-3 classic / 64-bit offset (Pure Rust, optional)** — enabled with
15//!   the `netcdf3` feature and served by the `netcdf3` crate.
16//!
17//! When neither backend can read the file a typed
18//! [`NetCdfError::InvalidFormat`] is returned — the reader never fabricates an
19//! empty dataset.
20
21use std::collections::HashMap;
22use std::path::Path;
23
24use crate::attribute::{Attribute, AttributeValue, Attributes};
25use crate::dimension::{Dimension, Dimensions};
26use crate::error::{NetCdfError, Result};
27use crate::metadata::{CfMetadata, NetCdfMetadata, NetCdfVersion};
28use crate::variable::{DataType, Variable, Variables};
29
30use oxinetcdf::{ByteOrder, Dtype, NcAttribute, NcFile, NcGroup};
31
32#[cfg(feature = "netcdf3")]
33use std::cell::RefCell;
34
35/// NetCDF file reader.
36///
37/// Provides methods for reading NetCDF files, including metadata and data.
38pub struct NetCdfReader {
39    metadata: NetCdfMetadata,
40    /// Pure-Rust NetCDF-4 (HDF5) backend, present when the file was opened via
41    /// [`oxinetcdf`].
42    nc4: Option<Nc4Backend>,
43    #[cfg(feature = "netcdf3")]
44    file_nc3: Option<RefCell<netcdf3::FileReader>>,
45}
46
47/// Pure-Rust NetCDF-4 backend state.
48///
49/// Holds the open [`oxinetcdf::NcFile`] plus a map from variable name to the
50/// underlying HDF5 dataset path, so array data can be read on demand.
51struct Nc4Backend {
52    file: NcFile,
53    var_paths: HashMap<String, String>,
54}
55
56impl std::fmt::Debug for NetCdfReader {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("NetCdfReader")
59            .field("metadata", &self.metadata)
60            .finish_non_exhaustive()
61    }
62}
63
64impl NetCdfReader {
65    /// Open a NetCDF file for reading.
66    ///
67    /// Real NetCDF-4 / HDF5 files are read with the Pure-Rust [`oxinetcdf`]
68    /// backend. When the `netcdf3` feature is enabled, NetCDF-3 classic and
69    /// 64-bit offset files are read first via the `netcdf3` crate.
70    ///
71    /// # Arguments
72    ///
73    /// * `path` - Path to the NetCDF file
74    ///
75    /// # Errors
76    ///
77    /// Returns [`NetCdfError::InvalidFormat`] if the file is neither a readable
78    /// NetCDF-3 file nor a readable NetCDF-4 / HDF5 file.
79    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
80        let path = path.as_ref();
81
82        // NetCDF-3 (Pure Rust) — try first so classic files are served by the
83        // dedicated NetCDF-3 reader instead of failing the HDF5 magic check.
84        #[cfg(feature = "netcdf3")]
85        {
86            if let Ok(file) = netcdf3::FileReader::open(path) {
87                return Self::from_netcdf3(file);
88            }
89        }
90
91        // NetCDF-4 / HDF5 (Pure Rust, primary backend).
92        match NcFile::open(path) {
93            Ok(file) => Self::from_oxinetcdf(file),
94            Err(e) => Err(NetCdfError::InvalidFormat(format!(
95                "file is not a readable NetCDF-3 or NetCDF-4/HDF5 file: {e}"
96            ))),
97        }
98    }
99
100    /// Build a reader from an open Pure-Rust NetCDF-4 file.
101    fn from_oxinetcdf(file: NcFile) -> Result<Self> {
102        let root = file.root_group().map_err(map_nc_err)?;
103        let mut metadata = build_metadata_from_group(&root)?;
104        metadata.parse_cf_metadata();
105        let var_paths = root
106            .variables
107            .iter()
108            .map(|v| (v.name.clone(), v.h5_path.clone()))
109            .collect();
110
111        Ok(Self {
112            metadata,
113            nc4: Some(Nc4Backend { file, var_paths }),
114            #[cfg(feature = "netcdf3")]
115            file_nc3: None,
116        })
117    }
118
119    /// Create a reader from a NetCDF-3 file.
120    ///
121    /// # Errors
122    ///
123    /// Returns error if metadata cannot be read.
124    #[cfg(feature = "netcdf3")]
125    pub fn from_netcdf3(file: netcdf3::FileReader) -> Result<Self> {
126        let metadata = Self::read_metadata_nc3(&file)?;
127        Ok(Self {
128            metadata,
129            nc4: None,
130            file_nc3: Some(RefCell::new(file)),
131        })
132    }
133
134    /// Get the file metadata.
135    #[must_use]
136    pub const fn metadata(&self) -> &NetCdfMetadata {
137        &self.metadata
138    }
139
140    /// Get the file format version.
141    #[must_use]
142    pub fn version(&self) -> NetCdfVersion {
143        self.metadata.version()
144    }
145
146    /// Get dimensions.
147    #[must_use]
148    pub fn dimensions(&self) -> &Dimensions {
149        self.metadata.dimensions()
150    }
151
152    /// Get variables.
153    #[must_use]
154    pub fn variables(&self) -> &Variables {
155        self.metadata.variables()
156    }
157
158    /// Get global attributes.
159    #[must_use]
160    pub fn global_attributes(&self) -> &Attributes {
161        self.metadata.global_attributes()
162    }
163
164    /// Get CF metadata if available.
165    #[must_use]
166    pub fn cf_metadata(&self) -> Option<&CfMetadata> {
167        self.metadata.cf_metadata()
168    }
169
170    /// Read metadata from NetCDF-3 file.
171    #[cfg(feature = "netcdf3")]
172    fn read_metadata_nc3(file: &netcdf3::FileReader) -> Result<NetCdfMetadata> {
173        use crate::nc3_compat;
174
175        let mut metadata = NetCdfMetadata::new_classic();
176        let dataset = file.data_set();
177
178        // Read dimensions
179        let dimensions = nc3_compat::read_dimensions(dataset)?;
180        for dimension in dimensions {
181            metadata.dimensions_mut().add(dimension)?;
182        }
183
184        // Read global attributes
185        for attr_name in dataset.get_global_attr_names() {
186            if let Some(attr) = nc3_compat::read_global_attribute(dataset, &attr_name)? {
187                metadata.global_attributes_mut().add(attr)?;
188            }
189        }
190
191        // Read variables
192        for var_name in dataset.get_var_names() {
193            let var = nc3_compat::read_variable(dataset, &var_name)?;
194            metadata.variables_mut().add(var)?;
195        }
196
197        // Parse CF metadata
198        metadata.parse_cf_metadata();
199
200        Ok(metadata)
201    }
202
203    /// Convert NetCDF-3 data type to our data type.
204    #[cfg(feature = "netcdf3")]
205    fn convert_datatype_nc3(nc3_type: netcdf3::DataType) -> Result<DataType> {
206        use netcdf3::DataType as Nc3Type;
207
208        match nc3_type {
209            Nc3Type::I8 => Ok(DataType::I8),
210            Nc3Type::I16 => Ok(DataType::I16),
211            Nc3Type::I32 => Ok(DataType::I32),
212            Nc3Type::F32 => Ok(DataType::F32),
213            Nc3Type::F64 => Ok(DataType::F64),
214            Nc3Type::U8 => Ok(DataType::Char), // U8 in netcdf3 v0.6 represents character data
215        }
216    }
217
218    /// Read variable data as f32.
219    ///
220    /// # Errors
221    ///
222    /// Returns error if variable not found or data cannot be read.
223    pub fn read_f32(&self, var_name: &str) -> Result<Vec<f32>> {
224        if let Some(nc4) = &self.nc4 {
225            return nc4.read_f32(var_name);
226        }
227
228        #[cfg(feature = "netcdf3")]
229        if let Some(ref file_cell) = self.file_nc3 {
230            return Self::read_f32_nc3(&mut file_cell.borrow_mut(), var_name);
231        }
232
233        Err(NetCdfError::FeatureNotEnabled {
234            feature: "netcdf reader".to_string(),
235            message: "No reader backend available".to_string(),
236        })
237    }
238
239    /// Read variable data as f64.
240    ///
241    /// # Errors
242    ///
243    /// Returns error if variable not found or data cannot be read.
244    pub fn read_f64(&self, var_name: &str) -> Result<Vec<f64>> {
245        if let Some(nc4) = &self.nc4 {
246            return nc4.read_f64(var_name);
247        }
248
249        #[cfg(feature = "netcdf3")]
250        if let Some(ref file_cell) = self.file_nc3 {
251            return Self::read_f64_nc3(&mut file_cell.borrow_mut(), var_name);
252        }
253
254        Err(NetCdfError::FeatureNotEnabled {
255            feature: "netcdf reader".to_string(),
256            message: "No reader backend available".to_string(),
257        })
258    }
259
260    /// Read variable data as i32.
261    ///
262    /// # Errors
263    ///
264    /// Returns error if variable not found or data cannot be read.
265    pub fn read_i32(&self, var_name: &str) -> Result<Vec<i32>> {
266        if let Some(nc4) = &self.nc4 {
267            return nc4.read_i32(var_name);
268        }
269
270        #[cfg(feature = "netcdf3")]
271        if let Some(ref file_cell) = self.file_nc3 {
272            return Self::read_i32_nc3(&mut file_cell.borrow_mut(), var_name);
273        }
274
275        Err(NetCdfError::FeatureNotEnabled {
276            feature: "netcdf reader".to_string(),
277            message: "No reader backend available".to_string(),
278        })
279    }
280
281    /// Read f32 data from NetCDF-3 file.
282    #[cfg(feature = "netcdf3")]
283    fn read_f32_nc3(file: &mut netcdf3::FileReader, var_name: &str) -> Result<Vec<f32>> {
284        let dataset = file.data_set();
285        let var_info = dataset
286            .get_var(var_name)
287            .ok_or_else(|| NetCdfError::VariableNotFound {
288                name: var_name.to_string(),
289            })?;
290
291        use netcdf3::DataType as Nc3Type;
292        let data_type = var_info.data_type();
293
294        if data_type != Nc3Type::F32 {
295            return Err(NetCdfError::DataTypeMismatch {
296                expected: "F32".to_string(),
297                found: format!("{:?}", data_type),
298            });
299        }
300
301        let data = file.read_var_f32(var_name)?;
302        Ok(data)
303    }
304
305    /// Read f64 data from NetCDF-3 file.
306    #[cfg(feature = "netcdf3")]
307    fn read_f64_nc3(file: &mut netcdf3::FileReader, var_name: &str) -> Result<Vec<f64>> {
308        let dataset = file.data_set();
309        let var_info = dataset
310            .get_var(var_name)
311            .ok_or_else(|| NetCdfError::VariableNotFound {
312                name: var_name.to_string(),
313            })?;
314
315        use netcdf3::DataType as Nc3Type;
316        let data_type = var_info.data_type();
317
318        if data_type != Nc3Type::F64 {
319            return Err(NetCdfError::DataTypeMismatch {
320                expected: "F64".to_string(),
321                found: format!("{:?}", data_type),
322            });
323        }
324
325        let data = file.read_var_f64(var_name)?;
326        Ok(data)
327    }
328
329    /// Read i32 data from NetCDF-3 file.
330    #[cfg(feature = "netcdf3")]
331    fn read_i32_nc3(file: &mut netcdf3::FileReader, var_name: &str) -> Result<Vec<i32>> {
332        let dataset = file.data_set();
333        let var_info = dataset
334            .get_var(var_name)
335            .ok_or_else(|| NetCdfError::VariableNotFound {
336                name: var_name.to_string(),
337            })?;
338
339        use netcdf3::DataType as Nc3Type;
340        let data_type = var_info.data_type();
341
342        if data_type != Nc3Type::I32 {
343            return Err(NetCdfError::DataTypeMismatch {
344                expected: "I32".to_string(),
345                found: format!("{:?}", data_type),
346            });
347        }
348
349        let data = file.read_var_i32(var_name)?;
350        Ok(data)
351    }
352}
353
354impl Nc4Backend {
355    /// Resolve `var_name` to its HDF5 dataset path.
356    fn path(&self, var_name: &str) -> Result<&str> {
357        self.var_paths
358            .get(var_name)
359            .map(String::as_str)
360            .ok_or_else(|| NetCdfError::VariableNotFound {
361                name: var_name.to_string(),
362            })
363    }
364
365    /// Read a variable's data as `f32`.
366    fn read_f32(&self, var_name: &str) -> Result<Vec<f32>> {
367        let path = self.path(var_name)?;
368        let ds = self
369            .file
370            .h5()
371            .dataset(path)
372            .map_err(|e| NetCdfError::Io(format!("read '{var_name}': {e}")))?;
373        ds.as_f32().map_err(|e| NetCdfError::DataTypeMismatch {
374            expected: "f32".to_string(),
375            found: format!("{e}"),
376        })
377    }
378
379    /// Read a variable's data as `f64`.
380    fn read_f64(&self, var_name: &str) -> Result<Vec<f64>> {
381        let path = self.path(var_name)?;
382        let ds = self
383            .file
384            .h5()
385            .dataset(path)
386            .map_err(|e| NetCdfError::Io(format!("read '{var_name}': {e}")))?;
387        ds.as_f64().map_err(|e| NetCdfError::DataTypeMismatch {
388            expected: "f64".to_string(),
389            found: format!("{e}"),
390        })
391    }
392
393    /// Read a variable's data as `i32`.
394    fn read_i32(&self, var_name: &str) -> Result<Vec<i32>> {
395        let path = self.path(var_name)?;
396        let ds = self
397            .file
398            .h5()
399            .dataset(path)
400            .map_err(|e| NetCdfError::Io(format!("read '{var_name}': {e}")))?;
401        ds.as_i32().map_err(|e| NetCdfError::DataTypeMismatch {
402            expected: "i32".to_string(),
403            found: format!("{e}"),
404        })
405    }
406}
407
408// ---------------------------------------------------------------------------
409// oxinetcdf → crate model mapping
410// ---------------------------------------------------------------------------
411
412/// Map an oxinetcdf error into the crate's error type.
413fn map_nc_err(e: oxinetcdf::NcError) -> NetCdfError {
414    match e {
415        oxinetcdf::NcError::VariableNotFound(name) => NetCdfError::VariableNotFound { name },
416        oxinetcdf::NcError::Unsupported(msg) => NetCdfError::Other(format!("unsupported: {msg}")),
417        other => NetCdfError::InvalidFormat(other.to_string()),
418    }
419}
420
421/// Build [`NetCdfMetadata`] from a resolved oxinetcdf root group.
422fn build_metadata_from_group(root: &NcGroup) -> Result<NetCdfMetadata> {
423    // NETCDF4_CLASSIC files carry a `_nc3_strict` root attribute.
424    let version = if root.attrs.iter().any(|a| a.name == "_nc3_strict") {
425        NetCdfVersion::NetCdf4Classic
426    } else {
427        NetCdfVersion::NetCdf4
428    };
429    let mut metadata = NetCdfMetadata::new(version);
430
431    // Dimensions declared on the group.
432    for d in &root.dimensions {
433        add_dimension(&mut metadata, &d.name, d.len, d.is_unlimited)?;
434    }
435
436    // Variables.
437    for v in &root.variables {
438        let Some(data_type) = dtype_to_datatype(&v.dtype) else {
439            // Enum / compound / opaque / vlen have no faithful DataType mapping;
440            // skip rather than misreport the type.
441            continue;
442        };
443
444        // Ensure every referenced axis exists as a dimension (covers oxinetcdf
445        // phony dimensions synthesised for undimensioned datasets).
446        for axis in &v.dims {
447            if !metadata.dimensions().contains(&axis.name) {
448                add_dimension(&mut metadata, &axis.name, axis.len, axis.is_unlimited)?;
449            }
450        }
451
452        let dim_names: Vec<String> = v.dims.iter().map(|a| a.name.clone()).collect();
453        let mut variable = Variable::new(&v.name, data_type, dim_names)?;
454        variable.set_coordinate(v.is_coordinate);
455        for attr in &v.attrs {
456            if let Some(value) = nc_attr_to_value(attr)
457                && let Ok(a) = Attribute::new(attr.name.clone(), value)
458            {
459                variable.attributes_mut().set(a);
460            }
461        }
462        // Ignore duplicate variable names defensively.
463        let _ = metadata.variables_mut().add(variable);
464    }
465
466    // Global attributes.
467    for attr in &root.attrs {
468        if let Some(value) = nc_attr_to_value(attr)
469            && let Ok(a) = Attribute::new(attr.name.clone(), value)
470        {
471            let _ = metadata.global_attributes_mut().add(a);
472        }
473    }
474
475    Ok(metadata)
476}
477
478/// Add a dimension to `metadata`, ignoring duplicates.
479fn add_dimension(
480    metadata: &mut NetCdfMetadata,
481    name: &str,
482    len: u64,
483    unlimited: bool,
484) -> Result<()> {
485    if metadata.dimensions().contains(name) {
486        return Ok(());
487    }
488    let len = usize::try_from(len).map_err(|_| NetCdfError::InvalidShape {
489        message: format!("dimension '{name}' length {len} exceeds usize"),
490    })?;
491    let dim = if unlimited {
492        Dimension::new_unlimited(name, len)?
493    } else {
494        Dimension::new(name, len)?
495    };
496    let _ = metadata.dimensions_mut().add(dim);
497    Ok(())
498}
499
500/// Map an oxih5 datatype to the crate's [`DataType`].
501///
502/// Returns `None` for datatypes (enum, compound, opaque, vlen, …) that have no
503/// faithful NetCDF scalar mapping.
504fn dtype_to_datatype(dtype: &Dtype) -> Option<DataType> {
505    Some(match dtype {
506        Dtype::Float { size: 4, .. } => DataType::F32,
507        Dtype::Float { size: 8, .. } => DataType::F64,
508        Dtype::Int {
509            size: 1,
510            signed: true,
511            ..
512        } => DataType::I8,
513        Dtype::Int {
514            size: 1,
515            signed: false,
516            ..
517        } => DataType::U8,
518        Dtype::Int {
519            size: 2,
520            signed: true,
521            ..
522        } => DataType::I16,
523        Dtype::Int {
524            size: 2,
525            signed: false,
526            ..
527        } => DataType::U16,
528        Dtype::Int {
529            size: 4,
530            signed: true,
531            ..
532        } => DataType::I32,
533        Dtype::Int {
534            size: 4,
535            signed: false,
536            ..
537        } => DataType::U32,
538        Dtype::Int {
539            size: 8,
540            signed: true,
541            ..
542        } => DataType::I64,
543        Dtype::Int {
544            size: 8,
545            signed: false,
546            ..
547        } => DataType::U64,
548        Dtype::String {
549            fixed_len: Some(1), ..
550        } => DataType::Char,
551        Dtype::String { .. } => DataType::String,
552        _ => return None,
553    })
554}
555
556/// Map an oxinetcdf attribute to the crate's [`AttributeValue`].
557///
558/// Returns `None` for datatypes that cannot be represented (e.g. compound).
559fn nc_attr_to_value(attr: &NcAttribute) -> Option<AttributeValue> {
560    let raw = attr.raw();
561    let value = match &raw.dtype {
562        Dtype::String { .. } => AttributeValue::Text(attr.as_text().ok()?),
563        Dtype::Float { size: 4, order } => AttributeValue::F32(decode_f32(&raw.data, order)),
564        Dtype::Float { size: 8, order } => AttributeValue::F64(decode_f64(&raw.data, order)),
565        Dtype::Int {
566            size: 1,
567            signed: true,
568            ..
569        } => AttributeValue::I8(raw.data.iter().map(|&b| b as i8).collect()),
570        Dtype::Int {
571            size: 1,
572            signed: false,
573            ..
574        } => AttributeValue::U8(raw.data.clone()),
575        Dtype::Int {
576            size: 2,
577            signed: true,
578            order,
579        } => AttributeValue::I16(decode_chunks(
580            &raw.data,
581            order,
582            i16::from_le_bytes,
583            i16::from_be_bytes,
584        )),
585        Dtype::Int {
586            size: 2,
587            signed: false,
588            order,
589        } => AttributeValue::U16(decode_chunks(
590            &raw.data,
591            order,
592            u16::from_le_bytes,
593            u16::from_be_bytes,
594        )),
595        Dtype::Int {
596            size: 4,
597            signed: true,
598            order,
599        } => AttributeValue::I32(decode_chunks(
600            &raw.data,
601            order,
602            i32::from_le_bytes,
603            i32::from_be_bytes,
604        )),
605        Dtype::Int {
606            size: 4,
607            signed: false,
608            order,
609        } => AttributeValue::U32(decode_chunks(
610            &raw.data,
611            order,
612            u32::from_le_bytes,
613            u32::from_be_bytes,
614        )),
615        Dtype::Int {
616            size: 8,
617            signed: true,
618            order,
619        } => AttributeValue::I64(decode_chunks(
620            &raw.data,
621            order,
622            i64::from_le_bytes,
623            i64::from_be_bytes,
624        )),
625        Dtype::Int {
626            size: 8,
627            signed: false,
628            order,
629        } => AttributeValue::U64(decode_chunks(
630            &raw.data,
631            order,
632            u64::from_le_bytes,
633            u64::from_be_bytes,
634        )),
635        _ => return None,
636    };
637    Some(value)
638}
639
640/// Decode a little/big-endian byte buffer into a `Vec<T>` using fixed-width
641/// array conversions. The chunk width is inferred from `N`.
642fn decode_chunks<T, const N: usize>(
643    data: &[u8],
644    order: &ByteOrder,
645    from_le: fn([u8; N]) -> T,
646    from_be: fn([u8; N]) -> T,
647) -> Vec<T> {
648    data.chunks_exact(N)
649        .filter_map(|c| <[u8; N]>::try_from(c).ok())
650        .map(|arr| match order {
651            ByteOrder::Little => from_le(arr),
652            ByteOrder::Big => from_be(arr),
653        })
654        .collect()
655}
656
657/// Decode a byte buffer into `Vec<f32>`.
658fn decode_f32(data: &[u8], order: &ByteOrder) -> Vec<f32> {
659    decode_chunks(data, order, f32::from_le_bytes, f32::from_be_bytes)
660}
661
662/// Decode a byte buffer into `Vec<f64>`.
663fn decode_f64(data: &[u8], order: &ByteOrder) -> Vec<f64> {
664    decode_chunks(data, order, f64::from_le_bytes, f64::from_be_bytes)
665}
666
667#[cfg(test)]
668mod tests {
669    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
670    use super::*;
671    use oxinetcdf::{NcFileWriter, NcType, VarOrGroup};
672
673    /// Write a real NetCDF-4 (HDF5) file with the Pure-Rust oxinetcdf writer
674    /// and return its path. The file carries a `lat`/`lon` grid, a `temp`
675    /// data variable, CF-style attributes, and a global `Conventions`.
676    fn write_real_nc4_fixture(tag: &str) -> std::path::PathBuf {
677        let path = std::env::temp_dir().join(format!(
678            "oxigdal_nc4_reader_{}_{}.nc",
679            tag,
680            std::process::id()
681        ));
682
683        let mut nc = NcFileWriter::new();
684        let lat = nc.def_dim("lat", 3).expect("def lat");
685        let lon = nc.def_dim("lon", 4).expect("def lon");
686        let temp = nc
687            .def_var("temp", &[lat, lon], NcType::Float64)
688            .expect("def temp");
689        let data: Vec<f64> = (0..12).map(|i| i as f64 * 0.5).collect();
690        nc.put_var_f64(temp, &data).expect("put temp");
691        nc.put_att_str(VarOrGroup::Var(temp), "units", "kelvin")
692            .expect("units");
693        nc.put_att_str(VarOrGroup::Var(temp), "standard_name", "air_temperature")
694            .expect("standard_name");
695        nc.put_att_str(VarOrGroup::Root, "Conventions", "CF-1.8")
696            .expect("conventions");
697        nc.put_att_str(VarOrGroup::Root, "title", "Reader Fixture")
698            .expect("title");
699        nc.close(&path).expect("close fixture");
700        path
701    }
702
703    #[test]
704    fn test_open_real_netcdf4_reads_dimensions_and_variables() {
705        let path = write_real_nc4_fixture("dims");
706        let reader = NetCdfReader::open(&path).expect("open real NetCDF-4");
707
708        assert!(reader.version().is_netcdf4());
709
710        // lat + lon dimensions.
711        assert_eq!(reader.dimensions().len(), 2);
712        assert_eq!(reader.dimensions().get("lat").expect("lat dim").len(), 3);
713        assert_eq!(reader.dimensions().get("lon").expect("lon dim").len(), 4);
714
715        // lat, lon coordinate variables + temp data variable.
716        let temp = reader.variables().get("temp").expect("temp var");
717        assert_eq!(temp.data_type(), DataType::F64);
718        assert_eq!(temp.dimension_names(), &["lat", "lon"]);
719
720        let _ = std::fs::remove_file(&path);
721    }
722
723    #[test]
724    fn test_open_real_netcdf4_reads_variable_data() {
725        let path = write_real_nc4_fixture("data");
726        let reader = NetCdfReader::open(&path).expect("open real NetCDF-4");
727
728        let data = reader.read_f64("temp").expect("read temp");
729        assert_eq!(data.len(), 12);
730        for (i, &v) in data.iter().enumerate() {
731            assert!((v - i as f64 * 0.5).abs() < 1e-12);
732        }
733
734        let _ = std::fs::remove_file(&path);
735    }
736
737    #[test]
738    fn test_open_real_netcdf4_reads_attributes_and_cf() {
739        let path = write_real_nc4_fixture("attrs");
740        let reader = NetCdfReader::open(&path).expect("open real NetCDF-4");
741
742        // Global CF metadata.
743        let cf = reader.cf_metadata().expect("cf metadata");
744        assert!(cf.is_cf_compliant());
745        assert_eq!(cf.conventions.as_deref(), Some("CF-1.8"));
746        assert_eq!(cf.title.as_deref(), Some("Reader Fixture"));
747
748        // Variable attributes.
749        let temp = reader.variables().get("temp").expect("temp var");
750        let units = temp.attributes().get("units").expect("units attr");
751        assert_eq!(units.value().as_text().expect("text"), "kelvin");
752        assert!(temp.attributes().get("standard_name").is_some());
753
754        // Reserved HDF5 convention attributes must not leak through.
755        assert!(temp.attributes().get("DIMENSION_LIST").is_none());
756        assert!(temp.attributes().get("CLASS").is_none());
757
758        let _ = std::fs::remove_file(&path);
759    }
760
761    #[test]
762    fn test_open_missing_variable_errors() {
763        let path = write_real_nc4_fixture("missing");
764        let reader = NetCdfReader::open(&path).expect("open real NetCDF-4");
765
766        let result = reader.read_f64("does_not_exist");
767        assert!(matches!(result, Err(NetCdfError::VariableNotFound { .. })));
768
769        let _ = std::fs::remove_file(&path);
770    }
771
772    #[test]
773    fn test_open_garbage_file_errors() {
774        let temp_dir = std::env::temp_dir();
775        let temp_file = temp_dir.join(format!("oxigdal_reader_garbage_{}.bin", std::process::id()));
776        std::fs::write(&temp_file, b"this is not any netcdf file").expect("write temp");
777
778        // Not NetCDF-3 and not a readable HDF5/NetCDF-4 file: fail loud.
779        let result = NetCdfReader::open(&temp_file);
780        assert!(result.is_err());
781        assert!(!matches!(result, Err(NetCdfError::NetCdf4NotAvailable)));
782
783        let _ = std::fs::remove_file(&temp_file);
784    }
785
786    #[test]
787    fn test_data_type_conversion() {
788        #[cfg(feature = "netcdf3")]
789        {
790            use netcdf3::DataType as Nc3Type;
791            assert_eq!(
792                NetCdfReader::convert_datatype_nc3(Nc3Type::F32).expect("convert F32"),
793                DataType::F32
794            );
795            assert_eq!(
796                NetCdfReader::convert_datatype_nc3(Nc3Type::F64).expect("convert F64"),
797                DataType::F64
798            );
799            assert_eq!(
800                NetCdfReader::convert_datatype_nc3(Nc3Type::I32).expect("convert I32"),
801                DataType::I32
802            );
803        }
804    }
805}