Skip to main content

oxigdal_netcdf/
metadata.rs

1//! NetCDF file metadata structures.
2//!
3//! This module provides structures for representing NetCDF file metadata,
4//! including global attributes, dimensions, and variables.
5
6use serde::{Deserialize, Serialize};
7
8use crate::attribute::{Attribute, AttributeValue, Attributes};
9use crate::dimension::Dimensions;
10use crate::error::{NetCdfError, Result};
11use crate::variable::Variables;
12
13/// NetCDF file format version.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15pub enum NetCdfVersion {
16    /// NetCDF-3 Classic format
17    #[default]
18    Classic,
19    /// NetCDF-3 64-bit offset format
20    Offset64Bit,
21    /// NetCDF-4 (HDF5-based)
22    NetCdf4,
23    /// NetCDF-4 Classic model
24    NetCdf4Classic,
25}
26
27impl NetCdfVersion {
28    /// Check if this is a NetCDF-4 variant.
29    #[must_use]
30    pub const fn is_netcdf4(&self) -> bool {
31        matches!(self, Self::NetCdf4 | Self::NetCdf4Classic)
32    }
33
34    /// Check if this is a NetCDF-3 variant.
35    #[must_use]
36    pub const fn is_netcdf3(&self) -> bool {
37        matches!(self, Self::Classic | Self::Offset64Bit)
38    }
39
40    /// Get the version number.
41    #[must_use]
42    pub const fn version_number(&self) -> u8 {
43        match self {
44            Self::Classic | Self::Offset64Bit => 3,
45            Self::NetCdf4 | Self::NetCdf4Classic => 4,
46        }
47    }
48
49    /// Get the format name.
50    #[must_use]
51    pub const fn format_name(&self) -> &'static str {
52        match self {
53            Self::Classic => "NetCDF-3 Classic",
54            Self::Offset64Bit => "NetCDF-3 64-bit Offset",
55            Self::NetCdf4 => "NetCDF-4",
56            Self::NetCdf4Classic => "NetCDF-4 Classic",
57        }
58    }
59}
60
61/// CF (Climate and Forecast) conventions metadata.
62///
63/// CF conventions provide standardized metadata for climate and forecast data.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct CfMetadata {
66    /// CF conventions version (e.g., "CF-1.8")
67    pub conventions: Option<String>,
68    /// Title of the dataset
69    pub title: Option<String>,
70    /// Institution where data was produced
71    pub institution: Option<String>,
72    /// Source of the data (e.g., model name)
73    pub source: Option<String>,
74    /// History of processing
75    pub history: Option<String>,
76    /// Additional references
77    pub references: Option<String>,
78    /// Comments
79    pub comment: Option<String>,
80}
81
82impl CfMetadata {
83    /// Create new CF metadata.
84    #[must_use]
85    pub const fn new() -> Self {
86        Self {
87            conventions: None,
88            title: None,
89            institution: None,
90            source: None,
91            history: None,
92            references: None,
93            comment: None,
94        }
95    }
96
97    /// Create from global attributes.
98    #[must_use]
99    pub fn from_attributes(attrs: &Attributes) -> Self {
100        let mut cf = Self::new();
101
102        if let Some(value) = attrs.get_value("Conventions") {
103            if let Ok(s) = value.as_text() {
104                cf.conventions = Some(s.to_string());
105            }
106        }
107
108        if let Some(value) = attrs.get_value("title") {
109            if let Ok(s) = value.as_text() {
110                cf.title = Some(s.to_string());
111            }
112        }
113
114        if let Some(value) = attrs.get_value("institution") {
115            if let Ok(s) = value.as_text() {
116                cf.institution = Some(s.to_string());
117            }
118        }
119
120        if let Some(value) = attrs.get_value("source") {
121            if let Ok(s) = value.as_text() {
122                cf.source = Some(s.to_string());
123            }
124        }
125
126        if let Some(value) = attrs.get_value("history") {
127            if let Ok(s) = value.as_text() {
128                cf.history = Some(s.to_string());
129            }
130        }
131
132        if let Some(value) = attrs.get_value("references") {
133            if let Ok(s) = value.as_text() {
134                cf.references = Some(s.to_string());
135            }
136        }
137
138        if let Some(value) = attrs.get_value("comment") {
139            if let Ok(s) = value.as_text() {
140                cf.comment = Some(s.to_string());
141            }
142        }
143
144        cf
145    }
146
147    /// Convert to attributes.
148    pub fn to_attributes(&self) -> Attributes {
149        let mut attrs = Attributes::new();
150
151        if let Some(ref conventions) = self.conventions {
152            let _ = attrs.add(
153                Attribute::new("Conventions", AttributeValue::text(conventions.clone()))
154                    .expect("Valid attribute"),
155            );
156        }
157
158        if let Some(ref title) = self.title {
159            let _ = attrs.add(
160                Attribute::new("title", AttributeValue::text(title.clone()))
161                    .expect("Valid attribute"),
162            );
163        }
164
165        if let Some(ref institution) = self.institution {
166            let _ = attrs.add(
167                Attribute::new("institution", AttributeValue::text(institution.clone()))
168                    .expect("Valid attribute"),
169            );
170        }
171
172        if let Some(ref source) = self.source {
173            let _ = attrs.add(
174                Attribute::new("source", AttributeValue::text(source.clone()))
175                    .expect("Valid attribute"),
176            );
177        }
178
179        if let Some(ref history) = self.history {
180            let _ = attrs.add(
181                Attribute::new("history", AttributeValue::text(history.clone()))
182                    .expect("Valid attribute"),
183            );
184        }
185
186        if let Some(ref references) = self.references {
187            let _ = attrs.add(
188                Attribute::new("references", AttributeValue::text(references.clone()))
189                    .expect("Valid attribute"),
190            );
191        }
192
193        if let Some(ref comment) = self.comment {
194            let _ = attrs.add(
195                Attribute::new("comment", AttributeValue::text(comment.clone()))
196                    .expect("Valid attribute"),
197            );
198        }
199
200        attrs
201    }
202
203    /// Check if CF conventions are specified.
204    #[must_use]
205    pub fn has_conventions(&self) -> bool {
206        self.conventions.is_some()
207    }
208
209    /// Check if this is a CF-compliant dataset.
210    #[must_use]
211    pub fn is_cf_compliant(&self) -> bool {
212        self.conventions
213            .as_ref()
214            .is_some_and(|c| c.starts_with("CF-"))
215    }
216}
217
218/// NetCDF file metadata.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct NetCdfMetadata {
221    /// File format version
222    version: NetCdfVersion,
223    /// Global attributes
224    global_attributes: Attributes,
225    /// Dimensions
226    dimensions: Dimensions,
227    /// Variables
228    variables: Variables,
229    /// CF conventions metadata (optional)
230    cf_metadata: Option<CfMetadata>,
231}
232
233impl NetCdfMetadata {
234    /// Create new metadata.
235    ///
236    /// # Arguments
237    ///
238    /// * `version` - NetCDF format version
239    pub fn new(version: NetCdfVersion) -> Self {
240        Self {
241            version,
242            global_attributes: Attributes::new(),
243            dimensions: Dimensions::new(),
244            variables: Variables::new(),
245            cf_metadata: None,
246        }
247    }
248
249    /// Create NetCDF-3 Classic metadata.
250    #[must_use]
251    pub fn new_classic() -> Self {
252        Self::new(NetCdfVersion::Classic)
253    }
254
255    /// Create NetCDF-4 metadata.
256    #[must_use]
257    pub fn new_netcdf4() -> Self {
258        Self::new(NetCdfVersion::NetCdf4)
259    }
260
261    /// Get the format version.
262    #[must_use]
263    pub const fn version(&self) -> NetCdfVersion {
264        self.version
265    }
266
267    /// Get global attributes.
268    #[must_use]
269    pub const fn global_attributes(&self) -> &Attributes {
270        &self.global_attributes
271    }
272
273    /// Get mutable access to global attributes.
274    pub fn global_attributes_mut(&mut self) -> &mut Attributes {
275        &mut self.global_attributes
276    }
277
278    /// Get dimensions.
279    #[must_use]
280    pub const fn dimensions(&self) -> &Dimensions {
281        &self.dimensions
282    }
283
284    /// Get mutable access to dimensions.
285    pub fn dimensions_mut(&mut self) -> &mut Dimensions {
286        &mut self.dimensions
287    }
288
289    /// Get variables.
290    #[must_use]
291    pub const fn variables(&self) -> &Variables {
292        &self.variables
293    }
294
295    /// Get mutable access to variables.
296    pub fn variables_mut(&mut self) -> &mut Variables {
297        &mut self.variables
298    }
299
300    /// Get CF metadata.
301    #[must_use]
302    pub const fn cf_metadata(&self) -> Option<&CfMetadata> {
303        self.cf_metadata.as_ref()
304    }
305
306    /// Set CF metadata.
307    pub fn set_cf_metadata(&mut self, cf: CfMetadata) {
308        self.cf_metadata = Some(cf);
309    }
310
311    /// Parse CF metadata from global attributes.
312    pub fn parse_cf_metadata(&mut self) {
313        let cf = CfMetadata::from_attributes(&self.global_attributes);
314        if cf.has_conventions() {
315            self.cf_metadata = Some(cf);
316        }
317    }
318
319    /// Validate the metadata.
320    ///
321    /// # Errors
322    ///
323    /// Returns error if metadata is invalid.
324    pub fn validate(&self) -> Result<()> {
325        // Check that all variable dimensions exist
326        for var in self.variables.iter() {
327            for dim_name in var.dimension_names() {
328                if !self.dimensions.contains(dim_name) {
329                    return Err(NetCdfError::DimensionNotFound {
330                        name: dim_name.clone(),
331                    });
332                }
333            }
334
335            // Check NetCDF-3 compatibility
336            if self.version.is_netcdf3() && !var.is_netcdf3_compatible() {
337                return Err(NetCdfError::VariableError(format!(
338                    "Variable '{}' uses data type '{}' which is not compatible with NetCDF-3",
339                    var.name(),
340                    var.data_type().name()
341                )));
342            }
343        }
344
345        // Check unlimited dimension (only one allowed in NetCDF-3)
346        if self.version.is_netcdf3() {
347            let unlimited_count = self.dimensions.iter().filter(|d| d.is_unlimited()).count();
348            if unlimited_count > 1 {
349                return Err(NetCdfError::UnlimitedDimensionError(
350                    "NetCDF-3 can only have one unlimited dimension".to_string(),
351                ));
352            }
353        }
354
355        Ok(())
356    }
357
358    /// Get a summary of the metadata.
359    #[must_use]
360    pub fn summary(&self) -> String {
361        format!(
362            "NetCDF {} file with {} dimensions, {} variables, {} global attributes",
363            self.version.format_name(),
364            self.dimensions.len(),
365            self.variables.len(),
366            self.global_attributes.len()
367        )
368    }
369}
370
371impl Default for NetCdfMetadata {
372    fn default() -> Self {
373        Self::new_classic()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::dimension::Dimension;
381    use crate::variable::{DataType, Variable};
382
383    #[test]
384    fn test_netcdf_version() {
385        assert!(NetCdfVersion::Classic.is_netcdf3());
386        assert!(!NetCdfVersion::Classic.is_netcdf4());
387        assert_eq!(NetCdfVersion::Classic.version_number(), 3);
388
389        assert!(NetCdfVersion::NetCdf4.is_netcdf4());
390        assert!(!NetCdfVersion::NetCdf4.is_netcdf3());
391        assert_eq!(NetCdfVersion::NetCdf4.version_number(), 4);
392    }
393
394    #[test]
395    fn test_cf_metadata() {
396        let mut cf = CfMetadata::new();
397        cf.conventions = Some("CF-1.8".to_string());
398        cf.title = Some("Test Dataset".to_string());
399
400        assert!(cf.has_conventions());
401        assert!(cf.is_cf_compliant());
402
403        let attrs = cf.to_attributes();
404        assert_eq!(attrs.len(), 2);
405        assert!(attrs.contains("Conventions"));
406        assert!(attrs.contains("title"));
407    }
408
409    #[test]
410    fn test_cf_from_attributes() {
411        let mut attrs = Attributes::new();
412        attrs
413            .add(
414                Attribute::new("Conventions", AttributeValue::text("CF-1.8"))
415                    .expect("Failed to create Conventions attribute"),
416            )
417            .expect("Failed to add Conventions attribute");
418        attrs
419            .add(
420                Attribute::new("title", AttributeValue::text("Test"))
421                    .expect("Failed to create title attribute"),
422            )
423            .expect("Failed to add title attribute");
424
425        let cf = CfMetadata::from_attributes(&attrs);
426        assert_eq!(cf.conventions.as_deref(), Some("CF-1.8"));
427        assert_eq!(cf.title.as_deref(), Some("Test"));
428    }
429
430    #[test]
431    fn test_metadata_creation() {
432        let mut metadata = NetCdfMetadata::new_classic();
433        assert_eq!(metadata.version(), NetCdfVersion::Classic);
434
435        metadata
436            .dimensions_mut()
437            .add(Dimension::new("time", 10).expect("Failed to create time dimension"))
438            .expect("Failed to add time dimension");
439        metadata
440            .variables_mut()
441            .add(
442                Variable::new_coordinate("time", DataType::F64)
443                    .expect("Failed to create time variable"),
444            )
445            .expect("Failed to add time variable");
446
447        assert_eq!(metadata.dimensions().len(), 1);
448        assert_eq!(metadata.variables().len(), 1);
449    }
450
451    #[test]
452    fn test_metadata_validation() {
453        let mut metadata = NetCdfMetadata::new_classic();
454        metadata
455            .dimensions_mut()
456            .add(Dimension::new("time", 10).expect("Failed to create time dimension"))
457            .expect("Failed to add time dimension");
458        metadata
459            .variables_mut()
460            .add(
461                Variable::new_coordinate("time", DataType::F64)
462                    .expect("Failed to create time variable"),
463            )
464            .expect("Failed to add time variable");
465
466        assert!(metadata.validate().is_ok());
467    }
468
469    #[test]
470    fn test_metadata_validation_missing_dimension() {
471        let mut metadata = NetCdfMetadata::new_classic();
472        metadata
473            .variables_mut()
474            .add(
475                Variable::new("temp", DataType::F32, vec!["time".to_string()])
476                    .expect("Failed to create temp variable"),
477            )
478            .expect("Failed to add temp variable");
479
480        let result = metadata.validate();
481        assert!(result.is_err());
482    }
483
484    #[test]
485    fn test_netcdf3_type_validation() {
486        let mut metadata = NetCdfMetadata::new_classic();
487        metadata
488            .dimensions_mut()
489            .add(Dimension::new("x", 10).expect("Failed to create x dimension"))
490            .expect("Failed to add x dimension");
491        metadata
492            .variables_mut()
493            .add(
494                Variable::new("data", DataType::U16, vec!["x".to_string()])
495                    .expect("Failed to create data variable"),
496            )
497            .expect("Failed to add data variable");
498
499        let result = metadata.validate();
500        assert!(result.is_err());
501    }
502
503    #[test]
504    fn test_unlimited_dimension_validation() {
505        let mut metadata = NetCdfMetadata::new_classic();
506        metadata
507            .dimensions_mut()
508            .add(Dimension::new_unlimited("time", 10).expect("Failed to create time dimension"))
509            .expect("Failed to add time dimension");
510        metadata
511            .dimensions_mut()
512            .add(Dimension::new_unlimited("level", 5).expect("Failed to create level dimension"))
513            .expect("Failed to add level dimension");
514
515        let result = metadata.validate();
516        assert!(result.is_err());
517    }
518
519    #[test]
520    fn test_summary() {
521        let mut metadata = NetCdfMetadata::new_classic();
522        metadata
523            .dimensions_mut()
524            .add(Dimension::new("time", 10).expect("Failed to create time dimension"))
525            .expect("Failed to add time dimension");
526        metadata
527            .variables_mut()
528            .add(
529                Variable::new_coordinate("time", DataType::F64)
530                    .expect("Failed to create time variable"),
531            )
532            .expect("Failed to add time variable");
533
534        let summary = metadata.summary();
535        assert!(summary.contains("NetCDF-3"));
536        assert!(summary.contains("1 dimensions"));
537        assert!(summary.contains("1 variables"));
538    }
539}