Skip to main content

oxiphysics_io/netcdf/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::type_complexity)]
6use crate::netcdf::types::*;
7
8/// Create an AMBER-convention NetCDF trajectory file.
9///
10/// AMBER trajectories use:
11/// - dimensions: frame (unlimited), atom, spatial (3)
12/// - variables: coordinates(frame, atom, spatial), cell_lengths(frame, spatial),
13///   cell_angles(frame, spatial), time(frame)
14#[allow(dead_code)]
15pub fn create_amber_trajectory(
16    n_atoms: usize,
17    n_frames: usize,
18    coordinates: Vec<f64>,
19    time: Vec<f64>,
20    cell_lengths: Option<Vec<f64>>,
21    cell_angles: Option<Vec<f64>>,
22) -> NetCdfFile {
23    let mut file = NetCdfFile::new();
24    file.add_unlimited_dimension("frame", n_frames);
25    file.add_dimension("atom", n_atoms);
26    file.add_dimension("spatial", 3);
27    file.add_attribute("Conventions", "AMBER");
28    file.add_attribute("ConventionVersion", "1.0");
29    file.add_attribute("program", "OxiPhysics");
30    let mut coords_var = NetCdfVariable::new(
31        "coordinates",
32        vec!["frame".into(), "atom".into(), "spatial".into()],
33        coordinates,
34    );
35    coords_var.add_attribute("units", "angstrom");
36    file.add_variable(coords_var);
37    let mut time_var = NetCdfVariable::new("time", vec!["frame".into()], time);
38    time_var.add_attribute("units", "picosecond");
39    file.add_variable(time_var);
40    if let Some(lengths) = cell_lengths {
41        let mut var = NetCdfVariable::new(
42            "cell_lengths",
43            vec!["frame".into(), "spatial".into()],
44            lengths,
45        );
46        var.add_attribute("units", "angstrom");
47        file.add_variable(var);
48    }
49    if let Some(angles) = cell_angles {
50        let mut var = NetCdfVariable::new(
51            "cell_angles",
52            vec!["frame".into(), "spatial".into()],
53            angles,
54        );
55        var.add_attribute("units", "degree");
56        file.add_variable(var);
57    }
58    file
59}
60/// Extract coordinates for a specific frame from an AMBER trajectory.
61#[allow(dead_code)]
62pub fn extract_frame_coordinates(file: &NetCdfFile, frame_idx: usize) -> Option<Vec<[f64; 3]>> {
63    let coords_var = file.get_variable("coordinates")?;
64    let n_atoms = file.get_dimension_size("atom")?;
65    let start = frame_idx * n_atoms * 3;
66    let end = start + n_atoms * 3;
67    if end > coords_var.data.len() {
68        return None;
69    }
70    let mut positions = Vec::with_capacity(n_atoms);
71    for i in 0..n_atoms {
72        let base = start + i * 3;
73        positions.push([
74            coords_var.data[base],
75            coords_var.data[base + 1],
76            coords_var.data[base + 2],
77        ]);
78    }
79    Some(positions)
80}
81/// Create a coordinate variable (a 1D variable with the same name as its dimension).
82#[allow(dead_code)]
83pub fn create_coordinate_variable(name: &str, values: Vec<f64>, units: &str) -> NetCdfVariable {
84    let mut var = NetCdfVariable::new(name, vec![name.to_string()], values);
85    var.add_attribute("units", units);
86    var
87}
88/// Write a [`NetCdfFile`] as human-readable CDL text.
89#[allow(dead_code)]
90pub fn write_ncf_text(file: &NetCdfFile) -> String {
91    let mut out = String::new();
92    out.push_str("netcdf data {\n");
93    out.push_str("dimensions:\n");
94    for (name, size) in &file.dimensions {
95        if file.unlimited_dim.as_deref() == Some(name.as_str()) {
96            out.push_str(&format!("\t{name} = UNLIMITED ; // ({size} currently)\n"));
97        } else {
98            out.push_str(&format!("\t{name} = {size} ;\n"));
99        }
100    }
101    out.push_str("variables:\n");
102    for var in &file.variables {
103        let dims = var.dims.join(", ");
104        out.push_str(&format!("\tdouble {}({}) ;\n", var.name, dims));
105        for attr in &var.attributes {
106            out.push_str(&format!(
107                "\t\t{}:{} = \"{}\" ;\n",
108                var.name, attr.key, attr.value
109            ));
110        }
111    }
112    if !file.attributes.is_empty() {
113        out.push_str("// global attributes:\n");
114        for (key, value) in &file.attributes {
115            out.push_str(&format!("\t\t:data:{key} = \"{value}\" ;\n"));
116        }
117    }
118    out.push_str("data:\n");
119    for var in &file.variables {
120        let values: Vec<String> = var.data.iter().map(|v| format!("{v}")).collect();
121        out.push_str(&format!("\t{} = {} ;\n", var.name, values.join(", ")));
122    }
123    out.push_str("}\n");
124    out
125}
126/// Parse a CDL text string produced by [`write_ncf_text`] into a [`NetCdfFile`].
127///
128/// Returns `Err(String)` on the first parse failure.
129#[allow(dead_code)]
130pub fn parse_ncf_text(cdl: &str) -> Result<NetCdfFile, String> {
131    let mut dimensions: Vec<(String, usize)> = Vec::new();
132    let mut variable_headers: Vec<(String, Vec<String>)> = Vec::new();
133    let mut variable_attrs: std::collections::HashMap<String, Vec<VariableAttribute>> =
134        std::collections::HashMap::new();
135    let mut attributes: Vec<(String, String)> = Vec::new();
136    let mut data_map: std::collections::HashMap<String, Vec<f64>> =
137        std::collections::HashMap::new();
138    let mut unlimited_dim: Option<String> = None;
139    #[derive(PartialEq)]
140    pub(super) enum Section {
141        None,
142        Dimensions,
143        Variables,
144        Attributes,
145        Data,
146    }
147    let mut section = Section::None;
148    for raw in cdl.lines() {
149        let line = raw.trim();
150        if line.is_empty() || line == "{" || line == "}" || line.starts_with("netcdf ") {
151            continue;
152        }
153        if line == "dimensions:" {
154            section = Section::Dimensions;
155            continue;
156        }
157        if line == "variables:" {
158            section = Section::Variables;
159            continue;
160        }
161        if line.starts_with("// global attributes") {
162            section = Section::Attributes;
163            continue;
164        }
165        if line == "data:" {
166            section = Section::Data;
167            continue;
168        }
169        let line_no_comment = if let Some(pos) = line.find("//") {
170            line[..pos].trim()
171        } else {
172            line
173        };
174        let line_clean = line_no_comment.trim_end_matches(';').trim();
175        match section {
176            Section::Dimensions => {
177                let parts: Vec<&str> = line_clean.splitn(2, '=').collect();
178                if parts.len() != 2 {
179                    return Err(format!("Bad dimension line: {raw}"));
180                }
181                let dim_name = parts[0].trim().to_string();
182                let size_str = parts[1].trim();
183                if size_str == "UNLIMITED" {
184                    dimensions.push((dim_name.clone(), 0));
185                    unlimited_dim = Some(dim_name);
186                } else {
187                    let dim_size = size_str
188                        .parse::<usize>()
189                        .map_err(|_| format!("Bad dimension size in: {raw}"))?;
190                    dimensions.push((dim_name, dim_size));
191                }
192            }
193            Section::Variables => {
194                if line_clean.contains(':') && !line_clean.starts_with("double ") {
195                    let parts: Vec<&str> = line_clean.splitn(2, ':').collect();
196                    if parts.len() == 2 {
197                        let var_name = parts[0].trim().to_string();
198                        let rest: Vec<&str> = parts[1].splitn(2, '=').collect();
199                        if rest.len() == 2 {
200                            let key = rest[0].trim().to_string();
201                            let val = rest[1].trim().trim_matches('"').to_string();
202                            variable_attrs
203                                .entry(var_name)
204                                .or_default()
205                                .push(VariableAttribute { key, value: val });
206                        }
207                    }
208                    continue;
209                }
210                if !line_clean.starts_with("double ") {
211                    continue;
212                }
213                let rest = line_clean["double ".len()..].trim();
214                if let Some(paren) = rest.find('(') {
215                    let var_name = rest[..paren].trim().to_string();
216                    let dims_str = rest[paren + 1..].trim_end_matches(')').trim();
217                    let dims: Vec<String> = if dims_str.is_empty() {
218                        Vec::new()
219                    } else {
220                        dims_str.split(',').map(|s| s.trim().to_string()).collect()
221                    };
222                    variable_headers.push((var_name, dims));
223                } else {
224                    return Err(format!("Bad variable line: {raw}"));
225                }
226            }
227            Section::Attributes => {
228                if !line_clean.starts_with(":data:") {
229                    continue;
230                }
231                let rest = &line_clean[":data:".len()..];
232                let parts: Vec<&str> = rest.splitn(2, '=').collect();
233                if parts.len() != 2 {
234                    return Err(format!("Bad attribute line: {raw}"));
235                }
236                let key = parts[0].trim().to_string();
237                let val = parts[1].trim().trim_matches('"').to_string();
238                attributes.push((key, val));
239            }
240            Section::Data => {
241                let parts: Vec<&str> = line_clean.splitn(2, '=').collect();
242                if parts.len() != 2 {
243                    return Err(format!("Bad data line: {raw}"));
244                }
245                let var_name = parts[0].trim().to_string();
246                let values: Result<Vec<f64>, _> = parts[1]
247                    .split(',')
248                    .map(|s| s.trim().parse::<f64>())
249                    .collect();
250                let values =
251                    values.map_err(|_| format!("Bad numeric data in variable '{var_name}'"))?;
252                data_map.insert(var_name, values);
253            }
254            Section::None => {}
255        }
256    }
257    let variables: Vec<NetCdfVariable> = variable_headers
258        .into_iter()
259        .map(|(name, dims)| {
260            let data = data_map.remove(&name).unwrap_or_default();
261            let attrs = variable_attrs.remove(&name).unwrap_or_default();
262            NetCdfVariable {
263                name,
264                dims,
265                data,
266                attributes: attrs,
267            }
268        })
269        .collect();
270    Ok(NetCdfFile {
271        dimensions,
272        variables,
273        attributes,
274        unlimited_dim,
275    })
276}
277/// Reshape flat data into a 2D array (row-major) given row and column sizes.
278#[allow(dead_code)]
279pub fn reshape_2d(data: &[f64], n_rows: usize, n_cols: usize) -> Vec<Vec<f64>> {
280    let mut result = Vec::with_capacity(n_rows);
281    for r in 0..n_rows {
282        let start = r * n_cols;
283        let end = (start + n_cols).min(data.len());
284        if start < data.len() {
285            result.push(data[start..end].to_vec());
286        }
287    }
288    result
289}
290/// Compute statistics for a variable's data.
291#[allow(dead_code)]
292pub fn variable_stats(var: &NetCdfVariable) -> Option<(f64, f64, f64)> {
293    if var.data.is_empty() {
294        return None;
295    }
296    let mut min = f64::INFINITY;
297    let mut max = f64::NEG_INFINITY;
298    let mut sum = 0.0;
299    for &v in &var.data {
300        if v < min {
301            min = v;
302        }
303        if v > max {
304            max = v;
305        }
306        sum += v;
307    }
308    let mean = sum / var.data.len() as f64;
309    Some((min, max, mean))
310}
311/// Create a NetCDF string variable for atom names, residue names, etc.
312#[allow(dead_code)]
313pub fn create_string_variable(name: &str, dims: Vec<String>, values: Vec<String>) -> Nc4Variable {
314    let mut var = Nc4Variable::string_var(name, dims, values);
315    var.add_attribute("dtype", "string");
316    var
317}
318/// Copy global attributes from a source file into each variable of a target file,
319/// unless the variable already has an attribute with the same key.
320#[allow(dead_code)]
321pub fn inherit_global_attributes(source: &NetCdfFile, target: &mut NetCdfFile) {
322    for (key, value) in &source.attributes {
323        for var in target.variables.iter_mut() {
324            if var.get_attribute(key).is_none() {
325                var.add_attribute(key, value);
326            }
327        }
328    }
329}
330/// Merge two `NetCdfFile` datasets by combining variables (source variables
331/// overwrite target variables with the same name).
332#[allow(dead_code)]
333pub fn merge_netcdf_files(base: &NetCdfFile, overlay: &NetCdfFile) -> NetCdfFile {
334    let mut result = base.clone();
335    for var in &overlay.variables {
336        if let Some(existing) = result.variables.iter_mut().find(|v| v.name == var.name) {
337            *existing = var.clone();
338        } else {
339            result.variables.push(var.clone());
340        }
341    }
342    for (key, value) in &overlay.attributes {
343        if let Some(existing) = result.attributes.iter_mut().find(|(k, _)| k == key) {
344            existing.1 = value.clone();
345        } else {
346            result.attributes.push((key.clone(), value.clone()));
347        }
348    }
349    result
350}
351/// Parse dimension declarations from a CDL string.
352///
353/// Returns a list of `(name, size)` pairs for lines matching `name` = `size` ;`.
354#[allow(dead_code)]
355pub fn parse_cdl_dimensions(cdl: &str) -> Vec<(String, usize)> {
356    let mut result = Vec::new();
357    let mut in_dimensions = false;
358    for line in cdl.lines() {
359        let trimmed = line.trim();
360        if trimmed == "dimensions:" {
361            in_dimensions = true;
362            continue;
363        }
364        if in_dimensions {
365            if trimmed == "variables:"
366                || trimmed == "data:"
367                || trimmed.starts_with("//")
368                || trimmed == "}"
369            {
370                break;
371            }
372            let clean = trimmed.trim_end_matches(';').trim();
373            let parts: Vec<&str> = clean.splitn(2, '=').collect();
374            if parts.len() == 2 {
375                let name = parts[0].trim().to_string();
376                let size_str = parts[1].trim();
377                if let Ok(size) = size_str.parse::<usize>() {
378                    result.push((name, size));
379                }
380            }
381        }
382    }
383    result
384}
385/// Compute trajectory statistics.
386///
387/// Returns `(total_time, avg_displacement, n_frames)`.
388///
389/// `total_time` = last time – first time (0 if fewer than 2 frames).
390/// `avg_displacement` = mean Euclidean distance from frame 0 to the last frame
391/// (averaged over atoms; 0 if no atoms or only 1 frame).
392#[allow(dead_code)]
393pub fn trajectory_stats(times: &[f64], positions: &[Vec<[f64; 3]>]) -> (f64, f64, usize) {
394    let n_frames = times.len();
395    if n_frames == 0 {
396        return (0.0, 0.0, 0);
397    }
398    let total_time = if n_frames >= 2 {
399        times[n_frames - 1] - times[0]
400    } else {
401        0.0
402    };
403    let avg_displacement = if n_frames >= 2 && !positions.is_empty() {
404        let first = &positions[0];
405        let last = &positions[positions.len() - 1];
406        let n = first.len().min(last.len());
407        if n == 0 {
408            0.0
409        } else {
410            let sum: f64 = (0..n)
411                .map(|i| {
412                    let dx = last[i][0] - first[i][0];
413                    let dy = last[i][1] - first[i][1];
414                    let dz = last[i][2] - first[i][2];
415                    (dx * dx + dy * dy + dz * dz).sqrt()
416                })
417                .sum();
418            sum / n as f64
419        }
420    } else {
421        0.0
422    };
423    (total_time, avg_displacement, n_frames)
424}
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    fn make_file() -> NetCdfFile {
430        NetCdfFile {
431            dimensions: vec![("time".to_string(), 3), ("space".to_string(), 2)],
432            variables: vec![
433                NetCdfVariable {
434                    name: "temperature".to_string(),
435                    dims: vec!["time".to_string(), "space".to_string()],
436                    data: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
437                    attributes: Vec::new(),
438                },
439                NetCdfVariable {
440                    name: "pressure".to_string(),
441                    dims: vec!["time".to_string()],
442                    data: vec![101.3, 100.8, 99.5],
443                    attributes: Vec::new(),
444                },
445            ],
446            attributes: vec![
447                ("title".to_string(), "Test dataset".to_string()),
448                ("units".to_string(), "SI".to_string()),
449            ],
450            unlimited_dim: None,
451        }
452    }
453    #[test]
454    fn test_write_ncf_text_structure() {
455        let file = make_file();
456        let cdl = write_ncf_text(&file);
457        assert!(cdl.contains("dimensions:"));
458        assert!(cdl.contains("time = 3"));
459        assert!(cdl.contains("space = 2"));
460        assert!(cdl.contains("variables:"));
461        assert!(cdl.contains("double temperature(time, space)"));
462        assert!(cdl.contains("double pressure(time)"));
463        assert!(cdl.contains("data:"));
464        assert!(cdl.contains("temperature ="));
465        assert!(cdl.contains("pressure ="));
466        assert!(cdl.contains(r#":data:title = "Test dataset""#));
467    }
468    #[test]
469    fn test_ncf_roundtrip() {
470        let original = make_file();
471        let cdl = write_ncf_text(&original);
472        let parsed = parse_ncf_text(&cdl).expect("parse failed");
473        assert_eq!(parsed.dimensions.len(), original.dimensions.len());
474        for (orig, got) in original.dimensions.iter().zip(parsed.dimensions.iter()) {
475            assert_eq!(orig.0, got.0, "dimension name mismatch");
476            assert_eq!(orig.1, got.1, "dimension size mismatch");
477        }
478        assert_eq!(parsed.variables.len(), original.variables.len());
479        for (orig, got) in original.variables.iter().zip(parsed.variables.iter()) {
480            assert_eq!(orig.name, got.name, "variable name mismatch");
481            assert_eq!(orig.dims, got.dims, "variable dims mismatch");
482            assert_eq!(orig.data.len(), got.data.len(), "data length mismatch");
483            for (a, b) in orig.data.iter().zip(got.data.iter()) {
484                assert!((a - b).abs() < 1e-9, "data value mismatch: {a} vs {b}");
485            }
486        }
487        assert_eq!(parsed.attributes.len(), original.attributes.len());
488        for (orig, got) in original.attributes.iter().zip(parsed.attributes.iter()) {
489            assert_eq!(orig.0, got.0, "attribute key mismatch");
490            assert_eq!(orig.1, got.1, "attribute value mismatch");
491        }
492    }
493    #[test]
494    fn test_ncf_empty_dataset() {
495        let file = NetCdfFile {
496            dimensions: vec![],
497            variables: vec![],
498            attributes: vec![],
499            unlimited_dim: None,
500        };
501        let cdl = write_ncf_text(&file);
502        let parsed = parse_ncf_text(&cdl).expect("parse failed");
503        assert!(parsed.dimensions.is_empty());
504        assert!(parsed.variables.is_empty());
505        assert!(parsed.attributes.is_empty());
506    }
507    #[test]
508    fn test_netcdf_file_new() {
509        let file = NetCdfFile::new();
510        assert!(file.dimensions.is_empty());
511        assert!(file.variables.is_empty());
512        assert!(file.attributes.is_empty());
513        assert!(file.unlimited_dim.is_none());
514    }
515    #[test]
516    fn test_add_dimension() {
517        let mut file = NetCdfFile::new();
518        file.add_dimension("time", 10);
519        assert_eq!(file.get_dimension_size("time"), Some(10));
520        assert_eq!(file.get_dimension_size("missing"), None);
521    }
522    #[test]
523    fn test_add_unlimited_dimension() {
524        let mut file = NetCdfFile::new();
525        file.add_unlimited_dimension("frame", 5);
526        assert!(file.is_unlimited_dimension("frame"));
527        assert!(!file.is_unlimited_dimension("atom"));
528        assert_eq!(file.get_dimension_size("frame"), Some(5));
529    }
530    #[test]
531    fn test_variable_new_and_attributes() {
532        let mut var = NetCdfVariable::new("temp", vec!["time".into()], vec![1.0, 2.0, 3.0]);
533        assert_eq!(var.len(), 3);
534        assert!(!var.is_empty());
535        var.add_attribute("units", "K");
536        assert_eq!(var.get_attribute("units"), Some("K"));
537        assert_eq!(var.get_attribute("missing"), None);
538    }
539    #[test]
540    fn test_variable_empty() {
541        let var = NetCdfVariable::new("empty", vec![], vec![]);
542        assert!(var.is_empty());
543        assert_eq!(var.len(), 0);
544    }
545    #[test]
546    fn test_get_variable() {
547        let mut file = NetCdfFile::new();
548        file.add_variable(NetCdfVariable::new("x", vec![], vec![1.0]));
549        file.add_variable(NetCdfVariable::new("y", vec![], vec![2.0]));
550        assert!(file.get_variable("x").is_some());
551        assert!(file.get_variable("z").is_none());
552        assert_eq!(file.get_variable("y").unwrap().data[0], 2.0);
553    }
554    #[test]
555    fn test_get_variable_mut() {
556        let mut file = NetCdfFile::new();
557        file.add_variable(NetCdfVariable::new("x", vec![], vec![1.0]));
558        file.get_variable_mut("x").unwrap().data.push(2.0);
559        assert_eq!(file.get_variable("x").unwrap().data.len(), 2);
560    }
561    #[test]
562    fn test_global_attribute() {
563        let mut file = NetCdfFile::new();
564        file.add_attribute("title", "test");
565        assert_eq!(file.get_attribute("title"), Some("test"));
566        assert_eq!(file.get_attribute("missing"), None);
567    }
568    #[test]
569    fn test_variable_names() {
570        let mut file = NetCdfFile::new();
571        file.add_variable(NetCdfVariable::new("a", vec![], vec![]));
572        file.add_variable(NetCdfVariable::new("b", vec![], vec![]));
573        let names = file.variable_names();
574        assert_eq!(names, vec!["a", "b"]);
575    }
576    #[test]
577    fn test_dimension_names() {
578        let mut file = NetCdfFile::new();
579        file.add_dimension("time", 10);
580        file.add_dimension("space", 3);
581        let names = file.dimension_names();
582        assert_eq!(names, vec!["time", "space"]);
583    }
584    #[test]
585    fn test_dimension_descriptor() {
586        let unlimited = NetCdfDimension {
587            name: "frame".into(),
588            size: None,
589        };
590        assert!(unlimited.is_unlimited());
591        assert_eq!(unlimited.effective_size(), 0);
592        let fixed = NetCdfDimension {
593            name: "atom".into(),
594            size: Some(100),
595        };
596        assert!(!fixed.is_unlimited());
597        assert_eq!(fixed.effective_size(), 100);
598    }
599    #[test]
600    fn test_amber_trajectory_creation() {
601        let n_atoms = 3;
602        let n_frames = 2;
603        let coords: Vec<f64> = (0..18).map(|i| i as f64).collect();
604        let time = vec![0.0, 1.0];
605        let cell_lengths = Some(vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.0]);
606        let cell_angles = Some(vec![90.0, 90.0, 90.0, 90.0, 90.0, 90.0]);
607        let file =
608            create_amber_trajectory(n_atoms, n_frames, coords, time, cell_lengths, cell_angles);
609        assert_eq!(file.get_attribute("Conventions"), Some("AMBER"));
610        assert_eq!(file.get_dimension_size("atom"), Some(3));
611        assert_eq!(file.get_dimension_size("spatial"), Some(3));
612        assert!(file.is_unlimited_dimension("frame"));
613        assert!(file.get_variable("coordinates").is_some());
614        assert!(file.get_variable("time").is_some());
615        assert!(file.get_variable("cell_lengths").is_some());
616        assert!(file.get_variable("cell_angles").is_some());
617    }
618    #[test]
619    fn test_amber_trajectory_no_cell() {
620        let file = create_amber_trajectory(2, 1, vec![0.0; 6], vec![0.0], None, None);
621        assert!(file.get_variable("cell_lengths").is_none());
622        assert!(file.get_variable("cell_angles").is_none());
623    }
624    #[test]
625    fn test_extract_frame_coordinates() {
626        let coords = vec![
627            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
628        ];
629        let file = create_amber_trajectory(2, 2, coords, vec![0.0, 1.0], None, None);
630        let frame0 = extract_frame_coordinates(&file, 0).unwrap();
631        assert_eq!(frame0.len(), 2);
632        assert!((frame0[0][0] - 1.0).abs() < 1e-10);
633        assert!((frame0[1][2] - 6.0).abs() < 1e-10);
634        let frame1 = extract_frame_coordinates(&file, 1).unwrap();
635        assert!((frame1[0][0] - 7.0).abs() < 1e-10);
636        assert!(extract_frame_coordinates(&file, 5).is_none());
637    }
638    #[test]
639    fn test_coordinate_variable() {
640        let var = create_coordinate_variable("time", vec![0.0, 0.5, 1.0], "seconds");
641        assert_eq!(var.name, "time");
642        assert_eq!(var.dims, vec!["time"]);
643        assert_eq!(var.get_attribute("units"), Some("seconds"));
644        assert_eq!(var.data.len(), 3);
645    }
646    #[test]
647    fn test_reshape_2d() {
648        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
649        let matrix = reshape_2d(&data, 2, 3);
650        assert_eq!(matrix.len(), 2);
651        assert_eq!(matrix[0], vec![1.0, 2.0, 3.0]);
652        assert_eq!(matrix[1], vec![4.0, 5.0, 6.0]);
653    }
654    #[test]
655    fn test_reshape_2d_empty() {
656        let matrix = reshape_2d(&[], 0, 3);
657        assert!(matrix.is_empty());
658    }
659    #[test]
660    fn test_variable_stats() {
661        let var = NetCdfVariable::new("t", vec![], vec![1.0, 5.0, 3.0]);
662        let (min, max, mean) = variable_stats(&var).unwrap();
663        assert!((min - 1.0).abs() < 1e-10);
664        assert!((max - 5.0).abs() < 1e-10);
665        assert!((mean - 3.0).abs() < 1e-10);
666    }
667    #[test]
668    fn test_variable_stats_empty() {
669        let var = NetCdfVariable::new("empty", vec![], vec![]);
670        assert!(variable_stats(&var).is_none());
671    }
672    #[test]
673    fn test_unlimited_dim_roundtrip() {
674        let mut file = NetCdfFile::new();
675        file.add_unlimited_dimension("frame", 5);
676        file.add_dimension("atom", 3);
677        file.add_variable(NetCdfVariable::new(
678            "x",
679            vec!["frame".into(), "atom".into()],
680            vec![1.0; 15],
681        ));
682        let cdl = write_ncf_text(&file);
683        assert!(cdl.contains("UNLIMITED"));
684        let parsed = parse_ncf_text(&cdl).unwrap();
685        assert!(parsed.unlimited_dim.is_some());
686        assert_eq!(parsed.unlimited_dim.as_deref(), Some("frame"));
687    }
688    #[test]
689    fn test_variable_attributes_roundtrip() {
690        let mut file = NetCdfFile::new();
691        file.add_dimension("x", 3);
692        let mut var = NetCdfVariable::new("temp", vec!["x".into()], vec![1.0, 2.0, 3.0]);
693        var.add_attribute("units", "kelvin");
694        var.add_attribute("long_name", "temperature");
695        file.add_variable(var);
696        let cdl = write_ncf_text(&file);
697        let parsed = parse_ncf_text(&cdl).unwrap();
698        let parsed_var = parsed.get_variable("temp").unwrap();
699        assert_eq!(parsed_var.get_attribute("units"), Some("kelvin"));
700        assert_eq!(parsed_var.get_attribute("long_name"), Some("temperature"));
701    }
702    #[test]
703    fn test_amber_trajectory_roundtrip_cdl() {
704        let file = create_amber_trajectory(
705            2,
706            1,
707            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
708            vec![0.0],
709            None,
710            None,
711        );
712        let cdl = write_ncf_text(&file);
713        let parsed = parse_ncf_text(&cdl).unwrap();
714        assert_eq!(parsed.get_attribute("Conventions"), Some("AMBER"));
715        assert!(parsed.get_variable("coordinates").is_some());
716    }
717    #[test]
718    fn test_full_amber_with_cell() {
719        let file = create_amber_trajectory(
720            1,
721            2,
722            vec![0.0; 6],
723            vec![0.0, 0.5],
724            Some(vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.0]),
725            Some(vec![90.0, 90.0, 90.0, 90.0, 90.0, 90.0]),
726        );
727        let coords_var = file.get_variable("coordinates").unwrap();
728        assert_eq!(coords_var.get_attribute("units"), Some("angstrom"));
729        let time_var = file.get_variable("time").unwrap();
730        assert_eq!(time_var.get_attribute("units"), Some("picosecond"));
731    }
732    #[test]
733    fn test_nc4_data_type_byte_width() {
734        assert_eq!(Nc4DataType::Float64.byte_width(), 8);
735        assert_eq!(Nc4DataType::Float32.byte_width(), 4);
736        assert_eq!(Nc4DataType::Int32.byte_width(), 4);
737        assert_eq!(Nc4DataType::UInt8.byte_width(), 1);
738        assert_eq!(Nc4DataType::String.byte_width(), 0);
739    }
740    #[test]
741    fn test_nc4_data_type_name() {
742        assert_eq!(Nc4DataType::Float64.type_name(), "double");
743        assert_eq!(Nc4DataType::String.type_name(), "string");
744        assert_eq!(Nc4DataType::Int32.type_name(), "int");
745    }
746    #[test]
747    fn test_nc4_variable_float64() {
748        let var = Nc4Variable::float64("temp", vec!["time".into()], vec![1.0, 2.0, 3.0]);
749        assert_eq!(var.data_type, Nc4DataType::Float64);
750        assert_eq!(var.len(), 3);
751        assert!(!var.is_empty());
752    }
753    #[test]
754    fn test_nc4_variable_string() {
755        let var = Nc4Variable::string_var(
756            "names",
757            vec!["n".into()],
758            vec!["ALA".to_string(), "GLY".to_string()],
759        );
760        assert_eq!(var.data_type, Nc4DataType::String);
761        assert_eq!(var.string_data.len(), 2);
762        assert_eq!(var.string_data[0], "ALA");
763        assert_eq!(var.len(), 2);
764    }
765    #[test]
766    fn test_nc4_variable_int32() {
767        let var = Nc4Variable::int32("step", vec!["frame".into()], vec![0, 100, 200]);
768        assert_eq!(var.data_type, Nc4DataType::Int32);
769        assert_eq!(var.int_data.len(), 3);
770        assert_eq!(var.int_data[1], 100);
771    }
772    #[test]
773    fn test_nc4_variable_attributes() {
774        let mut var = Nc4Variable::float64("temp", vec![], vec![]);
775        var.add_attribute("units", "K");
776        var.add_attribute("long_name", "temperature");
777        assert_eq!(var.get_attribute("units"), Some("K"));
778        assert_eq!(var.get_attribute("missing"), None);
779    }
780    #[test]
781    fn test_nc4_group_add_variable() {
782        let mut group = Nc4Group::new("trajectory");
783        group.add_variable(Nc4Variable::float64("x", vec![], vec![1.0, 2.0]));
784        group.add_variable(Nc4Variable::float64("y", vec![], vec![3.0, 4.0]));
785        assert_eq!(group.variables.len(), 2);
786        let xv = group.get_variable("x").unwrap();
787        assert!((xv.float_data[0] - 1.0).abs() < 1e-10);
788    }
789    #[test]
790    fn test_nc4_group_add_subgroup() {
791        let mut root = Nc4Group::new("/");
792        let mut child = Nc4Group::new("data");
793        child.add_variable(Nc4Variable::float64("t", vec![], vec![0.0]));
794        root.add_subgroup(child);
795        assert_eq!(root.subgroups.len(), 1);
796        assert_eq!(root.subgroups[0].name, "data");
797    }
798    #[test]
799    fn test_nc4_group_total_variable_count() {
800        let mut root = Nc4Group::new("/");
801        root.add_variable(Nc4Variable::float64("a", vec![], vec![]));
802        let mut child = Nc4Group::new("child");
803        child.add_variable(Nc4Variable::float64("b", vec![], vec![]));
804        child.add_variable(Nc4Variable::float64("c", vec![], vec![]));
805        root.add_subgroup(child);
806        assert_eq!(root.total_variable_count(), 3);
807    }
808    #[test]
809    fn test_nc4_group_all_variables() {
810        let mut root = Nc4Group::new("/");
811        root.add_variable(Nc4Variable::float64("a", vec![], vec![]));
812        let mut child = Nc4Group::new("child");
813        child.add_variable(Nc4Variable::float64("b", vec![], vec![]));
814        root.add_subgroup(child);
815        let all = root.all_variables();
816        assert_eq!(all.len(), 2);
817    }
818    #[test]
819    fn test_nc4_group_attributes() {
820        let mut group = Nc4Group::new("meta");
821        group.add_attribute("author", "test");
822        assert_eq!(group.get_attribute("author"), Some("test"));
823        assert_eq!(group.get_attribute("missing"), None);
824    }
825    #[test]
826    fn test_nc4_file_add_dimension() {
827        let mut f = Nc4File::new();
828        f.add_dimension("atom", 100);
829        f.add_unlimited_dimension("frame");
830        assert_eq!(f.get_dimension_size("atom"), Some(100));
831        assert_eq!(f.get_dimension_size("frame"), None);
832    }
833    #[test]
834    fn test_nc4_file_extend_unlimited() {
835        let mut f = Nc4File::new();
836        f.add_unlimited_dimension("frame");
837        f.extend_unlimited();
838        f.extend_unlimited();
839        assert_eq!(f.unlimited_size, 2);
840    }
841    #[test]
842    fn test_nc4_file_global_attributes() {
843        let mut f = Nc4File::new();
844        f.add_global_attribute("Conventions", "CF-1.8");
845        f.add_global_attribute("title", "Test");
846        assert_eq!(f.get_global_attribute("Conventions"), Some("CF-1.8"));
847        assert_eq!(f.get_global_attribute("missing"), None);
848    }
849    #[test]
850    fn test_nc4_file_add_get_variable() {
851        let mut f = Nc4File::new();
852        f.add_variable(Nc4Variable::float64(
853            "pressure",
854            vec!["time".into()],
855            vec![101.3, 100.5],
856        ));
857        let v = f.get_variable("pressure").unwrap();
858        assert_eq!(v.float_data.len(), 2);
859        assert!(f.get_variable("missing").is_none());
860    }
861    #[test]
862    fn test_nc4_file_add_group() {
863        let mut f = Nc4File::new();
864        let mut g = Nc4Group::new("simdata");
865        g.add_variable(Nc4Variable::int32("step", vec![], vec![0, 1, 2]));
866        f.add_group(g);
867        let retrieved = f.get_group("simdata").unwrap();
868        assert_eq!(retrieved.variables.len(), 1);
869        assert!(f.get_group("missing").is_none());
870    }
871    #[test]
872    fn test_nc4_file_total_variable_count() {
873        let mut f = Nc4File::new();
874        f.add_variable(Nc4Variable::float64("a", vec![], vec![]));
875        let mut g = Nc4Group::new("sub");
876        g.add_variable(Nc4Variable::float64("b", vec![], vec![]));
877        f.add_group(g);
878        assert_eq!(f.total_variable_count(), 2);
879    }
880    #[test]
881    fn test_nc4_file_to_bytes_non_empty() {
882        let mut f = Nc4File::new();
883        f.add_global_attribute("test", "value");
884        f.add_variable(Nc4Variable::float64("x", vec![], vec![1.0, 2.0, 3.0]));
885        let bytes = f.to_bytes();
886        assert!(!bytes.is_empty());
887        assert_eq!(&bytes[0..5], b"OXNC4");
888    }
889    #[test]
890    fn test_create_string_variable() {
891        let var = create_string_variable(
892            "residue_name",
893            vec!["atom".into()],
894            vec!["ALA".to_string(), "GLY".to_string(), "PRO".to_string()],
895        );
896        assert_eq!(var.name, "residue_name");
897        assert_eq!(var.string_data.len(), 3);
898        assert_eq!(var.string_data[1], "GLY");
899        assert_eq!(var.get_attribute("dtype"), Some("string"));
900    }
901    #[test]
902    fn test_inherit_global_attributes() {
903        let mut source = NetCdfFile::new();
904        source.add_attribute("Conventions", "AMBER");
905        source.add_attribute("program", "OxiPhysics");
906        let mut target = NetCdfFile::new();
907        let mut var = NetCdfVariable::new("x", vec![], vec![1.0]);
908        var.add_attribute("Conventions", "CF");
909        target.add_variable(var);
910        inherit_global_attributes(&source, &mut target);
911        let xv = target.get_variable("x").unwrap();
912        assert_eq!(xv.get_attribute("Conventions"), Some("CF"));
913        assert_eq!(xv.get_attribute("program"), Some("OxiPhysics"));
914    }
915    #[test]
916    fn test_merge_netcdf_files() {
917        let mut base = NetCdfFile::new();
918        base.add_dimension("time", 3);
919        base.add_variable(NetCdfVariable::new(
920            "a",
921            vec!["time".into()],
922            vec![1.0, 2.0, 3.0],
923        ));
924        base.add_variable(NetCdfVariable::new(
925            "b",
926            vec!["time".into()],
927            vec![4.0, 5.0, 6.0],
928        ));
929        base.add_attribute("source", "base");
930        let mut overlay = NetCdfFile::new();
931        overlay.add_variable(NetCdfVariable::new(
932            "a",
933            vec!["time".into()],
934            vec![10.0, 20.0, 30.0],
935        ));
936        overlay.add_variable(NetCdfVariable::new("c", vec![], vec![99.0]));
937        overlay.add_attribute("source", "overlay");
938        let merged = merge_netcdf_files(&base, &overlay);
939        assert_eq!(merged.variable_names().len(), 3);
940        let av = merged.get_variable("a").unwrap();
941        assert!((av.data[0] - 10.0).abs() < 1e-10);
942        let bv = merged.get_variable("b").unwrap();
943        assert!((bv.data[0] - 4.0).abs() < 1e-10);
944        assert_eq!(merged.get_attribute("source"), Some("overlay"));
945    }
946    #[test]
947    fn test_netcdf_file_new_empty() {
948        let f = NetcdfFile::new();
949        assert!(f.dimensions.is_empty());
950        assert!(f.variables.is_empty());
951        assert!(f.global_attrs.is_empty());
952    }
953    #[test]
954    fn test_netcdf_file_add_dimension() {
955        let mut f = NetcdfFile::new();
956        f.add_dimension("time", 10);
957        f.add_dimension("space", 3);
958        assert_eq!(f.dimensions.get("time"), Some(&10));
959        assert_eq!(f.dimensions.get("space"), Some(&3));
960    }
961    #[test]
962    fn test_netcdf_file_add_variable_increases_count() {
963        let mut f = NetcdfFile::new();
964        assert_eq!(f.variables.len(), 0);
965        f.add_variable(NetcdfVariable {
966            name: "temp".to_string(),
967            dimensions: vec!["time".to_string()],
968            data: vec![1.0, 2.0, 3.0],
969            units: "K".to_string(),
970            long_name: "temperature".to_string(),
971        });
972        assert_eq!(f.variables.len(), 1);
973        f.add_variable(NetcdfVariable {
974            name: "pressure".to_string(),
975            dimensions: vec!["time".to_string()],
976            data: vec![100.0],
977            units: "Pa".to_string(),
978            long_name: "pressure".to_string(),
979        });
980        assert_eq!(f.variables.len(), 2);
981    }
982    #[test]
983    fn test_netcdf_file_add_global_attr() {
984        let mut f = NetcdfFile::new();
985        f.add_global_attr("Conventions", "CF-1.8");
986        f.add_global_attr("title", "Test");
987        assert_eq!(f.global_attrs.len(), 2);
988        assert!(
989            f.global_attrs
990                .iter()
991                .any(|(k, v)| k == "Conventions" && v == "CF-1.8")
992        );
993    }
994    #[test]
995    fn test_netcdf_file_write_cdl_contains_dimension_name() {
996        let mut f = NetcdfFile::new();
997        f.add_dimension("latitude", 180);
998        f.add_dimension("longitude", 360);
999        let cdl = f.write_cdl();
1000        assert!(cdl.contains("latitude"), "CDL missing dimension 'latitude'");
1001        assert!(cdl.contains("180"), "CDL missing dimension size 180");
1002        assert!(cdl.contains("longitude"));
1003        assert!(cdl.contains("360"));
1004    }
1005    #[test]
1006    fn test_netcdf_file_write_cdl_contains_variable() {
1007        let mut f = NetcdfFile::new();
1008        f.add_dimension("t", 5);
1009        f.add_variable(NetcdfVariable {
1010            name: "velocity".to_string(),
1011            dimensions: vec!["t".to_string()],
1012            data: vec![1.0, 2.0, 3.0, 4.0, 5.0],
1013            units: "m/s".to_string(),
1014            long_name: "wind velocity".to_string(),
1015        });
1016        let cdl = f.write_cdl();
1017        assert!(cdl.contains("velocity"));
1018        assert!(cdl.contains("m/s"));
1019        assert!(cdl.contains("wind velocity"));
1020    }
1021    #[test]
1022    fn test_parse_cdl_dimensions() {
1023        let cdl = "netcdf test {\ndimensions:\n\ttime = 10 ;\n\tatom = 100 ;\nvariables:\n}\n";
1024        let dims = parse_cdl_dimensions(cdl);
1025        assert_eq!(dims.len(), 2, "expected 2 dimensions, got {:?}", dims);
1026        assert!(dims.iter().any(|(n, s)| n == "time" && *s == 10));
1027        assert!(dims.iter().any(|(n, s)| n == "atom" && *s == 100));
1028    }
1029    #[test]
1030    fn test_trajectory_stats_n_frames() {
1031        let times = vec![0.0, 0.5, 1.0, 1.5, 2.0];
1032        let positions: Vec<Vec<[f64; 3]>> = times.iter().map(|_| vec![[0.0, 0.0, 0.0]]).collect();
1033        let (_total_time, _avg_disp, n_frames) = trajectory_stats(&times, &positions);
1034        assert_eq!(n_frames, 5);
1035    }
1036    #[test]
1037    fn test_trajectory_stats_total_time() {
1038        let times = vec![0.0, 1.0, 2.0];
1039        let positions: Vec<Vec<[f64; 3]>> = times.iter().map(|_| vec![[0.0, 0.0, 0.0]]).collect();
1040        let (total_time, _, _) = trajectory_stats(&times, &positions);
1041        assert!((total_time - 2.0).abs() < 1e-10);
1042    }
1043    #[test]
1044    fn test_trajectory_stats_avg_displacement() {
1045        let times = vec![0.0, 1.0];
1046        let positions = vec![
1047            vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
1048            vec![[3.0, 4.0, 0.0], [1.0, 0.0, 0.0]],
1049        ];
1050        let (_total_time, avg_disp, _) = trajectory_stats(&times, &positions);
1051        assert!((avg_disp - 2.5).abs() < 1e-10, "avg_disp={}", avg_disp);
1052    }
1053    #[test]
1054    fn test_trajectory_write_creates_file() {
1055        let times = vec![0.0, 0.5, 1.0];
1056        let positions = vec![
1057            vec![[1.0, 2.0, 3.0]],
1058            vec![[1.1, 2.1, 3.1]],
1059            vec![[1.2, 2.2, 3.2]],
1060        ];
1061        let path = "/tmp/test_trajectory.cdl";
1062        NetcdfFile::trajectory_write(path, &times, &positions).unwrap();
1063        let content = std::fs::read_to_string(path).unwrap();
1064        assert!(content.contains("frame"), "missing 'frame' dimension");
1065        assert!(content.contains("atom"));
1066        assert!(content.contains("coordinates"));
1067        assert!(content.contains("time"));
1068    }
1069}
1070/// Write a simple particle trajectory in CDL format using [`NetcdfWriter`].
1071///
1072/// Dimensions: `time` (unlimited), `atom`, `spatial` (3).
1073/// Variables: `time(time)`, `x(time, atom)`, `y(time, atom)`, `z(time, atom)`.
1074#[allow(dead_code)]
1075pub fn write_particle_trajectory_cdl(
1076    times: &[f64],
1077    positions: &[Vec<[f64; 3]>],
1078    units_time: &str,
1079    units_pos: &str,
1080) -> String {
1081    let n_frames = times.len();
1082    let n_atoms = positions.first().map(|f| f.len()).unwrap_or(0);
1083    let mut w = NetcdfWriter::new("trajectory");
1084    w.add_unlimited_dimension("time", n_frames);
1085    w.add_dimension("atom", n_atoms);
1086    w.add_dimension("spatial", 3);
1087    w.add_global_attribute("Conventions", "OxiPhysics");
1088    w.add_global_attribute("n_frames", &format!("{}", n_frames));
1089    w.add_global_attribute("n_atoms", &format!("{}", n_atoms));
1090    w.add_coordinate("time", times.to_vec(), units_time);
1091    let x_data: Vec<f64> = positions
1092        .iter()
1093        .flat_map(|f| f.iter().map(|p| p[0]))
1094        .collect();
1095    let y_data: Vec<f64> = positions
1096        .iter()
1097        .flat_map(|f| f.iter().map(|p| p[1]))
1098        .collect();
1099    let z_data: Vec<f64> = positions
1100        .iter()
1101        .flat_map(|f| f.iter().map(|p| p[2]))
1102        .collect();
1103    w.add_variable("x", &["time", "atom"], x_data);
1104    w.add_variable_attribute("units", units_pos);
1105    w.add_variable_attribute("long_name", "x coordinate");
1106    w.add_variable("y", &["time", "atom"], y_data);
1107    w.add_variable_attribute("units", units_pos);
1108    w.add_variable_attribute("long_name", "y coordinate");
1109    w.add_variable("z", &["time", "atom"], z_data);
1110    w.add_variable_attribute("units", units_pos);
1111    w.add_variable_attribute("long_name", "z coordinate");
1112    w.finish()
1113}
1114/// Parse a particle trajectory CDL back into time and position arrays.
1115///
1116/// Returns `(times, positions)` where `positions\[frame\]\[atom\] = \[x, y, z\]`.
1117#[allow(dead_code)]
1118pub fn read_particle_trajectory_cdl(cdl: &str) -> Result<(Vec<f64>, Vec<Vec<[f64; 3]>>), String> {
1119    let reader = NetcdfReader::from_cdl(cdl)?;
1120    let times = reader
1121        .get_data("time")
1122        .ok_or("missing 'time' variable")?
1123        .to_vec();
1124    let n_frames = times.len();
1125    let n_atoms = reader.get_dimension("atom").unwrap_or(0);
1126    let x_data = reader.get_data("x").ok_or("missing 'x' variable")?.to_vec();
1127    let y_data = reader.get_data("y").ok_or("missing 'y' variable")?.to_vec();
1128    let z_data = reader.get_data("z").ok_or("missing 'z' variable")?.to_vec();
1129    let mut positions = Vec::with_capacity(n_frames);
1130    for frame in 0..n_frames {
1131        let mut frame_pos = Vec::with_capacity(n_atoms);
1132        for atom in 0..n_atoms {
1133            let idx = frame * n_atoms + atom;
1134            let x = x_data.get(idx).copied().unwrap_or(0.0);
1135            let y = y_data.get(idx).copied().unwrap_or(0.0);
1136            let z = z_data.get(idx).copied().unwrap_or(0.0);
1137            frame_pos.push([x, y, z]);
1138        }
1139        positions.push(frame_pos);
1140    }
1141    Ok((times, positions))
1142}
1143/// Build a regularly-spaced time coordinate (start, step, n).
1144#[allow(dead_code)]
1145pub fn linspace_time(start: f64, step: f64, n: usize) -> Vec<f64> {
1146    (0..n).map(|i| start + i as f64 * step).collect()
1147}
1148/// Build X/Y/Z Cartesian coordinate arrays for a uniform grid.
1149#[allow(dead_code)]
1150pub fn uniform_grid_coords(
1151    nx: usize,
1152    ny: usize,
1153    nz: usize,
1154    origin: [f64; 3],
1155    spacing: [f64; 3],
1156) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
1157    let x: Vec<f64> = (0..nx).map(|i| origin[0] + i as f64 * spacing[0]).collect();
1158    let y: Vec<f64> = (0..ny).map(|j| origin[1] + j as f64 * spacing[1]).collect();
1159    let z: Vec<f64> = (0..nz).map(|k| origin[2] + k as f64 * spacing[2]).collect();
1160    (x, y, z)
1161}
1162/// Common CF (Climate and Forecast) convention global attributes.
1163#[allow(dead_code)]
1164pub fn cf_global_attributes(title: &str, institution: &str, source: &str) -> Vec<(String, String)> {
1165    vec![
1166        ("Conventions".to_string(), "CF-1.8".to_string()),
1167        ("title".to_string(), title.to_string()),
1168        ("institution".to_string(), institution.to_string()),
1169        ("source".to_string(), source.to_string()),
1170        ("history".to_string(), "Created by OxiPhysics".to_string()),
1171    ]
1172}
1173/// Add CF convention attributes to a [`NetCdfFile`].
1174#[allow(dead_code)]
1175pub fn apply_cf_conventions(file: &mut NetCdfFile, title: &str) {
1176    file.add_attribute("Conventions", "CF-1.8");
1177    file.add_attribute("title", title);
1178    file.add_attribute("institution", "OxiPhysics");
1179}
1180/// Compute extended statistics for a variable.
1181#[allow(dead_code)]
1182pub fn extended_variable_stats(var: &NetCdfVariable) -> Option<NetcdfVariableStats> {
1183    if var.data.is_empty() {
1184        return None;
1185    }
1186    let n = var.data.len();
1187    let mut min = f64::INFINITY;
1188    let mut max = f64::NEG_INFINITY;
1189    let mut sum = 0.0;
1190    for &v in &var.data {
1191        if v < min {
1192            min = v;
1193        }
1194        if v > max {
1195            max = v;
1196        }
1197        sum += v;
1198    }
1199    let mean = sum / n as f64;
1200    let var_sq = var.data.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n as f64;
1201    Some(NetcdfVariableStats {
1202        name: var.name.clone(),
1203        min,
1204        max,
1205        mean,
1206        std_dev: var_sq.sqrt(),
1207        count: n,
1208    })
1209}
1210#[cfg(test)]
1211mod tests_netcdf_new {
1212    use super::*;
1213
1214    #[test]
1215    fn writer_basic_cdl() {
1216        let mut w = NetcdfWriter::new("test");
1217        w.add_dimension("time", 3);
1218        w.add_variable("temperature", &["time"], vec![300.0, 301.0, 302.0]);
1219        w.add_variable_attribute("units", "K");
1220        let cdl = w.finish();
1221        assert!(cdl.starts_with("netcdf test {"));
1222        assert!(cdl.contains("time = 3"));
1223        assert!(cdl.contains("double temperature(time)"));
1224        assert!(cdl.contains("temperature:units = \"K\""));
1225        assert!(cdl.contains("300"));
1226    }
1227    #[test]
1228    fn writer_unlimited_dimension() {
1229        let mut w = NetcdfWriter::new("sim");
1230        w.add_unlimited_dimension("frame", 5);
1231        w.add_dimension("atom", 10);
1232        let cdl = w.to_cdl();
1233        assert!(cdl.contains("UNLIMITED"));
1234        assert!(cdl.contains("5 currently"));
1235    }
1236    #[test]
1237    fn writer_global_attributes() {
1238        let mut w = NetcdfWriter::new("data");
1239        w.add_global_attribute("Conventions", "CF-1.8");
1240        w.add_global_attribute("title", "Test");
1241        let cdl = w.to_cdl();
1242        assert!(cdl.contains(":Conventions = \"CF-1.8\""));
1243        assert!(cdl.contains(":title = \"Test\""));
1244    }
1245    #[test]
1246    fn writer_coordinate_variable() {
1247        let mut w = NetcdfWriter::new("grid");
1248        w.add_dimension("x", 4);
1249        w.add_coordinate("x", vec![0.0, 1.0, 2.0, 3.0], "m");
1250        let cdl = w.to_cdl();
1251        assert!(cdl.contains("x:units = \"m\""));
1252        assert!(cdl.contains("0, 1, 2, 3"));
1253    }
1254    #[test]
1255    fn writer_n_dimensions_and_variables() {
1256        let mut w = NetcdfWriter::new("d");
1257        w.add_dimension("a", 1);
1258        w.add_dimension("b", 2);
1259        w.add_variable("v", &["a"], vec![1.0]);
1260        assert_eq!(w.n_dimensions(), 2);
1261        assert_eq!(w.n_variables(), 1);
1262    }
1263    #[test]
1264    fn writer_write_to_file() {
1265        let mut w = NetcdfWriter::new("out");
1266        w.add_dimension("t", 2);
1267        w.add_variable("time", &["t"], vec![0.0, 1.0]);
1268        let path = "/tmp/test_netcdf_writer.cdl";
1269        w.write_to_file(path).unwrap();
1270        let content = std::fs::read_to_string(path).unwrap();
1271        assert!(content.contains("netcdf out"));
1272        assert!(content.contains("time = 0, 1"));
1273    }
1274    #[test]
1275    fn reader_from_cdl_roundtrip() {
1276        let mut w = NetcdfWriter::new("rt");
1277        w.add_dimension("n", 3);
1278        w.add_variable("pressure", &["n"], vec![100.0, 101.0, 102.0]);
1279        w.add_variable_attribute("units", "Pa");
1280        w.add_global_attribute("source", "test");
1281        let cdl = w.finish();
1282        let reader = NetcdfReader::from_cdl(&cdl).unwrap();
1283        assert_eq!(reader.get_dimension("n"), Some(3));
1284        let p = reader.get_data("pressure").unwrap();
1285        assert_eq!(p.len(), 3);
1286        assert!((p[1] - 101.0).abs() < 1e-9);
1287    }
1288    #[test]
1289    fn reader_variable_names() {
1290        let mut w = NetcdfWriter::new("x");
1291        w.add_dimension("k", 2);
1292        w.add_variable("alpha", &["k"], vec![1.0, 2.0]);
1293        w.add_variable("beta", &["k"], vec![3.0, 4.0]);
1294        let cdl = w.finish();
1295        let reader = NetcdfReader::from_cdl(&cdl).unwrap();
1296        let names = reader.variable_names();
1297        assert!(names.contains(&"alpha"));
1298        assert!(names.contains(&"beta"));
1299    }
1300    #[test]
1301    fn reader_missing_variable_returns_none() {
1302        let w = NetcdfWriter::new("empty");
1303        let cdl = w.finish();
1304        let reader = NetcdfReader::from_cdl(&cdl).unwrap();
1305        assert!(reader.get_variable("nonexistent").is_none());
1306        assert!(reader.get_data("nonexistent").is_none());
1307    }
1308    #[test]
1309    fn particle_trajectory_write_read_roundtrip() {
1310        let times = vec![0.0, 1.0, 2.0];
1311        let positions = vec![
1312            vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
1313            vec![[1.1, 2.1, 3.1], [4.1, 5.1, 6.1]],
1314            vec![[1.2, 2.2, 3.2], [4.2, 5.2, 6.2]],
1315        ];
1316        let cdl = write_particle_trajectory_cdl(&times, &positions, "ps", "angstrom");
1317        assert!(cdl.contains("n_frames"));
1318        assert!(cdl.contains("n_atoms"));
1319        assert!(cdl.contains("OxiPhysics"));
1320        let (rt_times, rt_pos) = read_particle_trajectory_cdl(&cdl).unwrap();
1321        assert_eq!(rt_times.len(), 3);
1322        assert!((rt_times[2] - 2.0).abs() < 1e-9);
1323        assert_eq!(rt_pos.len(), 3);
1324        assert_eq!(rt_pos[0].len(), 2);
1325        assert!((rt_pos[1][0][0] - 1.1).abs() < 1e-9);
1326        assert!((rt_pos[2][1][2] - 6.2).abs() < 1e-9);
1327    }
1328    #[test]
1329    fn particle_trajectory_empty() {
1330        let cdl = write_particle_trajectory_cdl(&[], &[], "ps", "nm");
1331        assert!(cdl.contains("netcdf trajectory"));
1332        assert!(cdl.contains("dimensions:"));
1333        assert!(cdl.contains("variables:"));
1334    }
1335    #[test]
1336    fn linspace_time_basic() {
1337        let t = linspace_time(0.0, 0.5, 5);
1338        assert_eq!(t.len(), 5);
1339        assert!((t[0] - 0.0).abs() < 1e-12);
1340        assert!((t[4] - 2.0).abs() < 1e-12);
1341    }
1342    #[test]
1343    fn linspace_time_single() {
1344        let t = linspace_time(3.0, 1.0, 1);
1345        assert_eq!(t, vec![3.0]);
1346    }
1347    #[test]
1348    fn uniform_grid_coords_sizes() {
1349        let (x, y, z) = uniform_grid_coords(4, 5, 6, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
1350        assert_eq!(x.len(), 4);
1351        assert_eq!(y.len(), 5);
1352        assert_eq!(z.len(), 6);
1353    }
1354    #[test]
1355    fn uniform_grid_coords_values() {
1356        let (x, _y, _z) = uniform_grid_coords(3, 1, 1, [1.0, 0.0, 0.0], [0.5, 1.0, 1.0]);
1357        assert!((x[0] - 1.0).abs() < 1e-12);
1358        assert!((x[1] - 1.5).abs() < 1e-12);
1359        assert!((x[2] - 2.0).abs() < 1e-12);
1360    }
1361    #[test]
1362    fn cf_attrs_content() {
1363        let attrs = cf_global_attributes("My Title", "ACME Corp", "OxiPhysics 1.0");
1364        assert!(
1365            attrs
1366                .iter()
1367                .any(|(k, v)| k == "Conventions" && v.contains("CF"))
1368        );
1369        assert!(attrs.iter().any(|(k, _)| k == "title"));
1370        assert!(attrs.iter().any(|(k, _)| k == "institution"));
1371    }
1372    #[test]
1373    fn apply_cf_adds_attributes() {
1374        let mut file = NetCdfFile::new();
1375        apply_cf_conventions(&mut file, "My Dataset");
1376        assert_eq!(file.get_attribute("Conventions"), Some("CF-1.8"));
1377        assert_eq!(file.get_attribute("title"), Some("My Dataset"));
1378    }
1379    #[test]
1380    fn extended_stats_basic() {
1381        let var = NetCdfVariable::new("v", vec![], vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1382        let s = extended_variable_stats(&var).unwrap();
1383        assert_eq!(s.count, 5);
1384        assert!((s.min - 1.0).abs() < 1e-12);
1385        assert!((s.max - 5.0).abs() < 1e-12);
1386        assert!((s.mean - 3.0).abs() < 1e-12);
1387        assert!((s.std_dev - 2.0f64.sqrt()).abs() < 1e-9);
1388    }
1389    #[test]
1390    fn extended_stats_range() {
1391        let var = NetCdfVariable::new("v", vec![], vec![-5.0, 5.0]);
1392        let s = extended_variable_stats(&var).unwrap();
1393        assert!((s.range() - 10.0).abs() < 1e-12);
1394    }
1395    #[test]
1396    fn extended_stats_empty() {
1397        let var = NetCdfVariable::new("empty", vec![], vec![]);
1398        assert!(extended_variable_stats(&var).is_none());
1399    }
1400    #[test]
1401    fn extended_stats_cv_zero_mean() {
1402        let var = NetCdfVariable::new("z", vec![], vec![0.0, 0.0, 0.0]);
1403        let s = extended_variable_stats(&var).unwrap();
1404        assert!((s.cv() - 0.0).abs() < f64::EPSILON * 10.0);
1405    }
1406    #[test]
1407    fn dimension_unlimited_check() {
1408        let d_fixed = NetCdfDimension {
1409            name: "x".to_string(),
1410            size: Some(10),
1411        };
1412        let d_unlim = NetCdfDimension {
1413            name: "t".to_string(),
1414            size: None,
1415        };
1416        assert!(!d_fixed.is_unlimited());
1417        assert!(d_unlim.is_unlimited());
1418        assert_eq!(d_fixed.effective_size(), 10);
1419        assert_eq!(d_unlim.effective_size(), 0);
1420    }
1421    #[test]
1422    fn merge_netcdf_files_basic() {
1423        let mut base = NetCdfFile::new();
1424        base.add_variable(NetCdfVariable::new("a", vec![], vec![1.0]));
1425        base.add_attribute("source", "base");
1426        let mut overlay = NetCdfFile::new();
1427        overlay.add_variable(NetCdfVariable::new("b", vec![], vec![2.0]));
1428        overlay.add_attribute("source", "overlay");
1429        let merged = merge_netcdf_files(&base, &overlay);
1430        assert!(merged.get_variable("a").is_some());
1431        assert!(merged.get_variable("b").is_some());
1432        assert_eq!(merged.get_attribute("source"), Some("overlay"));
1433    }
1434    #[test]
1435    fn inherit_global_attributes_basic() {
1436        let mut source = NetCdfFile::new();
1437        source.add_attribute("units", "SI");
1438        let mut target = NetCdfFile::new();
1439        target.add_variable(NetCdfVariable::new("T", vec![], vec![300.0]));
1440        inherit_global_attributes(&source, &mut target);
1441        let attr = target.get_variable("T").unwrap().get_attribute("units");
1442        assert_eq!(attr, Some("SI"));
1443    }
1444    #[test]
1445    fn reshape_2d_basic() {
1446        let data: Vec<f64> = (1..=6).map(|i| i as f64).collect();
1447        let r = reshape_2d(&data, 2, 3);
1448        assert_eq!(r.len(), 2);
1449        assert_eq!(r[0], vec![1.0, 2.0, 3.0]);
1450        assert_eq!(r[1], vec![4.0, 5.0, 6.0]);
1451    }
1452    #[test]
1453    fn variable_stats_basic() {
1454        let var = NetCdfVariable::new("v", vec![], vec![2.0, 4.0, 6.0]);
1455        let (min, max, mean) = variable_stats(&var).unwrap();
1456        assert!((min - 2.0).abs() < 1e-12);
1457        assert!((max - 6.0).abs() < 1e-12);
1458        assert!((mean - 4.0).abs() < 1e-12);
1459    }
1460    #[test]
1461    fn variable_stats_empty() {
1462        let var = NetCdfVariable::new("empty", vec![], vec![]);
1463        assert!(variable_stats(&var).is_none());
1464    }
1465    #[test]
1466    fn amber_trajectory_structure() {
1467        let n_atoms = 5;
1468        let n_frames = 3;
1469        let coords: Vec<f64> = (0..n_atoms * n_frames * 3).map(|i| i as f64).collect();
1470        let times = vec![0.0, 1.0, 2.0];
1471        let file = create_amber_trajectory(n_atoms, n_frames, coords, times, None, None);
1472        assert_eq!(file.get_dimension_size("atom"), Some(n_atoms));
1473        assert_eq!(file.get_dimension_size("spatial"), Some(3));
1474        assert_eq!(file.get_attribute("Conventions"), Some("AMBER"));
1475        let coords_var = file.get_variable("coordinates").unwrap();
1476        assert_eq!(coords_var.data.len(), n_atoms * n_frames * 3);
1477    }
1478    #[test]
1479    fn amber_extract_frame_coordinates() {
1480        let n_atoms = 2;
1481        let n_frames = 2;
1482        let coords = vec![
1483            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1484        ];
1485        let file = create_amber_trajectory(n_atoms, n_frames, coords, vec![0.0, 1.0], None, None);
1486        let frame0 = extract_frame_coordinates(&file, 0).unwrap();
1487        assert_eq!(frame0.len(), 2);
1488        assert!((frame0[0][0] - 1.0).abs() < 1e-9);
1489        assert!((frame0[1][2] - 6.0).abs() < 1e-9);
1490        let frame1 = extract_frame_coordinates(&file, 1).unwrap();
1491        assert!((frame1[0][0] - 7.0).abs() < 1e-9);
1492    }
1493    #[test]
1494    fn amber_extract_out_of_range() {
1495        let file = create_amber_trajectory(2, 1, vec![0.0; 6], vec![0.0], None, None);
1496        assert!(extract_frame_coordinates(&file, 99).is_none());
1497    }
1498    #[test]
1499    fn coordinate_variable_units_attribute() {
1500        let var = create_coordinate_variable("time", vec![0.0, 1.0, 2.0], "ps");
1501        assert_eq!(var.get_attribute("units"), Some("ps"));
1502        assert_eq!(var.dims, vec!["time"]);
1503    }
1504    #[test]
1505    fn nc4_file_to_bytes_contains_magic() {
1506        let mut f = Nc4File::new();
1507        f.add_global_attribute("title", "Test");
1508        f.add_dimension("x", 10);
1509        let bytes = f.to_bytes();
1510        assert_eq!(&bytes[..5], b"OXNC4");
1511    }
1512    #[test]
1513    fn nc4_file_unlimited_extend() {
1514        let mut f = Nc4File::new();
1515        f.add_unlimited_dimension("time");
1516        f.extend_unlimited();
1517        f.extend_unlimited();
1518        assert_eq!(f.unlimited_size, 2);
1519        assert_eq!(f.get_dimension_size("time"), Some(2));
1520    }
1521    #[test]
1522    fn nc4_group_hierarchy() {
1523        let mut root = Nc4Group::new("root");
1524        let mut child = Nc4Group::new("child");
1525        child.add_variable(Nc4Variable::float64("v", vec![], vec![1.0, 2.0]));
1526        root.add_subgroup(child);
1527        assert_eq!(root.total_variable_count(), 1);
1528        let all = root.all_variables();
1529        assert_eq!(all.len(), 1);
1530        assert_eq!(all[0].name, "v");
1531    }
1532}
1533#[cfg(test)]
1534#[allow(dead_code)]
1535mod tests_netcdf_extra {
1536    use super::*;
1537
1538    #[test]
1539    fn ncdf_variable_new_empty_data() {
1540        let var = NetCdfVariable::new("pressure", vec!["time".to_string()], vec![]);
1541        assert!(var.is_empty());
1542        assert_eq!(var.len(), 0);
1543    }
1544    #[test]
1545    fn ncdf_variable_add_and_get_attribute() {
1546        let mut var = NetCdfVariable::new("temp", vec![], vec![1.0]);
1547        var.add_attribute("units", "K");
1548        assert_eq!(var.get_attribute("units"), Some("K"));
1549        assert_eq!(var.get_attribute("missing"), None);
1550    }
1551    #[test]
1552    fn ncdf_variable_multiple_attributes() {
1553        let mut var = NetCdfVariable::new("vel", vec!["atom".to_string()], vec![1.0, 2.0]);
1554        var.add_attribute("units", "angstrom/ps");
1555        var.add_attribute("long_name", "velocity");
1556        assert_eq!(var.get_attribute("long_name"), Some("velocity"));
1557        assert_eq!(var.attributes.len(), 2);
1558    }
1559    #[test]
1560    fn ncdf_dimension_unlimited() {
1561        let dim = NetCdfDimension {
1562            name: "time".to_string(),
1563            size: None,
1564        };
1565        assert!(dim.is_unlimited());
1566        assert_eq!(dim.effective_size(), 0);
1567    }
1568    #[test]
1569    fn ncdf_dimension_fixed() {
1570        let dim = NetCdfDimension {
1571            name: "atom".to_string(),
1572            size: Some(100),
1573        };
1574        assert!(!dim.is_unlimited());
1575        assert_eq!(dim.effective_size(), 100);
1576    }
1577    #[test]
1578    fn netcdf_file_add_and_get_variable() {
1579        let mut f = NetCdfFile::new();
1580        let var = NetCdfVariable::new("coords", vec!["atom".to_string()], vec![1.0, 2.0, 3.0]);
1581        f.add_variable(var);
1582        let got = f.get_variable("coords").unwrap();
1583        assert_eq!(got.data, vec![1.0, 2.0, 3.0]);
1584    }
1585    #[test]
1586    fn netcdf_file_get_variable_missing() {
1587        let f = NetCdfFile::new();
1588        assert!(f.get_variable("missing").is_none());
1589    }
1590    #[test]
1591    fn netcdf_file_add_dimension() {
1592        let mut f = NetCdfFile::new();
1593        f.add_dimension("atom", 50);
1594        assert_eq!(f.get_dimension_size("atom"), Some(50));
1595    }
1596    #[test]
1597    fn netcdf_file_unlimited_dimension() {
1598        let mut f = NetCdfFile::new();
1599        f.add_unlimited_dimension("time", 0);
1600        assert!(f.is_unlimited_dimension("time"));
1601        assert!(!f.is_unlimited_dimension("atom"));
1602    }
1603    #[test]
1604    fn netcdf_file_add_global_attribute() {
1605        let mut f = NetCdfFile::new();
1606        f.add_attribute("Conventions", "AMBER");
1607        assert_eq!(f.get_attribute("Conventions"), Some("AMBER"));
1608    }
1609    #[test]
1610    fn netcdf_file_variable_names() {
1611        let mut f = NetCdfFile::new();
1612        f.add_variable(NetCdfVariable::new("time", vec![], vec![]));
1613        f.add_variable(NetCdfVariable::new("coords", vec![], vec![]));
1614        let names = f.variable_names();
1615        assert!(names.contains(&"time"));
1616        assert!(names.contains(&"coords"));
1617    }
1618    #[test]
1619    fn netcdf_file_dimension_names() {
1620        let mut f = NetCdfFile::new();
1621        f.add_dimension("frame", 10);
1622        f.add_dimension("atom", 5);
1623        let names = f.dimension_names();
1624        assert_eq!(names.len(), 2);
1625    }
1626    #[test]
1627    fn ncf_text_roundtrip_basic() {
1628        let mut f = NetCdfFile::new();
1629        f.add_dimension("time", 3);
1630        let mut var = NetCdfVariable::new(
1631            "temperature",
1632            vec!["time".to_string()],
1633            vec![300.0, 310.0, 320.0],
1634        );
1635        var.add_attribute("units", "K");
1636        f.add_variable(var);
1637        let cdl = write_ncf_text(&f);
1638        let parsed = parse_ncf_text(&cdl).unwrap();
1639        let v = parsed.get_variable("temperature").unwrap();
1640        assert_eq!(v.data, vec![300.0, 310.0, 320.0]);
1641    }
1642    #[test]
1643    fn ncf_text_contains_dimension() {
1644        let mut f = NetCdfFile::new();
1645        f.add_dimension("atom", 42);
1646        let cdl = write_ncf_text(&f);
1647        assert!(cdl.contains("atom"));
1648    }
1649    #[test]
1650    fn ncf_text_contains_variable_attribute() {
1651        let mut f = NetCdfFile::new();
1652        let mut var = NetCdfVariable::new("v", vec![], vec![1.0]);
1653        var.add_attribute("units", "m/s");
1654        f.add_variable(var);
1655        let cdl = write_ncf_text(&f);
1656        assert!(cdl.contains("m/s"));
1657    }
1658    #[test]
1659    fn reshape_2d_basic() {
1660        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
1661        let mat = reshape_2d(&data, 2, 3);
1662        assert_eq!(mat.len(), 2);
1663        assert_eq!(mat[0], vec![1.0, 2.0, 3.0]);
1664        assert_eq!(mat[1], vec![4.0, 5.0, 6.0]);
1665    }
1666    #[test]
1667    fn reshape_2d_single_row() {
1668        let data = vec![1.0, 2.0, 3.0];
1669        let mat = reshape_2d(&data, 1, 3);
1670        assert_eq!(mat, vec![vec![1.0, 2.0, 3.0]]);
1671    }
1672    #[test]
1673    fn reshape_2d_zero_rows() {
1674        let mat = reshape_2d(&[], 0, 3);
1675        assert!(mat.is_empty());
1676    }
1677    #[test]
1678    fn variable_stats_basic() {
1679        let var = NetCdfVariable::new("t", vec![], vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1680        let (min, max, mean) = variable_stats(&var).unwrap();
1681        assert!((min - 1.0).abs() < 1e-12);
1682        assert!((max - 5.0).abs() < 1e-12);
1683        assert!((mean - 3.0).abs() < 1e-12);
1684    }
1685    #[test]
1686    fn variable_stats_empty_returns_none() {
1687        let var = NetCdfVariable::new("t", vec![], vec![]);
1688        assert!(variable_stats(&var).is_none());
1689    }
1690    #[test]
1691    fn amber_trajectory_n_frames_dimension() {
1692        let file = create_amber_trajectory(3, 2, vec![0.0; 18], vec![0.0, 1.0], None, None);
1693        assert_eq!(file.get_dimension_size("frame"), Some(2));
1694        assert_eq!(file.get_dimension_size("atom"), Some(3));
1695    }
1696    #[test]
1697    fn amber_trajectory_has_time_variable() {
1698        let file = create_amber_trajectory(2, 3, vec![0.0; 18], vec![0.0, 1.0, 2.0], None, None);
1699        assert!(file.get_variable("time").is_some());
1700    }
1701    #[test]
1702    fn amber_trajectory_has_coordinates_variable() {
1703        let file = create_amber_trajectory(
1704            2,
1705            1,
1706            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
1707            vec![0.0],
1708            None,
1709            None,
1710        );
1711        assert!(file.get_variable("coordinates").is_some());
1712    }
1713    #[test]
1714    fn amber_trajectory_amber_conventions_attribute() {
1715        let file = create_amber_trajectory(1, 1, vec![0.0; 3], vec![0.0], None, None);
1716        let conv = file.get_attribute("Conventions");
1717        assert!(conv.is_some());
1718    }
1719    #[test]
1720    fn coordinate_variable_data() {
1721        let var = create_coordinate_variable("x", vec![0.0, 1.0, 2.0], "nm");
1722        assert_eq!(var.data, vec![0.0, 1.0, 2.0]);
1723        assert_eq!(var.name, "x");
1724    }
1725    #[test]
1726    fn coordinate_variable_units_attr() {
1727        let var = create_coordinate_variable("time", vec![0.0, 0.5], "ps");
1728        assert_eq!(var.get_attribute("units"), Some("ps"));
1729    }
1730    #[test]
1731    fn nc4_data_type_float64_width() {
1732        assert_eq!(Nc4DataType::Float64.byte_width(), 8);
1733    }
1734    #[test]
1735    fn nc4_data_type_int32_width() {
1736        assert_eq!(Nc4DataType::Int32.byte_width(), 4);
1737    }
1738    #[test]
1739    fn nc4_data_type_type_names() {
1740        assert_eq!(Nc4DataType::Float64.type_name(), "double");
1741        assert_eq!(Nc4DataType::Int32.type_name(), "int");
1742    }
1743    #[test]
1744    fn nc4_variable_float64_constructor() {
1745        let v = Nc4Variable::float64("coords", vec!["atom".to_string()], vec![1.0, 2.0]);
1746        assert_eq!(v.name, "coords");
1747        assert_eq!(v.data_type, Nc4DataType::Float64);
1748        assert_eq!(v.float_data, vec![1.0, 2.0]);
1749    }
1750    #[test]
1751    fn nc4_variable_int32_constructor() {
1752        let v = Nc4Variable::int32("n", vec![], vec![1, 2, 3]);
1753        assert_eq!(v.data_type, Nc4DataType::Int32);
1754        assert_eq!(v.int_data, vec![1_i64, 2, 3]);
1755    }
1756    #[test]
1757    fn nc4_variable_add_get_attribute() {
1758        let mut v = Nc4Variable::float64("t", vec![], vec![]);
1759        v.add_attribute("units", "K");
1760        assert_eq!(v.get_attribute("units"), Some("K"));
1761    }
1762    #[test]
1763    fn nc4_variable_len_is_empty() {
1764        let v = Nc4Variable::float64("x", vec![], vec![1.0, 2.0, 3.0]);
1765        assert_eq!(v.len(), 3);
1766        assert!(!v.is_empty());
1767        let empty = Nc4Variable::float64("y", vec![], vec![]);
1768        assert!(empty.is_empty());
1769    }
1770    #[test]
1771    fn nc4_file_add_and_get_variable() {
1772        let mut f = Nc4File::new();
1773        f.add_variable(Nc4Variable::float64("temperature", vec![], vec![300.0]));
1774        let v = f.get_variable("temperature").unwrap();
1775        assert_eq!(v.float_data, vec![300.0]);
1776    }
1777    #[test]
1778    fn nc4_file_total_variable_count() {
1779        let mut f = Nc4File::new();
1780        f.add_variable(Nc4Variable::float64("a", vec![], vec![]));
1781        f.add_variable(Nc4Variable::float64("b", vec![], vec![]));
1782        assert_eq!(f.total_variable_count(), 2);
1783    }
1784    #[test]
1785    fn nc4_file_add_group_and_retrieve() {
1786        let mut f = Nc4File::new();
1787        let mut g = Nc4Group::new("traj");
1788        g.add_variable(Nc4Variable::float64("v", vec![], vec![1.0]));
1789        f.add_group(g);
1790        assert!(f.get_group("traj").is_some());
1791        assert!(f.get_group("missing").is_none());
1792    }
1793    #[test]
1794    fn nc4_file_global_attribute_roundtrip() {
1795        let mut f = Nc4File::new();
1796        f.add_global_attribute("title", "Test Run");
1797        assert_eq!(f.get_global_attribute("title"), Some("Test Run"));
1798    }
1799    #[test]
1800    fn nc4_file_is_unlimited_fixed_dim() {
1801        let mut f = Nc4File::new();
1802        f.add_dimension("atom", 50);
1803        assert!(!f.is_unlimited("atom"));
1804    }
1805    #[test]
1806    fn merge_netcdf_files_overlay_wins() {
1807        let mut base = NetCdfFile::new();
1808        base.add_variable(NetCdfVariable::new("temp", vec![], vec![100.0]));
1809        let mut overlay = NetCdfFile::new();
1810        overlay.add_variable(NetCdfVariable::new("temp", vec![], vec![200.0]));
1811        overlay.add_variable(NetCdfVariable::new("pressure", vec![], vec![1.0]));
1812        let merged = merge_netcdf_files(&base, &overlay);
1813        let temp = merged.get_variable("temp").unwrap();
1814        assert_eq!(temp.data, vec![200.0]);
1815        assert!(merged.get_variable("pressure").is_some());
1816    }
1817    #[test]
1818    fn parse_cdl_dimensions_basic() {
1819        let cdl = "netcdf x {\ndimensions:\n\tatom = 5 ;\n\tframe = 10 ;\nvariables:\n}";
1820        let dims = parse_cdl_dimensions(cdl);
1821        assert_eq!(dims.len(), 2);
1822        let names: Vec<&str> = dims.iter().map(|(n, _)| n.as_str()).collect();
1823        assert!(names.contains(&"atom"));
1824        assert!(names.contains(&"frame"));
1825    }
1826    #[test]
1827    fn parse_cdl_dimensions_empty() {
1828        let cdl = "netcdf x {\ndimensions:\nvariables:\n}";
1829        let dims = parse_cdl_dimensions(cdl);
1830        assert!(dims.is_empty());
1831    }
1832    #[test]
1833    fn netcdffile_hashmap_add_dimension() {
1834        let mut f = NetcdfFile::new();
1835        f.add_dimension("atom", 10);
1836        assert_eq!(f.dimensions.get("atom"), Some(&10));
1837    }
1838    #[test]
1839    fn netcdffile_write_cdl_contains_dims_and_vars() {
1840        let mut f = NetcdfFile::new();
1841        f.add_dimension("time", 5);
1842        f.variables.push(NetcdfVariable {
1843            name: "temperature".to_string(),
1844            dimensions: vec!["time".to_string()],
1845            data: vec![300.0; 5],
1846            units: "K".to_string(),
1847            long_name: "Temperature".to_string(),
1848        });
1849        let cdl = f.write_cdl();
1850        assert!(cdl.contains("time = 5"));
1851        assert!(cdl.contains("temperature"));
1852        assert!(cdl.contains("K"));
1853    }
1854    #[test]
1855    fn linspace_time_basic() {
1856        let t = linspace_time(0.0, 0.5, 5);
1857        assert_eq!(t.len(), 5);
1858        assert!((t[0] - 0.0).abs() < 1e-12);
1859        assert!((t[1] - 0.5).abs() < 1e-12);
1860        assert!((t[4] - 2.0).abs() < 1e-12);
1861    }
1862    #[test]
1863    fn linspace_time_empty() {
1864        let t = linspace_time(0.0, 1.0, 0);
1865        assert!(t.is_empty());
1866    }
1867    #[test]
1868    fn cf_global_attributes_has_required_keys() {
1869        let attrs = cf_global_attributes("My Run", "UniX", "MD");
1870        let keys: Vec<&str> = attrs.iter().map(|(k, _)| k.as_str()).collect();
1871        assert!(keys.contains(&"title") || attrs.iter().any(|(_, v)| v.contains("My Run")));
1872    }
1873}
1874/// Write a CDL dimensions block from a list of `NetcdfDimSpec`.
1875#[allow(dead_code)]
1876pub fn write_cdl_dimensions<W: std::io::Write>(
1877    writer: &mut W,
1878    dims: &[NetcdfDimSpec],
1879) -> std::io::Result<()> {
1880    writeln!(writer, "dimensions:")?;
1881    for d in dims {
1882        writeln!(writer, "{}", d.to_cdl())?;
1883    }
1884    Ok(())
1885}
1886/// Wrap positions from Å → nm (divide by 10).
1887#[allow(dead_code)]
1888pub fn angstroms_to_nm(positions: &[[f64; 3]]) -> Vec<[f64; 3]> {
1889    positions
1890        .iter()
1891        .map(|p| [p[0] / 10.0, p[1] / 10.0, p[2] / 10.0])
1892        .collect()
1893}
1894/// Wrap positions from nm → Å (multiply by 10).
1895#[allow(dead_code)]
1896pub fn nm_to_angstroms(positions: &[[f64; 3]]) -> Vec<[f64; 3]> {
1897    positions
1898        .iter()
1899        .map(|p| [p[0] * 10.0, p[1] * 10.0, p[2] * 10.0])
1900        .collect()
1901}
1902/// Generate a linearly spaced time vector from `t0` to `t0 + (n-1)*dt`.
1903/// (Same as linspace_time but with explicit start time.)
1904#[allow(dead_code)]
1905pub fn time_vector(t0: f64, dt: f64, n: usize) -> Vec<f64> {
1906    (0..n).map(|i| t0 + i as f64 * dt).collect()
1907}
1908/// Compute mean-square displacement (MSD) for a 1-D position series.
1909/// `positions\[i\]` is the 1-D coordinate at frame `i`.
1910#[allow(dead_code)]
1911pub fn msd_1d(positions: &[f64]) -> Vec<f64> {
1912    if positions.is_empty() {
1913        return vec![];
1914    }
1915    let n = positions.len();
1916    let p0 = positions[0];
1917    positions
1918        .iter()
1919        .map(|&p| (p - p0).powi(2))
1920        .collect::<Vec<_>>()[..n]
1921        .to_vec()
1922}
1923/// Compute the mean (average) of a slice.
1924#[allow(dead_code)]
1925pub fn slice_mean(data: &[f64]) -> f64 {
1926    if data.is_empty() {
1927        return 0.0;
1928    }
1929    data.iter().sum::<f64>() / data.len() as f64
1930}
1931/// Compute the variance of a slice.
1932#[allow(dead_code)]
1933pub fn slice_variance(data: &[f64]) -> f64 {
1934    if data.len() < 2 {
1935        return 0.0;
1936    }
1937    let m = slice_mean(data);
1938    data.iter().map(|x| (x - m).powi(2)).sum::<f64>() / data.len() as f64
1939}
1940/// Compute the autocorrelation at lag `lag` for a centred signal.
1941#[allow(dead_code)]
1942pub fn autocorrelation(data: &[f64], lag: usize) -> f64 {
1943    let n = data.len();
1944    if lag >= n {
1945        return 0.0;
1946    }
1947    let m = slice_mean(data);
1948    let var = slice_variance(data);
1949    if var == 0.0 {
1950        return 0.0;
1951    }
1952    let cov: f64 = (0..n - lag)
1953        .map(|i| (data[i] - m) * (data[i + lag] - m))
1954        .sum::<f64>()
1955        / (n - lag) as f64;
1956    cov / var
1957}