Skip to main content

netcdf_reader/
lib.rs

1//! Pure-Rust NetCDF file reader.
2//!
3//! Supports:
4//! - **CDF-1** (classic): `CDF\x01` magic
5//! - **CDF-2** (64-bit offset): `CDF\x02` magic
6//! - **CDF-5** (64-bit data): `CDF\x05` magic
7//! - **NetCDF-4** (HDF5-backed): `\x89HDF\r\n\x1a\n` magic (requires `netcdf4` feature)
8//!
9//! PnetCDF-produced CDF-1/2/5 files are supported as files. NetCDF-C files
10//! created through parallel NetCDF-4/HDF5 APIs are supported when the final
11//! HDF5 file uses supported HDF5 features. This crate does not implement
12//! MPI communicators, `nc_open_par`, `nc_create_par`, collective/independent
13//! access modes, PnetCDF subfiling, or write APIs.
14//!
15//! # Example
16//!
17//! ```no_run
18//! use netcdf_reader::NcFile;
19//!
20//! let file = NcFile::open("example.nc").unwrap();
21//! println!("format: {:?}", file.format());
22//! for var in file.variables().unwrap() {
23//!     println!("  variable: {} shape={:?}", var.name(), var.shape());
24//! }
25//! ```
26
27pub mod classic;
28pub mod error;
29pub mod masked;
30pub mod types;
31pub mod unpack;
32
33#[cfg(feature = "netcdf4")]
34pub mod nc4;
35#[cfg(feature = "netcdf4")]
36pub mod user_defined;
37
38#[cfg(feature = "cf")]
39pub mod cf;
40
41pub use error::{Error, Result};
42#[cfg(feature = "netcdf4")]
43pub use hdf5_reader::storage::DynStorage;
44#[cfg(feature = "netcdf4")]
45pub use hdf5_reader::{
46    BlockCacheStats, BlockCacheStorage, BytesStorage, ChunkCacheStats, DatasetChunk,
47    DatasetChunkIterator, ExternalFileResolver, ExternalLinkResolver, FileStorage,
48    FilesystemExternalFileResolver, FilesystemExternalLinkResolver, MmapStorage,
49    RangeRequestStorage, Storage, StorageBuffer,
50};
51pub use types::*;
52#[cfg(feature = "netcdf4")]
53pub use user_defined::{
54    NcArrayValue, NcCompoundFieldView, NcCompoundValueField, NcEnumValue, NcValue, NcValueView,
55};
56
57use std::fs::File;
58use std::io::Read;
59use std::path::Path;
60#[cfg(feature = "netcdf4")]
61use std::sync::Arc;
62
63use memmap2::Mmap;
64use ndarray::ArrayD;
65#[cfg(feature = "rayon")]
66use rayon::ThreadPool;
67
68/// Trait alias for types readable from both classic and NetCDF-4 files.
69///
70/// This unifies `classic::data::NcReadType` (for CDF-1/2/5) and
71/// `hdf5_reader::H5Type` (for NetCDF-4/HDF5) so that `NcFile::read_variable`
72/// works across all formats with a single type parameter.
73#[cfg(feature = "netcdf4")]
74pub trait NcReadable: classic::data::NcReadType + hdf5_reader::H5Type {}
75#[cfg(feature = "netcdf4")]
76impl<T: classic::data::NcReadType + hdf5_reader::H5Type> NcReadable for T {}
77
78#[cfg(not(feature = "netcdf4"))]
79pub trait NcReadable: classic::data::NcReadType {}
80#[cfg(not(feature = "netcdf4"))]
81impl<T: classic::data::NcReadType> NcReadable for T {}
82
83/// An opened NetCDF file.
84pub struct NcFile {
85    format: NcFormat,
86    inner: NcFileInner,
87}
88
89enum NcFileInner {
90    Classic(classic::ClassicFile),
91    #[cfg(feature = "netcdf4")]
92    Nc4(Box<nc4::Nc4File>),
93}
94
95/// HDF5 magic bytes: `\x89HDF\r\n\x1a\n`
96const HDF5_MAGIC: [u8; 8] = [0x89, b'H', b'D', b'F', 0x0D, 0x0A, 0x1A, 0x0A];
97
98/// Detect the NetCDF format from the first bytes of a file.
99fn detect_format(data: &[u8]) -> Result<NcFormat> {
100    if data.len() < 4 {
101        return Err(Error::InvalidMagic);
102    }
103
104    // Check for CDF magic: "CDF" followed by version byte.
105    if data[0] == b'C' && data[1] == b'D' && data[2] == b'F' {
106        return match data[3] {
107            1 => Ok(NcFormat::Classic),
108            2 => Ok(NcFormat::Offset64),
109            5 => Ok(NcFormat::Cdf5),
110            v => Err(Error::UnsupportedVersion(v)),
111        };
112    }
113
114    // Check for HDF5 magic (8 bytes).
115    if data.len() >= 8 && data[..8] == HDF5_MAGIC {
116        return Ok(NcFormat::Nc4);
117    }
118
119    Err(Error::InvalidMagic)
120}
121
122fn read_magic_prefix(reader: &mut impl Read) -> std::io::Result<([u8; 8], usize)> {
123    let mut magic = [0u8; 8];
124    let mut read_len = 0;
125    while read_len < magic.len() {
126        let n = reader.read(&mut magic[read_len..])?;
127        if n == 0 {
128            break;
129        }
130        read_len += n;
131    }
132    Ok((magic, read_len))
133}
134
135#[cfg(feature = "cf")]
136fn parent_group_path(path: &str) -> &str {
137    let trimmed = path.trim_matches('/');
138    trimmed
139        .rsplit_once('/')
140        .map(|(group_path, _)| group_path)
141        .unwrap_or("")
142}
143
144impl NcFile {
145    /// Open a NetCDF file from a path.
146    ///
147    /// The format is auto-detected from the file's magic bytes.
148    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
149        Self::open_with_options(path, NcOpenOptions::default())
150    }
151
152    /// Open a NetCDF file from in-memory bytes.
153    ///
154    /// The format is auto-detected from the magic bytes.
155    pub fn from_bytes(data: &[u8]) -> Result<Self> {
156        Self::from_bytes_with_options(data, NcOpenOptions::default())
157    }
158
159    /// Open a NetCDF file from a custom random-access storage backend.
160    ///
161    /// Both NetCDF-4 and classic CDF-1/2/5 files stay range-backed.
162    #[cfg(feature = "netcdf4")]
163    pub fn from_storage(storage: DynStorage) -> Result<Self> {
164        Self::from_storage_with_options(storage, NcOpenOptions::default())
165    }
166
167    /// Open a NetCDF file from a custom random-access storage backend with custom options.
168    #[cfg(feature = "netcdf4")]
169    pub fn from_storage_with_options(storage: DynStorage, options: NcOpenOptions) -> Result<Self> {
170        let magic_len = storage.len().min(HDF5_MAGIC.len() as u64) as usize;
171        let magic = storage.read_range(0, magic_len)?;
172        let format = detect_format(magic.as_ref())?;
173
174        match format {
175            NcFormat::Classic | NcFormat::Offset64 | NcFormat::Cdf5 => {
176                let classic = classic::ClassicFile::from_storage(storage, format)?;
177                Ok(NcFile {
178                    format,
179                    inner: NcFileInner::Classic(classic),
180                })
181            }
182            NcFormat::Nc4 | NcFormat::Nc4Classic => {
183                let nc4 = nc4::Nc4File::from_storage_with_options(storage, options)?;
184                let actual_format = if nc4.is_classic_model() {
185                    NcFormat::Nc4Classic
186                } else {
187                    NcFormat::Nc4
188                };
189                Ok(NcFile {
190                    format: actual_format,
191                    inner: NcFileInner::Nc4(Box::new(nc4)),
192                })
193            }
194        }
195    }
196
197    /// Open a NetCDF file from in-memory bytes with custom options.
198    ///
199    /// NC4 options are applied when the payload is HDF5-backed.
200    pub fn from_bytes_with_options(data: &[u8], options: NcOpenOptions) -> Result<Self> {
201        let format = detect_format(data)?;
202
203        match format {
204            NcFormat::Classic | NcFormat::Offset64 | NcFormat::Cdf5 => {
205                let classic = classic::ClassicFile::from_bytes(data, format)?;
206                Ok(NcFile {
207                    format,
208                    inner: NcFileInner::Classic(classic),
209                })
210            }
211            NcFormat::Nc4 | NcFormat::Nc4Classic => {
212                #[cfg(feature = "netcdf4")]
213                {
214                    let nc4 = nc4::Nc4File::from_bytes_with_options(data, options)?;
215                    let actual_format = if nc4.is_classic_model() {
216                        NcFormat::Nc4Classic
217                    } else {
218                        NcFormat::Nc4
219                    };
220                    Ok(NcFile {
221                        format: actual_format,
222                        inner: NcFileInner::Nc4(Box::new(nc4)),
223                    })
224                }
225                #[cfg(not(feature = "netcdf4"))]
226                {
227                    let _ = options;
228                    Err(Error::Nc4NotEnabled)
229                }
230            }
231        }
232    }
233
234    /// The detected file format.
235    pub fn format(&self) -> NcFormat {
236        self.format
237    }
238
239    /// The root group of the file.
240    ///
241    /// Classic files have a single implicit root group containing all
242    /// dimensions, variables, and global attributes. NetCDF-4 files
243    /// can have nested sub-groups.
244    pub fn root_group(&self) -> Result<&NcGroup> {
245        match &self.inner {
246            NcFileInner::Classic(c) => Ok(c.root_group()),
247            #[cfg(feature = "netcdf4")]
248            NcFileInner::Nc4(n) => n.root_group(),
249        }
250    }
251
252    /// Convenience: dimensions in the root group.
253    pub fn dimensions(&self) -> Result<&[NcDimension]> {
254        match &self.inner {
255            NcFileInner::Classic(c) => Ok(&c.root_group().dimensions),
256            #[cfg(feature = "netcdf4")]
257            NcFileInner::Nc4(n) => n.dimensions(),
258        }
259    }
260
261    /// Convenience: variables in the root group.
262    pub fn variables(&self) -> Result<&[NcVariable]> {
263        match &self.inner {
264            NcFileInner::Classic(c) => Ok(&c.root_group().variables),
265            #[cfg(feature = "netcdf4")]
266            NcFileInner::Nc4(n) => n.variables(),
267        }
268    }
269
270    /// Convenience: global attributes (attributes of the root group).
271    pub fn global_attributes(&self) -> Result<&[NcAttribute]> {
272        match &self.inner {
273            NcFileInner::Classic(c) => Ok(&c.root_group().attributes),
274            #[cfg(feature = "netcdf4")]
275            NcFileInner::Nc4(n) => n.global_attributes(),
276        }
277    }
278
279    /// Find a group by path relative to the root group.
280    pub fn group(&self, path: &str) -> Result<&NcGroup> {
281        match &self.inner {
282            NcFileInner::Classic(c) => c
283                .root_group()
284                .group(path)
285                .ok_or_else(|| Error::GroupNotFound(path.to_string())),
286            #[cfg(feature = "netcdf4")]
287            NcFileInner::Nc4(n) => n.group(path),
288        }
289    }
290
291    /// Find a variable by name or path relative to the root group.
292    pub fn variable(&self, name: &str) -> Result<&NcVariable> {
293        match &self.inner {
294            NcFileInner::Classic(c) => c
295                .root_group()
296                .variable(name)
297                .ok_or_else(|| Error::VariableNotFound(name.to_string())),
298            #[cfg(feature = "netcdf4")]
299            NcFileInner::Nc4(n) => n.variable(name),
300        }
301    }
302
303    /// Find a dimension by name or path relative to the root group.
304    pub fn dimension(&self, name: &str) -> Result<&NcDimension> {
305        match &self.inner {
306            NcFileInner::Classic(c) => c
307                .root_group()
308                .dimension(name)
309                .ok_or_else(|| Error::DimensionNotFound(name.to_string())),
310            #[cfg(feature = "netcdf4")]
311            NcFileInner::Nc4(n) => n.dimension(name),
312        }
313    }
314
315    /// Find the coordinate variable for a dimension name or path.
316    pub fn coordinate_variable(&self, name: &str) -> Result<&NcVariable> {
317        self.root_group()?
318            .coordinate_variable(name)
319            .ok_or_else(|| Error::VariableNotFound(format!("coordinate variable for {name}")))
320    }
321
322    /// Discover CF axes from coordinate variables in a group.
323    #[cfg(feature = "cf")]
324    pub fn cf_coordinate_axes(&self, group_path: &str) -> Result<Vec<cf::CfCoordinateAxis<'_>>> {
325        let group = self.group(group_path)?;
326        Ok(cf::discover_coordinate_axes(group))
327    }
328
329    /// Discover CF axes used by a variable from its coordinate variables.
330    #[cfg(feature = "cf")]
331    pub fn cf_variable_axes(&self, name: &str) -> Result<Vec<cf::CfCoordinateAxis<'_>>> {
332        let variable = self.variable(name)?;
333        let group = self.group(parent_group_path(name))?;
334        Ok(cf::discover_variable_axes(variable, group))
335    }
336
337    /// Discover CF time coordinate variables in a group.
338    #[cfg(feature = "cf")]
339    pub fn cf_time_coordinates(&self, group_path: &str) -> Result<Vec<cf::CfTimeCoordinate<'_>>> {
340        let group = self.group(group_path)?;
341        cf::discover_time_coordinates(group)
342    }
343
344    /// Discover the CF time coordinate used by a variable, if one exists.
345    #[cfg(feature = "cf")]
346    pub fn cf_variable_time_coordinate(
347        &self,
348        name: &str,
349    ) -> Result<Option<cf::CfTimeCoordinate<'_>>> {
350        let variable = self.variable(name)?;
351        let group = self.group(parent_group_path(name))?;
352        cf::discover_variable_time_coordinate(variable, group)
353    }
354
355    /// Find a group attribute by name or path relative to the root group.
356    pub fn global_attribute(&self, name: &str) -> Result<&NcAttribute> {
357        match &self.inner {
358            NcFileInner::Classic(c) => c
359                .root_group()
360                .attribute(name)
361                .ok_or_else(|| Error::AttributeNotFound(name.to_string())),
362            #[cfg(feature = "netcdf4")]
363            NcFileInner::Nc4(n) => n.global_attribute(name),
364        }
365    }
366
367    /// Read a variable's data as a typed array.
368    ///
369    /// Works for both classic (CDF-1/2/5) and NetCDF-4 files. NetCDF-4 nested
370    /// variables can be addressed with paths like `group/subgroup/var`. The type
371    /// parameter `T` must implement `NcReadable`, which is satisfied by:
372    /// `i8, u8, i16, u16, i32, u32, i64, u64, f32, f64`.
373    pub fn read_variable<T: NcReadable>(&self, name: &str) -> Result<ArrayD<T>> {
374        match &self.inner {
375            NcFileInner::Classic(c) => c.read_variable::<T>(name),
376            #[cfg(feature = "netcdf4")]
377            NcFileInner::Nc4(n) => Ok(n.read_variable::<T>(name)?),
378        }
379    }
380
381    /// Read a variable into a caller-provided typed buffer.
382    pub fn read_variable_into<T: NcReadable>(&self, name: &str, dst: &mut [T]) -> Result<()> {
383        match &self.inner {
384            NcFileInner::Classic(c) => c.read_variable_into::<T>(name, dst),
385            #[cfg(feature = "netcdf4")]
386            NcFileInner::Nc4(n) => Ok(n.read_variable_into::<T>(name, dst)?),
387        }
388    }
389
390    /// Read a NetCDF-4 variable as logical raw bytes in HDF5 datatype byte order.
391    #[cfg(feature = "netcdf4")]
392    pub fn read_variable_raw_bytes(&self, name: &str) -> Result<Vec<u8>> {
393        match &self.inner {
394            NcFileInner::Classic(_) => Err(Error::TypeMismatch {
395                expected: "NetCDF-4 variable".to_string(),
396                actual: "classic NetCDF variable".to_string(),
397            }),
398            NcFileInner::Nc4(n) => Ok(n.read_variable_raw_bytes(name)?),
399        }
400    }
401
402    /// Read a NetCDF-4 variable as logical raw bytes into a caller-provided buffer.
403    #[cfg(feature = "netcdf4")]
404    pub fn read_variable_raw_bytes_into(&self, name: &str, dst: &mut [u8]) -> Result<()> {
405        match &self.inner {
406            NcFileInner::Classic(_) => Err(Error::TypeMismatch {
407                expected: "NetCDF-4 variable".to_string(),
408                actual: "classic NetCDF variable".to_string(),
409            }),
410            NcFileInner::Nc4(n) => Ok(n.read_variable_raw_bytes_into(name, dst)?),
411        }
412    }
413
414    /// Read a NetCDF-4 variable as logical raw bytes with numeric fields in native endian.
415    #[cfg(feature = "netcdf4")]
416    pub fn read_variable_native_bytes(&self, name: &str) -> Result<Vec<u8>> {
417        match &self.inner {
418            NcFileInner::Classic(_) => Err(Error::TypeMismatch {
419                expected: "NetCDF-4 variable".to_string(),
420                actual: "classic NetCDF variable".to_string(),
421            }),
422            NcFileInner::Nc4(n) => Ok(n.read_variable_native_bytes(name)?),
423        }
424    }
425
426    /// Read native-endian logical raw bytes for a NetCDF-4 variable into a buffer.
427    #[cfg(feature = "netcdf4")]
428    pub fn read_variable_native_bytes_into(&self, name: &str, dst: &mut [u8]) -> Result<()> {
429        match &self.inner {
430            NcFileInner::Classic(_) => Err(Error::TypeMismatch {
431                expected: "NetCDF-4 variable".to_string(),
432                actual: "classic NetCDF variable".to_string(),
433            }),
434            NcFileInner::Nc4(n) => Ok(n.read_variable_native_bytes_into(name, dst)?),
435        }
436    }
437
438    /// Iterate decoded HDF5 chunks for a NetCDF-4 variable.
439    #[cfg(feature = "netcdf4")]
440    pub fn iter_variable_chunks(&self, name: &str) -> Result<DatasetChunkIterator> {
441        match &self.inner {
442            NcFileInner::Classic(_) => Err(Error::TypeMismatch {
443                expected: "NetCDF-4 chunked variable".to_string(),
444                actual: "classic NetCDF variable".to_string(),
445            }),
446            NcFileInner::Nc4(n) => Ok(n.iter_variable_chunks(name)?),
447        }
448    }
449
450    /// Return chunk-cache statistics for NetCDF-4 files.
451    #[cfg(feature = "netcdf4")]
452    pub fn chunk_cache_stats(&self) -> Option<ChunkCacheStats> {
453        match &self.inner {
454            NcFileInner::Classic(_) => None,
455            NcFileInner::Nc4(n) => Some(n.chunk_cache_stats()),
456        }
457    }
458
459    /// Read a variable using internal chunk-level parallelism when available.
460    ///
461    /// Classic formats fall back to `read_variable`.
462    #[cfg(feature = "rayon")]
463    pub fn read_variable_parallel<T: NcReadable>(&self, name: &str) -> Result<ArrayD<T>> {
464        match &self.inner {
465            NcFileInner::Classic(c) => c.read_variable_parallel::<T>(name),
466            #[cfg(feature = "netcdf4")]
467            NcFileInner::Nc4(n) => Ok(n.read_variable_parallel::<T>(name)?),
468        }
469    }
470
471    /// Read a variable using the provided Rayon thread pool when available.
472    ///
473    /// Classic formats fall back to `read_variable`.
474    #[cfg(feature = "rayon")]
475    pub fn read_variable_in_pool<T: NcReadable>(
476        &self,
477        name: &str,
478        pool: &ThreadPool,
479    ) -> Result<ArrayD<T>> {
480        match &self.inner {
481            NcFileInner::Classic(c) => pool.install(|| c.read_variable_parallel::<T>(name)),
482            #[cfg(feature = "netcdf4")]
483            NcFileInner::Nc4(n) => Ok(n.read_variable_in_pool::<T>(name, pool)?),
484        }
485    }
486
487    /// Access the underlying classic file (for reading data).
488    ///
489    /// Returns `None` if this is a NetCDF-4 file.
490    pub fn as_classic(&self) -> Option<&classic::ClassicFile> {
491        match &self.inner {
492            NcFileInner::Classic(c) => Some(c),
493            #[cfg(feature = "netcdf4")]
494            NcFileInner::Nc4(_) => None,
495        }
496    }
497
498    /// Read a variable with automatic type promotion to f64.
499    ///
500    /// Reads in the native storage type (i8, i16, i32, f32, f64, u8, etc.)
501    /// and promotes all values to f64. This avoids the `TypeMismatch` error
502    /// that `read_variable::<f64>` produces for non-f64 variables.
503    pub fn read_variable_as_f64(&self, name: &str) -> Result<ArrayD<f64>> {
504        match &self.inner {
505            NcFileInner::Classic(c) => c.read_variable_as_f64(name),
506            #[cfg(feature = "netcdf4")]
507            NcFileInner::Nc4(n) => n.read_variable_as_f64(name),
508        }
509    }
510
511    /// Read a string variable as a single string.
512    ///
513    /// Use [`NcFile::read_variable_as_strings`] when the variable contains
514    /// multiple string elements.
515    pub fn read_variable_as_string(&self, name: &str) -> Result<String> {
516        match &self.inner {
517            NcFileInner::Classic(c) => c.read_variable_as_string(name),
518            #[cfg(feature = "netcdf4")]
519            NcFileInner::Nc4(n) => n.read_variable_as_string(name),
520        }
521    }
522
523    /// Read a string or char variable as a flat vector of strings.
524    ///
525    /// Classic char arrays interpret the last dimension as the string length
526    /// and flatten the leading dimensions.
527    pub fn read_variable_as_strings(&self, name: &str) -> Result<Vec<String>> {
528        match &self.inner {
529            NcFileInner::Classic(c) => c.read_variable_as_strings(name),
530            #[cfg(feature = "netcdf4")]
531            NcFileInner::Nc4(n) => n.read_variable_as_strings(name),
532        }
533    }
534
535    /// Read a NetCDF-4 user-defined variable into dynamic values.
536    ///
537    /// This supports enum, opaque, compound, fixed-size array, and non-string
538    /// vlen datatypes. Primitive values nested inside those types are decoded
539    /// according to the HDF5 datatype byte order.
540    #[cfg(feature = "netcdf4")]
541    pub fn read_variable_user_defined(&self, name: &str) -> Result<ArrayD<NcValue>> {
542        match &self.inner {
543            NcFileInner::Classic(_) => Err(Error::TypeMismatch {
544                expected: "NetCDF-4 user-defined variable".to_string(),
545                actual: "classic NetCDF variable".to_string(),
546            }),
547            NcFileInner::Nc4(n) => n.read_variable_user_defined(name),
548        }
549    }
550
551    /// Read a NetCDF-4 user-defined variable through a caller-provided decoder.
552    ///
553    /// The decoder receives one [`NcValueView`] per logical variable element,
554    /// allowing direct construction of application structs without allocating
555    /// an intermediate [`NcValue`] tree.
556    #[cfg(feature = "netcdf4")]
557    pub fn read_variable_user_defined_with<T, F>(&self, name: &str, decoder: F) -> Result<ArrayD<T>>
558    where
559        F: FnMut(NcValueView<'_>) -> Result<T>,
560    {
561        match &self.inner {
562            NcFileInner::Classic(_) => Err(Error::TypeMismatch {
563                expected: "NetCDF-4 user-defined variable".to_string(),
564                actual: "classic NetCDF variable".to_string(),
565            }),
566            NcFileInner::Nc4(n) => n.read_variable_user_defined_with(name, decoder),
567        }
568    }
569
570    /// Read a variable and apply `scale_factor`/`add_offset` unpacking.
571    ///
572    /// Returns `actual = stored * scale_factor + add_offset`.
573    /// If neither attribute is present, returns the raw data as f64.
574    /// Uses type-promoting read so it works with any numeric storage type.
575    pub fn read_variable_unpacked(&self, name: &str) -> Result<ArrayD<f64>> {
576        let var = self.variable(name)?;
577        let params = unpack::UnpackParams::from_variable(var);
578        let mut data = self.read_variable_as_f64(name)?;
579        if let Some(p) = params {
580            p.apply(&mut data);
581        }
582        Ok(data)
583    }
584
585    /// Read a variable, replace `_FillValue`/`missing_value` with NaN,
586    /// and mask values outside `valid_min`/`valid_max`/`valid_range`.
587    /// Uses type-promoting read so it works with any numeric storage type.
588    pub fn read_variable_masked(&self, name: &str) -> Result<ArrayD<f64>> {
589        let var = self.variable(name)?;
590        let params = masked::MaskParams::from_variable(var);
591        let mut data = self.read_variable_as_f64(name)?;
592        if let Some(p) = params {
593            p.apply(&mut data);
594        }
595        Ok(data)
596    }
597
598    /// Read a variable with both masking and unpacking (CF spec order).
599    ///
600    /// Order: read → mask fill/missing → unpack (scale+offset).
601    /// Uses type-promoting read so it works with any numeric storage type.
602    pub fn read_variable_unpacked_masked(&self, name: &str) -> Result<ArrayD<f64>> {
603        let var = self.variable(name)?;
604        let mask_params = masked::MaskParams::from_variable(var);
605        let unpack_params = unpack::UnpackParams::from_variable(var);
606        let mut data = self.read_variable_as_f64(name)?;
607        if let Some(p) = mask_params {
608            p.apply(&mut data);
609        }
610        if let Some(p) = unpack_params {
611            p.apply(&mut data);
612        }
613        Ok(data)
614    }
615
616    // ----- Slice API -----
617
618    /// Read a slice (hyperslab) of a variable as a typed array.
619    pub fn read_variable_slice<T: NcReadable>(
620        &self,
621        name: &str,
622        selection: &NcSliceInfo,
623    ) -> Result<ArrayD<T>> {
624        match &self.inner {
625            NcFileInner::Classic(c) => c.read_variable_slice::<T>(name, selection),
626            #[cfg(feature = "netcdf4")]
627            NcFileInner::Nc4(n) => Ok(n.read_variable_slice::<T>(name, selection)?),
628        }
629    }
630
631    /// Read a slice (hyperslab) using chunk-level parallelism when available.
632    ///
633    /// For NetCDF-4 chunked datasets, overlapping chunks are decompressed in
634    /// parallel via Rayon. Classic formats fall back to `read_variable_slice`.
635    #[cfg(feature = "rayon")]
636    pub fn read_variable_slice_parallel<T: NcReadable>(
637        &self,
638        name: &str,
639        selection: &NcSliceInfo,
640    ) -> Result<ArrayD<T>> {
641        match &self.inner {
642            NcFileInner::Classic(c) => c.read_variable_slice_parallel::<T>(name, selection),
643            #[cfg(feature = "netcdf4")]
644            NcFileInner::Nc4(n) => Ok(n.read_variable_slice_parallel::<T>(name, selection)?),
645        }
646    }
647
648    /// Read a slice of a variable with automatic type promotion to f64.
649    pub fn read_variable_slice_as_f64(
650        &self,
651        name: &str,
652        selection: &NcSliceInfo,
653    ) -> Result<ArrayD<f64>> {
654        match &self.inner {
655            NcFileInner::Classic(c) => c.read_variable_slice_as_f64(name, selection),
656            #[cfg(feature = "netcdf4")]
657            NcFileInner::Nc4(n) => n.read_variable_slice_as_f64(name, selection),
658        }
659    }
660
661    /// Read a slice with `scale_factor`/`add_offset` unpacking.
662    pub fn read_variable_slice_unpacked(
663        &self,
664        name: &str,
665        selection: &NcSliceInfo,
666    ) -> Result<ArrayD<f64>> {
667        let var = self.variable(name)?;
668        let params = unpack::UnpackParams::from_variable(var);
669        let mut data = self.read_variable_slice_as_f64(name, selection)?;
670        if let Some(p) = params {
671            p.apply(&mut data);
672        }
673        Ok(data)
674    }
675
676    /// Read a slice with fill/missing value masking.
677    pub fn read_variable_slice_masked(
678        &self,
679        name: &str,
680        selection: &NcSliceInfo,
681    ) -> Result<ArrayD<f64>> {
682        let var = self.variable(name)?;
683        let params = masked::MaskParams::from_variable(var);
684        let mut data = self.read_variable_slice_as_f64(name, selection)?;
685        if let Some(p) = params {
686            p.apply(&mut data);
687        }
688        Ok(data)
689    }
690
691    /// Read a slice with both masking and unpacking (CF spec order).
692    pub fn read_variable_slice_unpacked_masked(
693        &self,
694        name: &str,
695        selection: &NcSliceInfo,
696    ) -> Result<ArrayD<f64>> {
697        let var = self.variable(name)?;
698        let mask_params = masked::MaskParams::from_variable(var);
699        let unpack_params = unpack::UnpackParams::from_variable(var);
700        let mut data = self.read_variable_slice_as_f64(name, selection)?;
701        if let Some(p) = mask_params {
702            p.apply(&mut data);
703        }
704        if let Some(p) = unpack_params {
705            p.apply(&mut data);
706        }
707        Ok(data)
708    }
709
710    // ----- Lazy Slice Iterator -----
711
712    /// Create an iterator that yields one slice per index along a given dimension.
713    ///
714    /// Each call to `next()` reads one slice using the slice API. This is
715    /// useful for iterating time steps, levels, etc. without loading the
716    /// entire dataset into memory.
717    pub fn iter_slices<T: NcReadable>(
718        &self,
719        name: &str,
720        dim: usize,
721    ) -> Result<NcSliceIterator<'_, T>> {
722        let var = self.variable(name)?;
723        let ndim = var.ndim();
724        if dim >= ndim {
725            return Err(Error::InvalidData(format!(
726                "dimension index {} out of range for {}-dimensional variable '{}'",
727                dim, ndim, name
728            )));
729        }
730        let dim_size = var.dimensions[dim].size;
731        Ok(NcSliceIterator {
732            file: self,
733            name: name.to_string(),
734            dim,
735            dim_size,
736            current: 0,
737            ndim,
738            _marker: std::marker::PhantomData,
739        })
740    }
741}
742
743/// Configuration options for opening a NetCDF file.
744pub struct NcOpenOptions {
745    /// Maximum bytes for the chunk cache (NC4 only). Default: 64 MiB.
746    pub chunk_cache_bytes: usize,
747    /// Maximum number of chunk cache slots (NC4 only). Default: 521.
748    pub chunk_cache_slots: usize,
749    /// NetCDF-4 metadata reconstruction policy. Default: strict.
750    pub metadata_mode: NcMetadataMode,
751    /// Custom filter registry (NC4 only).
752    #[cfg(feature = "netcdf4")]
753    pub filter_registry: Option<hdf5_reader::FilterRegistry>,
754    /// Resolver for HDF5 external raw data files (NC4 only). If `None`,
755    /// external raw data files are not resolved.
756    #[cfg(feature = "netcdf4")]
757    pub external_file_resolver: Option<Arc<dyn hdf5_reader::ExternalFileResolver>>,
758    /// Resolver for HDF5 external links (NC4 only).
759    #[cfg(feature = "netcdf4")]
760    pub external_link_resolver: Option<Arc<dyn hdf5_reader::ExternalLinkResolver>>,
761}
762
763impl Default for NcOpenOptions {
764    fn default() -> Self {
765        NcOpenOptions {
766            chunk_cache_bytes: 64 * 1024 * 1024,
767            chunk_cache_slots: 521,
768            metadata_mode: NcMetadataMode::Strict,
769            #[cfg(feature = "netcdf4")]
770            filter_registry: None,
771            #[cfg(feature = "netcdf4")]
772            external_file_resolver: None,
773            #[cfg(feature = "netcdf4")]
774            external_link_resolver: None,
775        }
776    }
777}
778
779impl NcFile {
780    /// Open a NetCDF file with custom options.
781    pub fn open_with_options(path: impl AsRef<Path>, options: NcOpenOptions) -> Result<Self> {
782        let path = path.as_ref();
783        let mut file = File::open(path)?;
784        let (magic, n) = read_magic_prefix(&mut file)?;
785        let format = detect_format(&magic[..n])?;
786
787        match format {
788            NcFormat::Classic | NcFormat::Offset64 | NcFormat::Cdf5 => {
789                let file = File::open(path)?;
790                // SAFETY: read-only mapping; caller must not modify the file concurrently.
791                let mmap = unsafe { Mmap::map(&file)? };
792                let classic = classic::ClassicFile::from_mmap(mmap, format)?;
793                Ok(NcFile {
794                    format,
795                    inner: NcFileInner::Classic(classic),
796                })
797            }
798            NcFormat::Nc4 | NcFormat::Nc4Classic => {
799                #[cfg(feature = "netcdf4")]
800                {
801                    let metadata_mode = options.metadata_mode;
802                    let hdf5 = hdf5_reader::Hdf5File::open_with_options(
803                        path,
804                        hdf5_reader::OpenOptions {
805                            chunk_cache_bytes: options.chunk_cache_bytes,
806                            chunk_cache_slots: options.chunk_cache_slots,
807                            filter_registry: options.filter_registry,
808                            external_file_resolver: options.external_file_resolver,
809                            external_link_resolver: options.external_link_resolver,
810                        },
811                    )?;
812                    let nc4 = nc4::Nc4File::from_hdf5(hdf5, metadata_mode)?;
813                    let actual_format = if nc4.is_classic_model() {
814                        NcFormat::Nc4Classic
815                    } else {
816                        NcFormat::Nc4
817                    };
818                    Ok(NcFile {
819                        format: actual_format,
820                        inner: NcFileInner::Nc4(Box::new(nc4)),
821                    })
822                }
823                #[cfg(not(feature = "netcdf4"))]
824                {
825                    let _ = options;
826                    Err(Error::Nc4NotEnabled)
827                }
828            }
829        }
830    }
831}
832
833/// Lazy iterator over slices of a variable along a given dimension.
834pub struct NcSliceIterator<'f, T: NcReadable> {
835    file: &'f NcFile,
836    name: String,
837    dim: usize,
838    dim_size: u64,
839    current: u64,
840    ndim: usize,
841    _marker: std::marker::PhantomData<T>,
842}
843
844impl<'f, T: NcReadable> Iterator for NcSliceIterator<'f, T> {
845    type Item = Result<ArrayD<T>>;
846
847    fn next(&mut self) -> Option<Self::Item> {
848        if self.current >= self.dim_size {
849            return None;
850        }
851        let mut selections = Vec::with_capacity(self.ndim);
852        for d in 0..self.ndim {
853            if d == self.dim {
854                selections.push(NcSliceInfoElem::Index(self.current));
855            } else {
856                selections.push(NcSliceInfoElem::Slice {
857                    start: 0,
858                    end: u64::MAX,
859                    step: 1,
860                });
861            }
862        }
863        let selection = NcSliceInfo { selections };
864        self.current += 1;
865        Some(self.file.read_variable_slice::<T>(&self.name, &selection))
866    }
867
868    fn size_hint(&self) -> (usize, Option<usize>) {
869        let remaining_u64 = self.dim_size.saturating_sub(self.current);
870        let remaining = remaining_u64.min(usize::MAX as u64) as usize;
871        (remaining, Some(remaining))
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    #[cfg(feature = "netcdf4")]
879    use std::sync::Arc;
880    #[cfg(feature = "netcdf4")]
881    use std::sync::Mutex;
882
883    #[test]
884    fn detect_cdf1() {
885        let data = b"CDF\x01rest_of_file";
886        assert_eq!(detect_format(data).unwrap(), NcFormat::Classic);
887    }
888
889    #[test]
890    fn detect_cdf2() {
891        let data = b"CDF\x02rest_of_file";
892        assert_eq!(detect_format(data).unwrap(), NcFormat::Offset64);
893    }
894
895    #[test]
896    fn detect_cdf5() {
897        let data = b"CDF\x05rest_of_file";
898        assert_eq!(detect_format(data).unwrap(), NcFormat::Cdf5);
899    }
900
901    #[test]
902    fn detect_hdf5() {
903        let mut data = vec![0x89, b'H', b'D', b'F', 0x0D, 0x0A, 0x1A, 0x0A];
904        data.extend_from_slice(b"rest_of_file");
905        assert_eq!(detect_format(&data).unwrap(), NcFormat::Nc4);
906    }
907
908    #[test]
909    fn detect_invalid_magic() {
910        let data = b"XXXX";
911        assert!(matches!(
912            detect_format(data).unwrap_err(),
913            Error::InvalidMagic
914        ));
915    }
916
917    #[test]
918    fn detect_unsupported_version() {
919        let data = b"CDF\x03";
920        assert!(matches!(
921            detect_format(data).unwrap_err(),
922            Error::UnsupportedVersion(3)
923        ));
924    }
925
926    #[test]
927    fn detect_too_short() {
928        let data = b"CD";
929        assert!(matches!(
930            detect_format(data).unwrap_err(),
931            Error::InvalidMagic
932        ));
933    }
934
935    #[test]
936    fn from_bytes_minimal_cdf1() {
937        // Minimal valid CDF-1 file: magic + numrecs + absent dim/att/var lists.
938        let mut data = Vec::new();
939        data.extend_from_slice(b"CDF\x01");
940        data.extend_from_slice(&0u32.to_be_bytes()); // numrecs = 0
941                                                     // dim_list: ABSENT
942        data.extend_from_slice(&0u32.to_be_bytes()); // tag = 0
943        data.extend_from_slice(&0u32.to_be_bytes()); // count = 0
944                                                     // att_list: ABSENT
945        data.extend_from_slice(&0u32.to_be_bytes());
946        data.extend_from_slice(&0u32.to_be_bytes());
947        // var_list: ABSENT
948        data.extend_from_slice(&0u32.to_be_bytes());
949        data.extend_from_slice(&0u32.to_be_bytes());
950
951        let file = NcFile::from_bytes(&data).unwrap();
952        assert_eq!(file.format(), NcFormat::Classic);
953        assert!(file.dimensions().unwrap().is_empty());
954        assert!(file.variables().unwrap().is_empty());
955        assert!(file.global_attributes().unwrap().is_empty());
956    }
957
958    #[cfg(feature = "netcdf4")]
959    struct CountingStorage {
960        data: Arc<[u8]>,
961        reads: Mutex<Vec<(u64, usize)>>,
962    }
963
964    #[cfg(feature = "netcdf4")]
965    impl CountingStorage {
966        fn new(data: Vec<u8>) -> Self {
967            Self {
968                data: Arc::<[u8]>::from(data),
969                reads: Mutex::new(Vec::new()),
970            }
971        }
972
973        fn reads(&self) -> Vec<(u64, usize)> {
974            self.reads.lock().unwrap().clone()
975        }
976    }
977
978    #[cfg(feature = "netcdf4")]
979    impl hdf5_reader::Storage for CountingStorage {
980        fn len(&self) -> u64 {
981            self.data.len() as u64
982        }
983
984        fn read_range(
985            &self,
986            offset: u64,
987            len: usize,
988        ) -> hdf5_reader::error::Result<hdf5_reader::StorageBuffer> {
989            self.reads.lock().unwrap().push((offset, len));
990            let start = usize::try_from(offset)
991                .map_err(|_| hdf5_reader::error::Error::OffsetOutOfBounds(offset))?;
992            let end = start
993                .checked_add(len)
994                .ok_or(hdf5_reader::error::Error::OffsetOutOfBounds(offset))?;
995            if end > self.data.len() {
996                return Err(hdf5_reader::error::Error::UnexpectedEof {
997                    offset,
998                    needed: len as u64,
999                    available: self.len().saturating_sub(offset),
1000                });
1001            }
1002            Ok(hdf5_reader::StorageBuffer::from_vec(
1003                self.data[start..end].to_vec(),
1004            ))
1005        }
1006    }
1007
1008    #[cfg(feature = "netcdf4")]
1009    fn classic_large_offset_fixture() -> Vec<u8> {
1010        fn write_name(buf: &mut Vec<u8>, name: &str) {
1011            buf.extend_from_slice(&(name.len() as u32).to_be_bytes());
1012            buf.extend_from_slice(name.as_bytes());
1013            while buf.len() % 4 != 0 {
1014                buf.push(0);
1015            }
1016        }
1017
1018        const DATA_OFFSET: usize = 70 * 1024;
1019        let mut buf = Vec::new();
1020        buf.extend_from_slice(b"CDF\x01");
1021        buf.extend_from_slice(&0u32.to_be_bytes());
1022        buf.extend_from_slice(&0x0000_000Au32.to_be_bytes());
1023        buf.extend_from_slice(&1u32.to_be_bytes());
1024        write_name(&mut buf, "x");
1025        buf.extend_from_slice(&4u32.to_be_bytes());
1026        buf.extend_from_slice(&0u32.to_be_bytes());
1027        buf.extend_from_slice(&0u32.to_be_bytes());
1028        buf.extend_from_slice(&0x0000_000Bu32.to_be_bytes());
1029        buf.extend_from_slice(&1u32.to_be_bytes());
1030        write_name(&mut buf, "data");
1031        buf.extend_from_slice(&1u32.to_be_bytes());
1032        buf.extend_from_slice(&0u32.to_be_bytes());
1033        buf.extend_from_slice(&0u32.to_be_bytes());
1034        buf.extend_from_slice(&0u32.to_be_bytes());
1035        buf.extend_from_slice(&4u32.to_be_bytes());
1036        buf.extend_from_slice(&16u32.to_be_bytes());
1037        buf.extend_from_slice(&(DATA_OFFSET as u32).to_be_bytes());
1038
1039        buf.resize(DATA_OFFSET, 0);
1040        for value in [10i32, 20, 30, 40] {
1041            buf.extend_from_slice(&value.to_be_bytes());
1042        }
1043        buf
1044    }
1045
1046    fn write_cdf1_name(buf: &mut Vec<u8>, name: &str) {
1047        buf.extend_from_slice(&(name.len() as u32).to_be_bytes());
1048        buf.extend_from_slice(name.as_bytes());
1049        while buf.len() % 4 != 0 {
1050            buf.push(0);
1051        }
1052    }
1053
1054    fn write_cdf5_count(buf: &mut Vec<u8>, value: u64) {
1055        buf.extend_from_slice(&value.to_be_bytes());
1056    }
1057
1058    fn write_cdf5_name(buf: &mut Vec<u8>, name: &str) {
1059        write_cdf5_count(buf, name.len() as u64);
1060        buf.extend_from_slice(name.as_bytes());
1061        while buf.len() % 4 != 0 {
1062            buf.push(0);
1063        }
1064    }
1065
1066    fn cdf5_huge_dimension_fixture() -> Vec<u8> {
1067        let mut buf = Vec::new();
1068        buf.extend_from_slice(b"CDF\x05");
1069        write_cdf5_count(&mut buf, 0);
1070
1071        buf.extend_from_slice(&0x0000_000Au32.to_be_bytes());
1072        write_cdf5_count(&mut buf, 1);
1073        write_cdf5_name(&mut buf, "n");
1074        write_cdf5_count(&mut buf, u64::MAX);
1075
1076        buf.extend_from_slice(&0u32.to_be_bytes());
1077        write_cdf5_count(&mut buf, 0);
1078
1079        buf.extend_from_slice(&0x0000_000Bu32.to_be_bytes());
1080        write_cdf5_count(&mut buf, 1);
1081        write_cdf5_name(&mut buf, "big");
1082        write_cdf5_count(&mut buf, 1);
1083        write_cdf5_count(&mut buf, 0);
1084        buf.extend_from_slice(&0u32.to_be_bytes());
1085        write_cdf5_count(&mut buf, 0);
1086        buf.extend_from_slice(&4u32.to_be_bytes());
1087        write_cdf5_count(&mut buf, 4);
1088        let offset_pos = buf.len();
1089        buf.extend_from_slice(&0u64.to_be_bytes());
1090
1091        let data_offset = buf.len() as u64;
1092        buf[offset_pos..offset_pos + 8].copy_from_slice(&data_offset.to_be_bytes());
1093        buf.extend_from_slice(&123i32.to_be_bytes());
1094        buf
1095    }
1096
1097    fn subfiling_marker_fixture() -> Vec<u8> {
1098        let mut buf = Vec::new();
1099        buf.extend_from_slice(b"CDF\x01");
1100        buf.extend_from_slice(&0u32.to_be_bytes());
1101        buf.extend_from_slice(&0u32.to_be_bytes());
1102        buf.extend_from_slice(&0u32.to_be_bytes());
1103
1104        buf.extend_from_slice(&0x0000_000Cu32.to_be_bytes());
1105        buf.extend_from_slice(&1u32.to_be_bytes());
1106        write_cdf1_name(&mut buf, "_PnetCDF_SubFiling_enabled");
1107        buf.extend_from_slice(&4u32.to_be_bytes());
1108        buf.extend_from_slice(&1u32.to_be_bytes());
1109        buf.extend_from_slice(&1i32.to_be_bytes());
1110
1111        buf.extend_from_slice(&0u32.to_be_bytes());
1112        buf.extend_from_slice(&0u32.to_be_bytes());
1113        buf
1114    }
1115
1116    fn streaming_cdf1_record_fixture(values: &[i32], trailing: &[u8]) -> Vec<u8> {
1117        let mut buf = Vec::new();
1118        buf.extend_from_slice(b"CDF\x01");
1119        buf.extend_from_slice(&u32::MAX.to_be_bytes());
1120
1121        buf.extend_from_slice(&0x0000_000Au32.to_be_bytes());
1122        buf.extend_from_slice(&1u32.to_be_bytes());
1123        write_cdf1_name(&mut buf, "time");
1124        buf.extend_from_slice(&0u32.to_be_bytes());
1125
1126        buf.extend_from_slice(&0u32.to_be_bytes());
1127        buf.extend_from_slice(&0u32.to_be_bytes());
1128
1129        buf.extend_from_slice(&0x0000_000Bu32.to_be_bytes());
1130        buf.extend_from_slice(&1u32.to_be_bytes());
1131        write_cdf1_name(&mut buf, "temp");
1132        buf.extend_from_slice(&1u32.to_be_bytes());
1133        buf.extend_from_slice(&0u32.to_be_bytes());
1134        buf.extend_from_slice(&0u32.to_be_bytes());
1135        buf.extend_from_slice(&0u32.to_be_bytes());
1136        buf.extend_from_slice(&4u32.to_be_bytes());
1137        buf.extend_from_slice(&4u32.to_be_bytes());
1138        let offset_pos = buf.len();
1139        buf.extend_from_slice(&0u32.to_be_bytes());
1140
1141        let data_offset = buf.len() as u32;
1142        buf[offset_pos..offset_pos + 4].copy_from_slice(&data_offset.to_be_bytes());
1143
1144        for value in values {
1145            buf.extend_from_slice(&value.to_be_bytes());
1146        }
1147        buf.extend_from_slice(trailing);
1148        buf
1149    }
1150
1151    fn streaming_cdf2_record_fixture(values: &[i32]) -> Vec<u8> {
1152        let mut buf = Vec::new();
1153        buf.extend_from_slice(b"CDF\x02");
1154        buf.extend_from_slice(&u32::MAX.to_be_bytes());
1155
1156        buf.extend_from_slice(&0x0000_000Au32.to_be_bytes());
1157        buf.extend_from_slice(&1u32.to_be_bytes());
1158        write_cdf1_name(&mut buf, "time");
1159        buf.extend_from_slice(&0u32.to_be_bytes());
1160
1161        buf.extend_from_slice(&0u32.to_be_bytes());
1162        buf.extend_from_slice(&0u32.to_be_bytes());
1163
1164        buf.extend_from_slice(&0x0000_000Bu32.to_be_bytes());
1165        buf.extend_from_slice(&1u32.to_be_bytes());
1166        write_cdf1_name(&mut buf, "temp");
1167        buf.extend_from_slice(&1u32.to_be_bytes());
1168        buf.extend_from_slice(&0u32.to_be_bytes());
1169        buf.extend_from_slice(&0u32.to_be_bytes());
1170        buf.extend_from_slice(&0u32.to_be_bytes());
1171        buf.extend_from_slice(&4u32.to_be_bytes());
1172        buf.extend_from_slice(&4u32.to_be_bytes());
1173        let offset_pos = buf.len();
1174        buf.extend_from_slice(&0u64.to_be_bytes());
1175
1176        let data_offset = buf.len() as u64;
1177        buf[offset_pos..offset_pos + 8].copy_from_slice(&data_offset.to_be_bytes());
1178
1179        for value in values {
1180            buf.extend_from_slice(&value.to_be_bytes());
1181        }
1182        buf
1183    }
1184
1185    fn streaming_cdf1_two_record_var_fixture(records: &[(i32, i16)]) -> Vec<u8> {
1186        let mut buf = Vec::new();
1187        buf.extend_from_slice(b"CDF\x01");
1188        buf.extend_from_slice(&u32::MAX.to_be_bytes());
1189
1190        buf.extend_from_slice(&0x0000_000Au32.to_be_bytes());
1191        buf.extend_from_slice(&1u32.to_be_bytes());
1192        write_cdf1_name(&mut buf, "time");
1193        buf.extend_from_slice(&0u32.to_be_bytes());
1194
1195        buf.extend_from_slice(&0u32.to_be_bytes());
1196        buf.extend_from_slice(&0u32.to_be_bytes());
1197
1198        buf.extend_from_slice(&0x0000_000Bu32.to_be_bytes());
1199        buf.extend_from_slice(&2u32.to_be_bytes());
1200
1201        write_cdf1_name(&mut buf, "temp");
1202        buf.extend_from_slice(&1u32.to_be_bytes());
1203        buf.extend_from_slice(&0u32.to_be_bytes());
1204        buf.extend_from_slice(&0u32.to_be_bytes());
1205        buf.extend_from_slice(&0u32.to_be_bytes());
1206        buf.extend_from_slice(&4u32.to_be_bytes());
1207        buf.extend_from_slice(&4u32.to_be_bytes());
1208        let temp_offset_pos = buf.len();
1209        buf.extend_from_slice(&0u32.to_be_bytes());
1210
1211        write_cdf1_name(&mut buf, "flag");
1212        buf.extend_from_slice(&1u32.to_be_bytes());
1213        buf.extend_from_slice(&0u32.to_be_bytes());
1214        buf.extend_from_slice(&0u32.to_be_bytes());
1215        buf.extend_from_slice(&0u32.to_be_bytes());
1216        buf.extend_from_slice(&3u32.to_be_bytes());
1217        buf.extend_from_slice(&2u32.to_be_bytes());
1218        let flag_offset_pos = buf.len();
1219        buf.extend_from_slice(&0u32.to_be_bytes());
1220
1221        let data_offset = buf.len() as u32;
1222        let flag_offset = data_offset + 4;
1223        buf[temp_offset_pos..temp_offset_pos + 4].copy_from_slice(&data_offset.to_be_bytes());
1224        buf[flag_offset_pos..flag_offset_pos + 4].copy_from_slice(&flag_offset.to_be_bytes());
1225
1226        for &(temp, flag) in records {
1227            buf.extend_from_slice(&temp.to_be_bytes());
1228            buf.extend_from_slice(&flag.to_be_bytes());
1229            buf.extend_from_slice(&[0, 0]);
1230        }
1231        buf
1232    }
1233
1234    #[test]
1235    fn cdf5_huge_dimension_can_slice_but_full_read_errors_cleanly() {
1236        let file = NcFile::from_bytes(&cdf5_huge_dimension_fixture()).unwrap();
1237        let selection = NcSliceInfo {
1238            selections: vec![NcSliceInfoElem::Index(0)],
1239        };
1240
1241        let sliced: ndarray::ArrayD<i32> = file.read_variable_slice("big", &selection).unwrap();
1242        assert_eq!(sliced.as_slice().unwrap(), &[123]);
1243
1244        let err = file.read_variable::<i32>("big").unwrap_err();
1245        assert!(matches!(err, Error::InvalidData(_)));
1246    }
1247
1248    #[test]
1249    fn classic_subfiling_marker_returns_unsupported_feature() {
1250        let err = match NcFile::from_bytes(&subfiling_marker_fixture()) {
1251            Ok(_) => panic!("subfiling marker should be rejected"),
1252            Err(err) => err,
1253        };
1254        assert!(matches!(
1255            err,
1256            Error::UnsupportedFeature(message) if message.contains("PnetCDF subfiling")
1257        ));
1258    }
1259
1260    #[test]
1261    fn streaming_cdf1_numrecs_are_derived_from_file_length() {
1262        let file = NcFile::from_bytes(&streaming_cdf1_record_fixture(&[10, 20, 30], &[])).unwrap();
1263
1264        assert_eq!(file.as_classic().unwrap().numrecs(), 3);
1265        assert_eq!(file.dimension("time").unwrap().size, 3);
1266        assert_eq!(file.variable("temp").unwrap().shape(), vec![3]);
1267
1268        let values: ndarray::ArrayD<i32> = file.read_variable("temp").unwrap();
1269        assert_eq!(values.as_slice().unwrap(), &[10, 20, 30]);
1270    }
1271
1272    #[test]
1273    fn streaming_cdf1_numrecs_ignore_trailing_partial_record() {
1274        let file =
1275            NcFile::from_bytes(&streaming_cdf1_record_fixture(&[10, 20], &[0xAA, 0xBB])).unwrap();
1276
1277        assert_eq!(file.as_classic().unwrap().numrecs(), 2);
1278        assert_eq!(file.variable("temp").unwrap().shape(), vec![2]);
1279
1280        let values: ndarray::ArrayD<i32> = file.read_variable("temp").unwrap();
1281        assert_eq!(values.as_slice().unwrap(), &[10, 20]);
1282    }
1283
1284    #[test]
1285    fn streaming_cdf2_numrecs_are_derived_from_file_length() {
1286        let file = NcFile::from_bytes(&streaming_cdf2_record_fixture(&[10, 20, 30])).unwrap();
1287
1288        assert_eq!(file.format(), NcFormat::Offset64);
1289        assert_eq!(file.as_classic().unwrap().numrecs(), 3);
1290        assert_eq!(file.dimension("time").unwrap().size, 3);
1291        assert_eq!(file.variable("temp").unwrap().shape(), vec![3]);
1292
1293        let values: ndarray::ArrayD<i32> = file.read_variable("temp").unwrap();
1294        assert_eq!(values.as_slice().unwrap(), &[10, 20, 30]);
1295    }
1296
1297    #[test]
1298    fn streaming_cdf1_numrecs_use_full_stride_for_multiple_record_variables() {
1299        let file = NcFile::from_bytes(&streaming_cdf1_two_record_var_fixture(&[
1300            (10, 1),
1301            (20, 2),
1302            (30, 3),
1303        ]))
1304        .unwrap();
1305
1306        assert_eq!(file.as_classic().unwrap().numrecs(), 3);
1307        assert_eq!(file.variable("temp").unwrap().shape(), vec![3]);
1308        assert_eq!(file.variable("flag").unwrap().shape(), vec![3]);
1309
1310        let temps: ndarray::ArrayD<i32> = file.read_variable("temp").unwrap();
1311        let flags: ndarray::ArrayD<i16> = file.read_variable("flag").unwrap();
1312        assert_eq!(temps.as_slice().unwrap(), &[10, 20, 30]);
1313        assert_eq!(flags.as_slice().unwrap(), &[1, 2, 3]);
1314    }
1315
1316    #[cfg(feature = "netcdf4")]
1317    #[test]
1318    fn streaming_cdf1_from_storage_derives_numrecs() {
1319        let file = NcFile::from_storage(Arc::new(BytesStorage::new(
1320            streaming_cdf1_record_fixture(&[10, 20, 30], &[]),
1321        )))
1322        .unwrap();
1323
1324        assert_eq!(file.as_classic().unwrap().numrecs(), 3);
1325        assert_eq!(file.dimension("time").unwrap().size, 3);
1326
1327        let values: ndarray::ArrayD<i32> = file.read_variable("temp").unwrap();
1328        assert_eq!(values.as_slice().unwrap(), &[10, 20, 30]);
1329    }
1330
1331    #[cfg(feature = "netcdf4")]
1332    #[test]
1333    fn classic_from_storage_keeps_open_range_backed() {
1334        let data = classic_large_offset_fixture();
1335        let full_len = data.len();
1336        let storage = Arc::new(CountingStorage::new(data));
1337
1338        let file = NcFile::from_storage(storage.clone()).unwrap();
1339        assert_eq!(file.format(), NcFormat::Classic);
1340        assert!(!storage
1341            .reads()
1342            .iter()
1343            .any(|&(offset, len)| offset == 0 && len == full_len));
1344
1345        let values: ndarray::ArrayD<i32> = file.read_variable("data").unwrap();
1346        assert_eq!(values.as_slice().unwrap(), &[10, 20, 30, 40]);
1347        assert!(storage
1348            .reads()
1349            .iter()
1350            .any(|&(offset, len)| { offset == (70 * 1024) as u64 && len == 16 }));
1351    }
1352
1353    #[cfg(feature = "netcdf4")]
1354    #[test]
1355    fn classic_from_storage_slice_reads_planned_range() {
1356        let storage = Arc::new(CountingStorage::new(classic_large_offset_fixture()));
1357        let file = NcFile::from_storage(storage.clone()).unwrap();
1358        let selection = NcSliceInfo {
1359            selections: vec![NcSliceInfoElem::Slice {
1360                start: 1,
1361                end: 3,
1362                step: 1,
1363            }],
1364        };
1365
1366        let values: ndarray::ArrayD<i32> = file.read_variable_slice("data", &selection).unwrap();
1367        assert_eq!(values.as_slice().unwrap(), &[20, 30]);
1368        assert!(storage
1369            .reads()
1370            .iter()
1371            .any(|&(offset, len)| { offset == (70 * 1024 + 4) as u64 && len == 8 }));
1372    }
1373
1374    #[cfg(feature = "netcdf4")]
1375    #[test]
1376    fn from_storage_minimal_cdf1() {
1377        // Minimal valid CDF-1 file: magic + numrecs + absent dim/att/var lists.
1378        let mut data = Vec::new();
1379        data.extend_from_slice(b"CDF\x01");
1380        data.extend_from_slice(&0u32.to_be_bytes()); // numrecs = 0
1381                                                     // dim_list: ABSENT
1382        data.extend_from_slice(&0u32.to_be_bytes()); // tag = 0
1383        data.extend_from_slice(&0u32.to_be_bytes()); // count = 0
1384                                                     // att_list: ABSENT
1385        data.extend_from_slice(&0u32.to_be_bytes());
1386        data.extend_from_slice(&0u32.to_be_bytes());
1387        // var_list: ABSENT
1388        data.extend_from_slice(&0u32.to_be_bytes());
1389        data.extend_from_slice(&0u32.to_be_bytes());
1390
1391        let file = NcFile::from_storage(Arc::new(BytesStorage::new(data))).unwrap();
1392        assert_eq!(file.format(), NcFormat::Classic);
1393        assert!(file.dimensions().unwrap().is_empty());
1394        assert!(file.variables().unwrap().is_empty());
1395        assert!(file.global_attributes().unwrap().is_empty());
1396    }
1397
1398    #[cfg(feature = "netcdf4")]
1399    #[test]
1400    fn from_storage_short_input_reports_invalid_magic() {
1401        let err = NcFile::from_storage(Arc::new(BytesStorage::new(vec![b'C', b'D'])))
1402            .err()
1403            .expect("short storage should not parse as NetCDF");
1404        assert!(matches!(err, Error::InvalidMagic));
1405    }
1406
1407    #[test]
1408    fn from_bytes_cdf1_with_data() {
1409        // Build a CDF-1 file with one dimension, one global attribute, and one variable.
1410        let mut data = Vec::new();
1411        data.extend_from_slice(b"CDF\x01");
1412        data.extend_from_slice(&0u32.to_be_bytes()); // numrecs = 0
1413
1414        // dim_list: 1 dimension "x" with size 3
1415        data.extend_from_slice(&0x0000_000Au32.to_be_bytes()); // NC_DIMENSION tag
1416        data.extend_from_slice(&1u32.to_be_bytes()); // nelems = 1
1417                                                     // name "x": length=1, "x", 3 bytes padding
1418        data.extend_from_slice(&1u32.to_be_bytes());
1419        data.push(b'x');
1420        data.extend_from_slice(&[0, 0, 0]); // padding to 4
1421                                            // dim size
1422        data.extend_from_slice(&3u32.to_be_bytes());
1423
1424        // att_list: 1 attribute "title" = "test"
1425        data.extend_from_slice(&0x0000_000Cu32.to_be_bytes()); // NC_ATTRIBUTE tag
1426        data.extend_from_slice(&1u32.to_be_bytes()); // nelems = 1
1427                                                     // name "title"
1428        data.extend_from_slice(&5u32.to_be_bytes());
1429        data.extend_from_slice(b"title");
1430        data.extend_from_slice(&[0, 0, 0]); // padding
1431                                            // nc_type = NC_CHAR = 2
1432        data.extend_from_slice(&2u32.to_be_bytes());
1433        // nvalues = 4
1434        data.extend_from_slice(&4u32.to_be_bytes());
1435        data.extend_from_slice(b"test"); // exactly 4 bytes, no padding needed
1436
1437        // var_list: 1 variable "vals" with dim x, type float
1438        data.extend_from_slice(&0x0000_000Bu32.to_be_bytes()); // NC_VARIABLE tag
1439        data.extend_from_slice(&1u32.to_be_bytes()); // nelems = 1
1440                                                     // name "vals"
1441        data.extend_from_slice(&4u32.to_be_bytes());
1442        data.extend_from_slice(b"vals");
1443        // ndims = 1
1444        data.extend_from_slice(&1u32.to_be_bytes());
1445        // dimid = 0
1446        data.extend_from_slice(&0u32.to_be_bytes());
1447        // att_list: absent
1448        data.extend_from_slice(&0u32.to_be_bytes());
1449        data.extend_from_slice(&0u32.to_be_bytes());
1450        // nc_type = NC_FLOAT = 5
1451        data.extend_from_slice(&5u32.to_be_bytes());
1452        // vsize = 12 (3 floats * 4 bytes)
1453        data.extend_from_slice(&12u32.to_be_bytes());
1454        // begin (offset): we'll put data right after this header
1455        let data_offset = data.len() as u32 + 4; // +4 for this field itself
1456        data.extend_from_slice(&data_offset.to_be_bytes());
1457
1458        // Now append the variable data: 3 floats
1459        data.extend_from_slice(&1.5f32.to_be_bytes());
1460        data.extend_from_slice(&2.5f32.to_be_bytes());
1461        data.extend_from_slice(&3.5f32.to_be_bytes());
1462
1463        let file = NcFile::from_bytes(&data).unwrap();
1464        assert_eq!(file.format(), NcFormat::Classic);
1465        assert_eq!(file.dimensions().unwrap().len(), 1);
1466        assert_eq!(file.dimensions().unwrap()[0].name, "x");
1467        assert_eq!(file.dimensions().unwrap()[0].size, 3);
1468
1469        assert_eq!(file.global_attributes().unwrap().len(), 1);
1470        assert_eq!(file.global_attributes().unwrap()[0].name, "title");
1471        assert_eq!(
1472            file.global_attributes().unwrap()[0]
1473                .value
1474                .as_string()
1475                .unwrap(),
1476            "test"
1477        );
1478
1479        assert_eq!(file.variables().unwrap().len(), 1);
1480        let var = file.variable("vals").unwrap();
1481        assert_eq!(var.dtype(), &NcType::Float);
1482        assert_eq!(var.shape(), vec![3]);
1483
1484        // Read the actual data through the classic file.
1485        let classic = file.as_classic().unwrap();
1486        let arr: ndarray::ArrayD<f32> = classic.read_variable("vals").unwrap();
1487        assert_eq!(arr.shape(), &[3]);
1488        assert_eq!(arr[[0]], 1.5f32);
1489        assert_eq!(arr[[1]], 2.5f32);
1490        assert_eq!(arr[[2]], 3.5f32);
1491    }
1492
1493    #[test]
1494    fn variable_not_found() {
1495        let mut data = Vec::new();
1496        data.extend_from_slice(b"CDF\x01");
1497        data.extend_from_slice(&0u32.to_be_bytes());
1498        // All absent.
1499        data.extend_from_slice(&0u32.to_be_bytes());
1500        data.extend_from_slice(&0u32.to_be_bytes());
1501        data.extend_from_slice(&0u32.to_be_bytes());
1502        data.extend_from_slice(&0u32.to_be_bytes());
1503        data.extend_from_slice(&0u32.to_be_bytes());
1504        data.extend_from_slice(&0u32.to_be_bytes());
1505
1506        let file = NcFile::from_bytes(&data).unwrap();
1507        assert!(matches!(
1508            file.variable("nonexistent").unwrap_err(),
1509            Error::VariableNotFound(_)
1510        ));
1511    }
1512
1513    #[test]
1514    fn group_not_found() {
1515        let mut data = Vec::new();
1516        data.extend_from_slice(b"CDF\x01");
1517        data.extend_from_slice(&0u32.to_be_bytes());
1518        data.extend_from_slice(&0u32.to_be_bytes());
1519        data.extend_from_slice(&0u32.to_be_bytes());
1520        data.extend_from_slice(&0u32.to_be_bytes());
1521        data.extend_from_slice(&0u32.to_be_bytes());
1522        data.extend_from_slice(&0u32.to_be_bytes());
1523        data.extend_from_slice(&0u32.to_be_bytes());
1524
1525        let file = NcFile::from_bytes(&data).unwrap();
1526        assert!(matches!(
1527            file.group("nonexistent").unwrap_err(),
1528            Error::GroupNotFound(_)
1529        ));
1530    }
1531}