Skip to main content

scirs2_datasets/
netcdf_dataset.rs

1//! NetCDF3 dataset reader for climate and geospatial data
2//!
3//! Provides `NetCdfDataset` — reads NetCDF-3 Classic or 64-bit-offset files
4//! using the pure-Rust `netcdf3` crate. No feature gate is required; this
5//! module is available in all build configurations.
6//!
7//! NetCDF (Network Common Data Form) is widely used to store gridded
8//! scientific data such as climate model output, reanalysis data, and
9//! satellite observations.
10//!
11//! # Example
12//!
13//! ```rust,no_run
14//! use scirs2_datasets::netcdf_dataset::NetCdfDataset;
15//!
16//! # fn example() -> Result<(), scirs2_datasets::error::DatasetsError> {
17//! let ds = NetCdfDataset::from_file("temperature.nc")?;
18//! println!("Variables: {:?}", ds.variable_names());
19//!
20//! if let Ok(arr) = ds.to_float_array("temperature") {
21//!     println!("First value: {}", arr[0]);
22//! }
23//! # Ok(())
24//! # }
25//! ```
26
27use crate::error::{DatasetsError, Result};
28use netcdf3::{DataSet, DataType, DataVector, FileReader, FileWriter, Version};
29use scirs2_core::ndarray::Array1;
30use std::path::Path;
31
32// ============================================================================
33// Public types
34// ============================================================================
35
36/// A named dimension with its size.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct NetCdfDimension {
39    /// Dimension name
40    pub name: String,
41    /// Dimension size (`None` for unlimited / record dimensions with zero
42    /// records written)
43    pub size: Option<usize>,
44}
45
46/// A scalar or vector attribute value.
47#[derive(Debug, Clone)]
48pub enum AttrValue {
49    /// Signed byte data (NC_BYTE / i8)
50    Byte(Vec<i8>),
51    /// Unsigned byte data — also used for NC_CHAR strings
52    UByte(Vec<u8>),
53    /// Short integer data (NC_SHORT / i16)
54    Short(Vec<i16>),
55    /// Integer data (NC_INT / i32)
56    Int(Vec<i32>),
57    /// Single-precision float data (NC_FLOAT / f32)
58    Float(Vec<f32>),
59    /// Double-precision float data (NC_DOUBLE / f64)
60    Double(Vec<f64>),
61    /// Character (string) attribute — decoded from NC_CHAR (U8) bytes
62    Char(String),
63}
64
65impl AttrValue {
66    fn from_attribute(attr: &netcdf3::Attribute) -> Self {
67        // Use the typed accessors on Attribute — `data` field is private
68        let dt = attr.data_type();
69        match dt {
70            DataType::I8 => attr
71                .get_i8()
72                .map(|s| AttrValue::Byte(s.to_vec()))
73                .unwrap_or(AttrValue::Byte(vec![])),
74            DataType::U8 => {
75                // U8 is also used for NC_CHAR strings; try as_string first
76                if let Some(s) = attr.get_as_string() {
77                    AttrValue::Char(s)
78                } else {
79                    attr.get_u8()
80                        .map(|s| AttrValue::UByte(s.to_vec()))
81                        .unwrap_or(AttrValue::UByte(vec![]))
82                }
83            }
84            DataType::I16 => attr
85                .get_i16()
86                .map(|s| AttrValue::Short(s.to_vec()))
87                .unwrap_or(AttrValue::Short(vec![])),
88            DataType::I32 => attr
89                .get_i32()
90                .map(|s| AttrValue::Int(s.to_vec()))
91                .unwrap_or(AttrValue::Int(vec![])),
92            DataType::F32 => attr
93                .get_f32()
94                .map(|s| AttrValue::Float(s.to_vec()))
95                .unwrap_or(AttrValue::Float(vec![])),
96            DataType::F64 => attr
97                .get_f64()
98                .map(|s| AttrValue::Double(s.to_vec()))
99                .unwrap_or(AttrValue::Double(vec![])),
100        }
101    }
102}
103
104/// A single attribute (name + value).
105#[derive(Debug, Clone)]
106pub struct NetCdfAttribute {
107    /// Attribute name
108    pub name: String,
109    /// Attribute value
110    pub value: AttrValue,
111}
112
113/// Column-data of a NetCDF variable.
114#[derive(Debug, Clone)]
115pub enum NcData {
116    /// Single-precision floats (NC_FLOAT)
117    Float(Array1<f32>),
118    /// Double-precision floats (NC_DOUBLE)
119    Double(Array1<f64>),
120    /// 32-bit integers (NC_INT)
121    Int(Array1<i32>),
122    /// 16-bit integers (NC_SHORT)
123    Short(Array1<i16>),
124    /// Signed bytes (NC_BYTE)
125    Byte(Vec<i8>),
126    /// Unsigned bytes (NC_CHAR — raw bytes, not decoded as string)
127    UByte(Vec<u8>),
128}
129
130impl NcData {
131    fn from_data_vector(dv: DataVector) -> Self {
132        match dv {
133            DataVector::F32(v) => NcData::Float(Array1::from_vec(v)),
134            DataVector::F64(v) => NcData::Double(Array1::from_vec(v)),
135            DataVector::I32(v) => NcData::Int(Array1::from_vec(v)),
136            DataVector::I16(v) => NcData::Short(Array1::from_vec(v)),
137            DataVector::I8(v) => NcData::Byte(v),
138            DataVector::U8(v) => NcData::UByte(v),
139        }
140    }
141
142    /// Cast contents to a flat `Array1<f32>` (for Float data only).
143    ///
144    /// Returns `None` for non-Float variants.
145    pub fn as_float_array(&self) -> Option<&Array1<f32>> {
146        if let NcData::Float(arr) = self {
147            Some(arr)
148        } else {
149            None
150        }
151    }
152
153    /// Cast contents to a flat `Array1<f64>` (for Double data only).
154    pub fn as_double_array(&self) -> Option<&Array1<f64>> {
155        if let NcData::Double(arr) = self {
156            Some(arr)
157        } else {
158            None
159        }
160    }
161
162    /// Number of elements.
163    pub fn len(&self) -> usize {
164        match self {
165            NcData::Float(a) => a.len(),
166            NcData::Double(a) => a.len(),
167            NcData::Int(a) => a.len(),
168            NcData::Short(a) => a.len(),
169            NcData::Byte(v) => v.len(),
170            NcData::UByte(v) => v.len(),
171        }
172    }
173
174    /// Returns `true` if there are no elements.
175    pub fn is_empty(&self) -> bool {
176        self.len() == 0
177    }
178}
179
180/// A NetCDF variable including its dimensions, attributes, and data.
181#[derive(Debug, Clone)]
182pub struct NetCdfVariable {
183    /// Variable name
184    pub name: String,
185    /// Dimension names (in order)
186    pub dimensions: Vec<String>,
187    /// Variable attributes
188    pub attributes: Vec<NetCdfAttribute>,
189    /// NetCDF data type
190    pub dtype: DataType,
191    /// Actual data, read on load
192    pub data: NcData,
193}
194
195/// A complete NetCDF3 dataset loaded into memory.
196#[derive(Debug, Clone)]
197pub struct NetCdfDataset {
198    /// Ordered list of dimensions
199    pub dimensions: Vec<NetCdfDimension>,
200    /// Global attributes
201    pub global_attributes: Vec<NetCdfAttribute>,
202    /// Variables (including coordinate variables)
203    pub variables: Vec<NetCdfVariable>,
204}
205
206// ============================================================================
207// Implementation
208// ============================================================================
209
210impl NetCdfDataset {
211    /// Read a NetCDF3 file from disk.
212    ///
213    /// # Errors
214    ///
215    /// Returns `DatasetsError` if the file cannot be opened, is not a valid
216    /// NetCDF3 file, or a variable's data cannot be read.
217    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
218        let path = path.as_ref();
219        if !path.exists() {
220            return Err(DatasetsError::NotFound(format!(
221                "NetCDF file not found: {}",
222                path.display()
223            )));
224        }
225
226        let mut reader = FileReader::open(path)
227            .map_err(|e| DatasetsError::InvalidFormat(format!("NetCDF3 open error: {e:?}")))?;
228
229        Self::from_reader(&mut reader)
230    }
231
232    /// Parse a NetCDF3 dataset from an in-memory byte slice.
233    ///
234    /// The bytes must start with the NetCDF3 magic (`CDF\x01` or `CDF\x02`).
235    ///
236    /// Internally the bytes are written to a temporary file and read back via
237    /// the `netcdf3` crate, which requires a seekable `Read` backed by a real
238    /// file.
239    ///
240    /// # Errors
241    ///
242    /// Returns `DatasetsError::InvalidFormat` if the bytes are not valid
243    /// NetCDF3 or if reading fails.
244    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
245        use std::io::Write;
246
247        let dir = tempfile::tempdir().map_err(DatasetsError::IoError)?;
248        let path = dir.path().join("from_bytes.nc");
249
250        std::fs::File::create(&path)
251            .map_err(DatasetsError::IoError)?
252            .write_all(bytes)
253            .map_err(DatasetsError::IoError)?;
254
255        let mut reader = FileReader::open(&path)
256            .map_err(|e| DatasetsError::InvalidFormat(format!("NetCDF3 parse error: {e:?}")))?;
257
258        Self::from_reader(&mut reader)
259    }
260
261    /// Internal: extract dataset from an already-opened `FileReader`.
262    fn from_reader(reader: &mut FileReader) -> Result<Self> {
263        let ds: &DataSet = reader.data_set();
264
265        // ── Dimensions ────────────────────────────────────────────────────
266        let dimensions: Vec<NetCdfDimension> = ds
267            .get_dims()
268            .iter()
269            .map(|dim| NetCdfDimension {
270                name: dim.name(),
271                size: ds.dim_size(&dim.name()),
272            })
273            .collect();
274
275        // ── Global attributes ─────────────────────────────────────────────
276        let global_attributes: Vec<NetCdfAttribute> = ds
277            .get_global_attrs()
278            .iter()
279            .map(|attr| NetCdfAttribute {
280                name: attr.name().to_owned(),
281                value: AttrValue::from_attribute(attr),
282            })
283            .collect();
284
285        // ── Variable metadata ─────────────────────────────────────────────
286        let var_names: Vec<String> = ds.get_var_names();
287        // Collect metadata while we have &DataSet
288        let var_meta: Vec<(String, Vec<String>, Vec<NetCdfAttribute>, DataType)> = var_names
289            .iter()
290            .filter_map(|var_name| {
291                let var_def = ds.get_var(var_name)?;
292                let dim_names: Vec<String> = var_def.dim_names();
293                let attrs: Vec<NetCdfAttribute> = ds
294                    .get_var_attrs(var_name)
295                    .unwrap_or_default()
296                    .iter()
297                    .map(|attr| NetCdfAttribute {
298                        name: attr.name().to_owned(),
299                        value: AttrValue::from_attribute(attr),
300                    })
301                    .collect();
302                let dtype = var_def.data_type();
303                Some((var_name.clone(), dim_names, attrs, dtype))
304            })
305            .collect();
306
307        // ── Read all variable data ────────────────────────────────────────
308        let var_data_map = reader
309            .read_all_vars()
310            .map_err(|e| DatasetsError::InvalidFormat(format!("NetCDF3 read vars error: {e:?}")))?;
311
312        // ── Assemble variables ────────────────────────────────────────────
313        let mut variables: Vec<NetCdfVariable> = Vec::with_capacity(var_meta.len());
314        for (var_name, dim_names, attrs, dtype) in var_meta {
315            let data_vec = var_data_map
316                .get(&var_name)
317                .ok_or_else(|| DatasetsError::NotFound(format!("Data for '{var_name}' missing")))?
318                .clone();
319
320            let data = NcData::from_data_vector(data_vec);
321
322            variables.push(NetCdfVariable {
323                name: var_name,
324                dimensions: dim_names,
325                attributes: attrs,
326                dtype,
327                data,
328            });
329        }
330
331        Ok(Self {
332            dimensions,
333            global_attributes,
334            variables,
335        })
336    }
337
338    // ── Lookup helpers ────────────────────────────────────────────────────
339
340    /// Look up a variable by name.
341    pub fn variable(&self, name: &str) -> Option<&NetCdfVariable> {
342        self.variables.iter().find(|v| v.name == name)
343    }
344
345    /// Look up a dimension by name.
346    pub fn dimension(&self, name: &str) -> Option<&NetCdfDimension> {
347        self.dimensions.iter().find(|d| d.name == name)
348    }
349
350    /// Return all variable names.
351    pub fn variable_names(&self) -> Vec<&str> {
352        self.variables.iter().map(|v| v.name.as_str()).collect()
353    }
354
355    /// Return all dimension names.
356    pub fn dimension_names(&self) -> Vec<&str> {
357        self.dimensions.iter().map(|d| d.name.as_str()).collect()
358    }
359
360    /// Get a float (f32) array for the named variable.
361    ///
362    /// # Errors
363    ///
364    /// Returns `NotFound` if no variable with that name exists, or
365    /// `InvalidFormat` if the variable's data is not a Float32 type.
366    pub fn to_float_array(&self, var_name: &str) -> Result<Array1<f32>> {
367        let var = self
368            .variable(var_name)
369            .ok_or_else(|| DatasetsError::NotFound(format!("Variable '{var_name}' not found")))?;
370        match &var.data {
371            NcData::Float(arr) => Ok(arr.clone()),
372            _ => Err(DatasetsError::InvalidFormat(format!(
373                "Variable '{var_name}' is not Float32 (actual dtype: {:?})",
374                var.dtype
375            ))),
376        }
377    }
378
379    /// Get a double (f64) array for the named variable.
380    ///
381    /// # Errors
382    ///
383    /// Returns `NotFound` if no variable with that name exists, or
384    /// `InvalidFormat` if the variable's data is not a Float64 type.
385    pub fn to_double_array(&self, var_name: &str) -> Result<Array1<f64>> {
386        let var = self
387            .variable(var_name)
388            .ok_or_else(|| DatasetsError::NotFound(format!("Variable '{var_name}' not found")))?;
389        match &var.data {
390            NcData::Double(arr) => Ok(arr.clone()),
391            _ => Err(DatasetsError::InvalidFormat(format!(
392                "Variable '{var_name}' is not Float64 (actual dtype: {:?})",
393                var.dtype
394            ))),
395        }
396    }
397
398    /// Promote any numeric variable to f64, regardless of underlying type.
399    ///
400    /// Characters and raw bytes are excluded.
401    ///
402    /// # Errors
403    ///
404    /// Returns `NotFound` or `InvalidFormat` as appropriate.
405    pub fn to_f64_array(&self, var_name: &str) -> Result<Array1<f64>> {
406        let var = self
407            .variable(var_name)
408            .ok_or_else(|| DatasetsError::NotFound(format!("Variable '{var_name}' not found")))?;
409        match &var.data {
410            NcData::Float(a) => Ok(a.mapv(|v| v as f64)),
411            NcData::Double(a) => Ok(a.clone()),
412            NcData::Int(a) => Ok(a.mapv(|v| v as f64)),
413            NcData::Short(a) => Ok(a.mapv(|v| v as f64)),
414            _ => Err(DatasetsError::InvalidFormat(format!(
415                "Variable '{var_name}' cannot be cast to f64 (dtype: {:?})",
416                var.dtype
417            ))),
418        }
419    }
420}
421
422// ============================================================================
423// Helper: build a minimal in-memory NetCDF3 file for tests
424// ============================================================================
425
426/// Write a minimal NetCDF3 Classic file with one fixed dimension and one
427/// Float32 variable, returning the raw bytes. Used in unit tests.
428///
429/// # Errors
430///
431/// Returns `DatasetsError` if the temporary file cannot be created or written.
432#[doc(hidden)]
433pub fn write_test_nc3_bytes(
434    dim_name: &str,
435    dim_size: usize,
436    var_name: &str,
437    data: &[f32],
438) -> Result<Vec<u8>> {
439    use std::io::Read;
440
441    let dir = tempfile::tempdir().map_err(DatasetsError::IoError)?;
442    let path = dir.path().join("test.nc");
443
444    let mut dataset = DataSet::new();
445    dataset
446        .add_fixed_dim(dim_name, dim_size)
447        .map_err(|e| DatasetsError::InvalidFormat(format!("NC3 dim error: {e:?}")))?;
448    dataset
449        .add_var_f32(var_name, &[dim_name])
450        .map_err(|e| DatasetsError::InvalidFormat(format!("NC3 var error: {e:?}")))?;
451
452    let mut writer = FileWriter::open(&path)
453        .map_err(|e| DatasetsError::InvalidFormat(format!("NC3 writer error: {e:?}")))?;
454    writer
455        .set_def(&dataset, Version::Classic, 0)
456        .map_err(|e| DatasetsError::InvalidFormat(format!("NC3 set_def error: {e:?}")))?;
457    writer
458        .write_var_f32(var_name, data)
459        .map_err(|e| DatasetsError::InvalidFormat(format!("NC3 write error: {e:?}")))?;
460    writer
461        .close()
462        .map_err(|e| DatasetsError::InvalidFormat(format!("NC3 close error: {e:?}")))?;
463
464    let mut bytes = Vec::new();
465    std::fs::File::open(&path)
466        .map_err(DatasetsError::IoError)?
467        .read_to_end(&mut bytes)
468        .map_err(DatasetsError::IoError)?;
469
470    Ok(bytes)
471}
472
473// ============================================================================
474// Tests
475// ============================================================================
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    /// Create a temp NC3 file with one dimension and one Float32 variable.
482    fn make_nc3_file_f32(
483        dim_name: &str,
484        dim_size: usize,
485        var_name: &str,
486        data: &[f32],
487    ) -> (tempfile::TempDir, std::path::PathBuf) {
488        let dir = tempfile::tempdir().expect("tmpdir");
489        let path = dir.path().join("test.nc");
490
491        let mut dataset = DataSet::new();
492        dataset.add_fixed_dim(dim_name, dim_size).expect("add_dim");
493        dataset.add_var_f32(var_name, &[dim_name]).expect("add_var");
494
495        let mut writer = FileWriter::open(&path).expect("writer open");
496        writer
497            .set_def(&dataset, Version::Classic, 0)
498            .expect("set_def");
499        writer.write_var_f32(var_name, data).expect("write_var");
500        writer.close().expect("close");
501
502        (dir, path)
503    }
504
505    /// Create a temp NC3 file with Float64 variable.
506    fn make_nc3_file_f64(
507        dim_name: &str,
508        dim_size: usize,
509        var_name: &str,
510        data: &[f64],
511    ) -> (tempfile::TempDir, std::path::PathBuf) {
512        let dir = tempfile::tempdir().expect("tmpdir");
513        let path = dir.path().join("test.nc");
514
515        let mut dataset = DataSet::new();
516        dataset.add_fixed_dim(dim_name, dim_size).expect("add_dim");
517        dataset.add_var_f64(var_name, &[dim_name]).expect("add_var");
518
519        let mut writer = FileWriter::open(&path).expect("writer open");
520        writer
521            .set_def(&dataset, Version::Classic, 0)
522            .expect("set_def");
523        writer.write_var_f64(var_name, data).expect("write_var");
524        writer.close().expect("close");
525
526        (dir, path)
527    }
528
529    #[test]
530    fn test_from_file_f32_roundtrip() {
531        let data = vec![1.0_f32, 2.5, std::f32::consts::PI, -1.0];
532        let (_dir, path) = make_nc3_file_f32("time", 4, "temperature", &data);
533
534        let ds = NetCdfDataset::from_file(&path).expect("from_file");
535
536        assert_eq!(ds.variable_names(), vec!["temperature"]);
537        assert_eq!(ds.dimension_names(), vec!["time"]);
538
539        let arr = ds.to_float_array("temperature").expect("to_float_array");
540        assert_eq!(arr.len(), 4);
541        assert!((arr[0] - 1.0).abs() < 1e-6);
542        assert!((arr[2] - std::f32::consts::PI).abs() < 1e-6);
543    }
544
545    #[test]
546    fn test_from_file_f64_roundtrip() {
547        let data = vec![100.0_f64, 200.0, 300.5];
548        let (_dir, path) = make_nc3_file_f64("x", 3, "altitude", &data);
549
550        let ds = NetCdfDataset::from_file(&path).expect("from_file");
551        let arr = ds.to_double_array("altitude").expect("to_double_array");
552
553        assert_eq!(arr.len(), 3);
554        assert!((arr[1] - 200.0).abs() < 1e-12);
555    }
556
557    #[test]
558    fn test_dimension_lookup() {
559        let data = vec![0.0_f32; 5];
560        let (_dir, path) = make_nc3_file_f32("lat", 5, "temp", &data);
561
562        let ds = NetCdfDataset::from_file(&path).expect("from_file");
563        let dim = ds.dimension("lat").expect("dimension lat");
564        assert_eq!(dim.name, "lat");
565        assert_eq!(dim.size, Some(5));
566    }
567
568    #[test]
569    fn test_variable_not_found() {
570        let data = vec![1.0_f32];
571        let (_dir, path) = make_nc3_file_f32("d", 1, "v", &data);
572
573        let ds = NetCdfDataset::from_file(&path).expect("from_file");
574        let result = ds.to_float_array("nonexistent");
575        assert!(result.is_err());
576    }
577
578    #[test]
579    fn test_from_file_not_found() {
580        let result = NetCdfDataset::from_file("/tmp/__scirs2_nonexistent_9999.nc");
581        assert!(matches!(result, Err(DatasetsError::NotFound(_))));
582    }
583
584    #[test]
585    fn test_from_bytes_roundtrip() {
586        let data = vec![10.0_f32, 20.0, 30.0];
587        let bytes = write_test_nc3_bytes("x", 3, "signal", &data).expect("write bytes");
588
589        // Check magic
590        assert!(!bytes.is_empty());
591        assert_eq!(&bytes[0..3], b"CDF");
592
593        let ds = NetCdfDataset::from_bytes(&bytes).expect("from_bytes");
594        let arr = ds.to_float_array("signal").expect("to_float_array");
595
596        assert_eq!(arr.len(), 3);
597        assert!((arr[0] - 10.0).abs() < 1e-6);
598        assert!((arr[2] - 30.0).abs() < 1e-6);
599    }
600
601    #[test]
602    fn test_to_f64_array_from_f32_variable() {
603        let data = vec![1.5_f32, 2.5, 3.5];
604        let (_dir, path) = make_nc3_file_f32("n", 3, "values", &data);
605
606        let ds = NetCdfDataset::from_file(&path).expect("from_file");
607        let arr = ds.to_f64_array("values").expect("to_f64_array");
608
609        assert_eq!(arr.len(), 3);
610        assert!((arr[0] - 1.5).abs() < 1e-5);
611    }
612
613    #[test]
614    fn test_variable_dim_references() {
615        let data = vec![0.0_f32; 4];
616        let (_dir, path) = make_nc3_file_f32("time", 4, "u_wind", &data);
617
618        let ds = NetCdfDataset::from_file(&path).expect("from_file");
619        let var = ds.variable("u_wind").expect("variable u_wind");
620        assert_eq!(var.dimensions, vec!["time"]);
621        assert_eq!(var.dtype, DataType::F32);
622    }
623
624    #[test]
625    fn test_nc3_magic_bytes() {
626        let data = vec![0.0_f32; 1];
627        let bytes = write_test_nc3_bytes("d", 1, "v", &data).expect("write");
628        // Classic format starts with CDF\x01
629        assert_eq!(bytes[0], b'C');
630        assert_eq!(bytes[1], b'D');
631        assert_eq!(bytes[2], b'F');
632        assert_eq!(bytes[3], 0x01);
633    }
634
635    #[test]
636    fn test_global_attribute_reading() {
637        let dir = tempfile::tempdir().expect("tmpdir");
638        let path = dir.path().join("with_attr.nc");
639
640        let mut dataset = DataSet::new();
641        dataset.add_fixed_dim("t", 2).expect("add_dim");
642        dataset.add_var_f32("temp", &["t"]).expect("add_var");
643        dataset
644            .add_global_attr_string("institution", "Test Institute")
645            .expect("add_attr");
646
647        let mut writer = FileWriter::open(&path).expect("open");
648        writer
649            .set_def(&dataset, Version::Classic, 0)
650            .expect("set_def");
651        writer.write_var_f32("temp", &[1.0, 2.0]).expect("write");
652        writer.close().expect("close");
653
654        let ds = NetCdfDataset::from_file(&path).expect("from_file");
655        assert!(!ds.global_attributes.is_empty());
656
657        let inst = ds
658            .global_attributes
659            .iter()
660            .find(|a| a.name == "institution")
661            .expect("institution attr");
662        if let AttrValue::Char(s) = &inst.value {
663            assert_eq!(s, "Test Institute");
664        } else {
665            // Accept UByte too — NC_CHAR may come back as raw bytes depending on crate version
666            if let AttrValue::UByte(bytes) = &inst.value {
667                let decoded = String::from_utf8_lossy(bytes);
668                assert!(decoded.contains("Test Institute"));
669            } else {
670                panic!(
671                    "Expected Char or UByte attribute for institution, got: {:?}",
672                    inst.value
673                );
674            }
675        }
676    }
677
678    #[test]
679    fn test_variable_attribute_reading() {
680        let dir = tempfile::tempdir().expect("tmpdir");
681        let path = dir.path().join("var_attr.nc");
682
683        let mut dataset = DataSet::new();
684        dataset.add_fixed_dim("z", 3).expect("add_dim");
685        dataset.add_var_f32("pressure", &["z"]).expect("add_var");
686        dataset
687            .add_var_attr_string("pressure", "units", "hPa")
688            .expect("add_attr");
689
690        let mut writer = FileWriter::open(&path).expect("open");
691        writer
692            .set_def(&dataset, Version::Classic, 0)
693            .expect("set_def");
694        writer
695            .write_var_f32("pressure", &[1013.0, 850.0, 500.0])
696            .expect("write");
697        writer.close().expect("close");
698
699        let ds = NetCdfDataset::from_file(&path).expect("from_file");
700        let var = ds.variable("pressure").expect("pressure variable");
701
702        let units_attr = var
703            .attributes
704            .iter()
705            .find(|a| a.name == "units")
706            .expect("units attr");
707        // Accept Char or UByte (NC_CHAR) for the "units" attribute
708        match &units_attr.value {
709            AttrValue::Char(s) => assert_eq!(s, "hPa"),
710            AttrValue::UByte(b) => {
711                let decoded = String::from_utf8_lossy(b);
712                assert!(decoded.contains("hPa"));
713            }
714            other => panic!("Unexpected attribute variant: {:?}", other),
715        }
716    }
717
718    #[test]
719    fn test_i32_variable() {
720        let dir = tempfile::tempdir().expect("tmpdir");
721        let path = dir.path().join("int_var.nc");
722
723        let mut dataset = DataSet::new();
724        dataset.add_fixed_dim("n", 3).expect("add_dim");
725        dataset.add_var_i32("counts", &["n"]).expect("add_var");
726
727        let mut writer = FileWriter::open(&path).expect("open");
728        writer
729            .set_def(&dataset, Version::Classic, 0)
730            .expect("set_def");
731        writer
732            .write_var_i32("counts", &[10, 20, 30])
733            .expect("write");
734        writer.close().expect("close");
735
736        let ds = NetCdfDataset::from_file(&path).expect("from_file");
737        let arr = ds.to_f64_array("counts").expect("to_f64_array");
738
739        assert_eq!(arr.len(), 3);
740        assert!((arr[0] - 10.0).abs() < 1e-12);
741        assert!((arr[2] - 30.0).abs() < 1e-12);
742    }
743}