Skip to main content

oxiphysics_io/
xarray_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! xarray-compatible multi-dimensional labeled array I/O.
5//!
6//! Provides `XarrayDataArray` and `XarrayDataset` mirroring the Python xarray
7//! data model, together with CSV round-trip, VTK export, and array operations
8//! such as resampling, time-averaging, and finite-difference gradients.
9
10use std::fs::File;
11use std::io::{BufRead, BufReader, Write};
12
13// ── Coordinate ───────────────────────────────────────────────────────────────
14
15/// A labeled coordinate axis.
16#[allow(dead_code)]
17#[derive(Debug, Clone)]
18pub struct XarrayCoordinate {
19    /// Coordinate name (e.g. `"time"`, `"lat"`).
20    pub name: String,
21    /// Coordinate tick values.
22    pub values: Vec<f64>,
23    /// Physical units string (e.g. `"s"`, `"m"`).
24    pub units: String,
25}
26
27impl XarrayCoordinate {
28    /// Create a new coordinate axis.
29    pub fn new(name: impl Into<String>, values: Vec<f64>, units: impl Into<String>) -> Self {
30        Self {
31            name: name.into(),
32            values,
33            units: units.into(),
34        }
35    }
36}
37
38// ── DataArray ─────────────────────────────────────────────────────────────────
39
40/// A labeled N-dimensional array (mirrors `xarray.DataArray`).
41#[allow(dead_code)]
42#[derive(Debug, Clone)]
43pub struct XarrayDataArray {
44    /// Variable name.
45    pub name: String,
46    /// Dimension names in order (length = ndim).
47    pub dims: Vec<String>,
48    /// Size along each dimension (C-order).
49    pub shape: Vec<usize>,
50    /// Flat data storage (row-major / C-order).
51    pub data: Vec<f64>,
52    /// Coordinate objects for each dimension (optional per-dim).
53    pub coords: Vec<XarrayCoordinate>,
54    /// Arbitrary string key-value attributes.
55    pub attrs: Vec<(String, String)>,
56}
57
58impl XarrayDataArray {
59    /// Create a zero-filled `XarrayDataArray` with the given name, dims, and shape.
60    pub fn new(name: impl Into<String>, dims: Vec<String>, shape: Vec<usize>) -> Self {
61        let total: usize = shape.iter().product();
62        Self {
63            name: name.into(),
64            dims,
65            shape,
66            data: vec![0.0; total],
67            coords: Vec::new(),
68            attrs: Vec::new(),
69        }
70    }
71
72    /// Attach (or replace) a coordinate for one dimension.
73    pub fn set_coord(&mut self, coord: XarrayCoordinate) {
74        if let Some(pos) = self.coords.iter().position(|c| c.name == coord.name) {
75            self.coords[pos] = coord;
76        } else {
77            self.coords.push(coord);
78        }
79    }
80
81    /// Number of dimensions.
82    pub fn ndim(&self) -> usize {
83        self.dims.len()
84    }
85
86    /// Total number of elements.
87    pub fn size(&self) -> usize {
88        self.shape.iter().product()
89    }
90
91    /// Get element at multi-dimensional index (C-order).
92    pub fn get(&self, indices: &[usize]) -> f64 {
93        let flat = linear_index(indices, &self.shape);
94        self.data[flat]
95    }
96
97    /// Set element at multi-dimensional index (C-order).
98    pub fn set(&mut self, indices: &[usize], value: f64) {
99        let flat = linear_index(indices, &self.shape);
100        self.data[flat] = value;
101    }
102}
103
104// ── Dataset ───────────────────────────────────────────────────────────────────
105
106/// A collection of `XarrayDataArray` variables (mirrors `xarray.Dataset`).
107#[allow(dead_code)]
108#[derive(Debug, Clone)]
109pub struct XarrayDataset {
110    /// Dataset name / title.
111    pub name: String,
112    /// Variables stored in this dataset.
113    pub variables: Vec<XarrayDataArray>,
114    /// Arbitrary string key-value attributes.
115    pub attrs: Vec<(String, String)>,
116}
117
118impl XarrayDataset {
119    /// Create an empty dataset with the given name.
120    pub fn new(name: impl Into<String>) -> Self {
121        Self {
122            name: name.into(),
123            variables: Vec::new(),
124            attrs: Vec::new(),
125        }
126    }
127
128    /// Add a variable to the dataset.
129    pub fn add_variable(&mut self, var: XarrayDataArray) {
130        self.variables.push(var);
131    }
132
133    /// Number of variables currently stored.
134    pub fn variable_count(&self) -> usize {
135        self.variables.len()
136    }
137
138    /// Look up a variable by name.
139    pub fn get_variable(&self, name: &str) -> Option<&XarrayDataArray> {
140        self.variables.iter().find(|v| v.name == name)
141    }
142}
143
144// ── Index utilities ───────────────────────────────────────────────────────────
145
146/// Compute the C-order (row-major) flat index from multi-dimensional indices.
147///
148/// Panics in debug mode if `indices.len() != shape.len()`.
149pub fn linear_index(indices: &[usize], shape: &[usize]) -> usize {
150    debug_assert_eq!(indices.len(), shape.len());
151    let mut flat = 0usize;
152    let mut stride = 1usize;
153    for d in (0..shape.len()).rev() {
154        flat += indices[d] * stride;
155        stride *= shape[d];
156    }
157    flat
158}
159
160// ── CSV I/O ───────────────────────────────────────────────────────────────────
161
162/// Write each variable in the dataset as a CSV file.
163///
164/// Each variable is written to `path`_<variable_name>.csv`.
165/// The first line is a header containing the dimension names.
166pub fn write_csv_xarray(dataset: &XarrayDataset, path: &str) -> Result<(), String> {
167    for var in &dataset.variables {
168        let file_path = format!("{}_{}.csv", path, var.name);
169        let mut f =
170            File::create(&file_path).map_err(|e| format!("cannot create {file_path}: {e}"))?;
171        // Header: dim names + "value"
172        let header = var.dims.join(",") + ",value\n";
173        f.write_all(header.as_bytes())
174            .map_err(|e| format!("write error: {e}"))?;
175        // Iterate in C-order
176        let n = var.size();
177        let ndim = var.ndim();
178        let mut indices = vec![0usize; ndim];
179        for flat in 0..n {
180            let row: Vec<String> = indices.iter().map(|v| v.to_string()).collect();
181            let line = row.join(",") + "," + &var.data[flat].to_string() + "\n";
182            f.write_all(line.as_bytes())
183                .map_err(|e| format!("write error: {e}"))?;
184            // Increment C-order
185            for d in (0..ndim).rev() {
186                indices[d] += 1;
187                if indices[d] < var.shape[d] {
188                    break;
189                }
190                indices[d] = 0;
191            }
192        }
193    }
194    Ok(())
195}
196
197/// Read a labeled CSV back into an `XarrayDataArray`.
198///
199/// Expects the format produced by [`write_csv_xarray`]: first row is headers
200/// (dim_0, dim_1, …, value), subsequent rows are data.
201pub fn read_csv_xarray(path: &str) -> Result<XarrayDataArray, String> {
202    let f = File::open(path).map_err(|e| format!("cannot open {path}: {e}"))?;
203    let reader = BufReader::new(f);
204    let mut lines = reader.lines();
205
206    let header_line = lines
207        .next()
208        .ok_or("empty file")?
209        .map_err(|e| format!("read error: {e}"))?;
210    let headers: Vec<String> = header_line
211        .split(',')
212        .map(|s| s.trim().to_string())
213        .collect();
214    let ndim = headers.len().saturating_sub(1);
215    let dim_names: Vec<String> = headers[..ndim].to_vec();
216
217    let mut rows: Vec<Vec<usize>> = Vec::new();
218    let mut values: Vec<f64> = Vec::new();
219
220    for line in lines {
221        let line = line.map_err(|e| format!("read error: {e}"))?;
222        if line.trim().is_empty() {
223            continue;
224        }
225        let parts: Vec<&str> = line.split(',').collect();
226        if parts.len() < ndim + 1 {
227            continue;
228        }
229        let idx: Vec<usize> = parts[..ndim]
230            .iter()
231            .map(|s| s.trim().parse::<usize>().unwrap_or(0))
232            .collect();
233        let val: f64 = parts[ndim].trim().parse::<f64>().unwrap_or(0.0);
234        rows.push(idx);
235        values.push(val);
236    }
237
238    // Infer shape from max index per dim
239    let mut shape = vec![0usize; ndim];
240    for row in &rows {
241        for d in 0..ndim {
242            if row[d] + 1 > shape[d] {
243                shape[d] = row[d] + 1;
244            }
245        }
246    }
247
248    let total: usize = if shape.is_empty() {
249        0
250    } else {
251        shape.iter().product()
252    };
253    let mut data = vec![0.0f64; total];
254    for (idx, &val) in rows.iter().zip(values.iter()) {
255        if idx.len() == ndim {
256            let flat = linear_index(idx, &shape);
257            if flat < data.len() {
258                data[flat] = val;
259            }
260        }
261    }
262
263    Ok(XarrayDataArray {
264        name: "loaded".to_string(),
265        dims: dim_names,
266        shape,
267        data,
268        coords: Vec::new(),
269        attrs: Vec::new(),
270    })
271}
272
273// ── VTK export ────────────────────────────────────────────────────────────────
274
275/// Export a 3-D variable from a dataset as a VTK structured grid.
276///
277/// The variable must have exactly 3 dimensions. Uses VTK legacy ASCII format.
278pub fn xarray_to_vtk_structured(
279    dataset: &XarrayDataset,
280    var_name: &str,
281    path: &str,
282) -> Result<(), String> {
283    let var = dataset
284        .get_variable(var_name)
285        .ok_or_else(|| format!("variable '{var_name}' not found"))?;
286    if var.shape.len() != 3 {
287        return Err(format!(
288            "variable '{var_name}' must have 3 dimensions, has {}",
289            var.shape.len()
290        ));
291    }
292    let (nx, ny, nz) = (var.shape[0], var.shape[1], var.shape[2]);
293    let mut f = File::create(path).map_err(|e| format!("cannot create {path}: {e}"))?;
294    writeln!(f, "# vtk DataFile Version 3.0").map_err(|e| e.to_string())?;
295    writeln!(f, "XarrayExport").map_err(|e| e.to_string())?;
296    writeln!(f, "ASCII").map_err(|e| e.to_string())?;
297    writeln!(f, "DATASET STRUCTURED_POINTS").map_err(|e| e.to_string())?;
298    writeln!(f, "DIMENSIONS {} {} {}", nx, ny, nz).map_err(|e| e.to_string())?;
299    writeln!(f, "ORIGIN 0 0 0").map_err(|e| e.to_string())?;
300    writeln!(f, "SPACING 1 1 1").map_err(|e| e.to_string())?;
301    writeln!(f, "POINT_DATA {}", nx * ny * nz).map_err(|e| e.to_string())?;
302    writeln!(f, "SCALARS {} double 1", var_name).map_err(|e| e.to_string())?;
303    writeln!(f, "LOOKUP_TABLE default").map_err(|e| e.to_string())?;
304    for &val in &var.data {
305        writeln!(f, "{val}").map_err(|e| e.to_string())?;
306    }
307    Ok(())
308}
309
310// ── Array operations ──────────────────────────────────────────────────────────
311
312/// Resample an array to a new shape using nearest-neighbour / linear interpolation.
313///
314/// Uses trilinear interpolation for up to 3-D; falls back to nearest-neighbour
315/// for higher dimensions.
316pub fn resample_linear(arr: &XarrayDataArray, new_shape: Vec<usize>) -> XarrayDataArray {
317    let ndim = arr.ndim();
318    assert_eq!(ndim, new_shape.len(), "shape rank mismatch");
319    let new_total: usize = new_shape.iter().product();
320    let mut out = XarrayDataArray::new(arr.name.clone(), arr.dims.clone(), new_shape.clone());
321    for flat in 0..new_total {
322        // Convert flat -> new_indices
323        let mut tmp = flat;
324        let mut new_idx = vec![0usize; ndim];
325        for d in (0..ndim).rev() {
326            new_idx[d] = tmp % new_shape[d];
327            tmp /= new_shape[d];
328        }
329        // Map to source coordinates
330        let src_coords: Vec<f64> = new_idx
331            .iter()
332            .enumerate()
333            .map(|(d, &ni)| {
334                if new_shape[d] <= 1 {
335                    0.0
336                } else {
337                    ni as f64 * (arr.shape[d] as f64 - 1.0) / (new_shape[d] as f64 - 1.0)
338                }
339            })
340            .collect();
341        // Nearest-neighbour fallback for all dims
342        let src_idx: Vec<usize> = src_coords
343            .iter()
344            .enumerate()
345            .map(|(d, &sc)| (sc.round() as usize).min(arr.shape[d].saturating_sub(1)))
346            .collect();
347        out.data[flat] = arr.get(&src_idx);
348    }
349    out
350}
351
352/// Average an array along dimension `time_dim`.
353///
354/// Returns an array with the same shape except `time_dim` is reduced to 1.
355pub fn time_average(arr: &XarrayDataArray, time_dim: usize) -> XarrayDataArray {
356    let ndim = arr.ndim();
357    assert!(time_dim < ndim);
358    let mut out_shape = arr.shape.clone();
359    out_shape[time_dim] = 1;
360    let mut out = XarrayDataArray::new(
361        arr.name.clone() + "_tavg",
362        arr.dims.clone(),
363        out_shape.clone(),
364    );
365    let nt = arr.shape[time_dim];
366    // Iterate over all output cells
367    let out_total: usize = out_shape.iter().product();
368    for flat_out in 0..out_total {
369        let mut tmp = flat_out;
370        let mut out_idx = vec![0usize; ndim];
371        for d in (0..ndim).rev() {
372            out_idx[d] = tmp % out_shape[d];
373            tmp /= out_shape[d];
374        }
375        let mut sum = 0.0f64;
376        let mut src_idx = out_idx.clone();
377        for t in 0..nt {
378            src_idx[time_dim] = t;
379            sum += arr.get(&src_idx);
380        }
381        out.data[flat_out] = sum / nt as f64;
382    }
383    out
384}
385
386/// Compute the finite-difference gradient of an array along dimension `dim`.
387///
388/// Uses central differences for interior points, one-sided for boundary.
389/// `dx` is the grid spacing along that dimension.
390pub fn spatial_gradient(arr: &XarrayDataArray, dim: usize, dx: f64) -> XarrayDataArray {
391    assert!(dim < arr.ndim());
392    let mut out = XarrayDataArray::new(
393        arr.name.clone() + "_grad",
394        arr.dims.clone(),
395        arr.shape.clone(),
396    );
397    let n_dim = arr.shape[dim];
398    let total = arr.size();
399    for flat in 0..total {
400        let mut tmp = flat;
401        let mut idx = vec![0usize; arr.ndim()];
402        for d in (0..arr.ndim()).rev() {
403            idx[d] = tmp % arr.shape[d];
404            tmp /= arr.shape[d];
405        }
406        let i = idx[dim];
407        let grad = if i == 0 {
408            // Forward difference
409            let mut idx_p = idx.clone();
410            idx_p[dim] = 1.min(n_dim - 1);
411            (arr.get(&idx_p) - arr.get(&idx)) / dx
412        } else if i == n_dim - 1 {
413            // Backward difference
414            let mut idx_m = idx.clone();
415            idx_m[dim] = i - 1;
416            (arr.get(&idx) - arr.get(&idx_m)) / dx
417        } else {
418            // Central difference
419            let mut idx_p = idx.clone();
420            let mut idx_m = idx.clone();
421            idx_p[dim] = i + 1;
422            idx_m[dim] = i - 1;
423            (arr.get(&idx_p) - arr.get(&idx_m)) / (2.0 * dx)
424        };
425        out.data[flat] = grad;
426    }
427    out
428}
429
430// ── Tests ────────────────────────────────────────────────────────────────────
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    // ── linear_index tests ────────────────────────────────────────────────
437
438    #[test]
439    fn test_linear_index_1d() {
440        assert_eq!(linear_index(&[3], &[10]), 3);
441    }
442
443    #[test]
444    fn test_linear_index_2d() {
445        // shape 3×4: index(1,2) = 1*4 + 2 = 6
446        assert_eq!(linear_index(&[1, 2], &[3, 4]), 6);
447    }
448
449    #[test]
450    fn test_linear_index_3d() {
451        // shape 2×3×4: index(1,2,3) = 1*12 + 2*4 + 3 = 23
452        assert_eq!(linear_index(&[1, 2, 3], &[2, 3, 4]), 23);
453    }
454
455    #[test]
456    fn test_linear_index_origin() {
457        assert_eq!(linear_index(&[0, 0, 0], &[5, 5, 5]), 0);
458    }
459
460    #[test]
461    fn test_linear_index_last_element() {
462        let shape = [2, 3, 4];
463        let idx = [1, 2, 3];
464        assert_eq!(linear_index(&idx, &shape), 2 * 3 * 4 - 1);
465    }
466
467    // ── XarrayDataArray tests ─────────────────────────────────────────────
468
469    #[test]
470    fn test_data_array_size() {
471        let arr = XarrayDataArray::new("t", vec!["x".into(), "y".into()], vec![3, 4]);
472        assert_eq!(arr.size(), 12);
473    }
474
475    #[test]
476    fn test_data_array_size_empty_shape() {
477        let arr = XarrayDataArray::new("t", vec![], vec![]);
478        assert_eq!(arr.size(), 1); // product of empty = 1
479    }
480
481    #[test]
482    fn test_data_array_ndim() {
483        let arr =
484            XarrayDataArray::new("t", vec!["x".into(), "y".into(), "z".into()], vec![2, 3, 4]);
485        assert_eq!(arr.ndim(), 3);
486    }
487
488    #[test]
489    fn test_data_array_set_get_roundtrip() {
490        let mut arr = XarrayDataArray::new("t", vec!["x".into(), "y".into()], vec![3, 4]);
491        arr.set(&[1, 2], 42.0);
492        assert!((arr.get(&[1, 2]) - 42.0).abs() < 1e-12);
493    }
494
495    #[test]
496    fn test_data_array_initial_zeros() {
497        let arr = XarrayDataArray::new("t", vec!["x".into()], vec![5]);
498        assert!(arr.data.iter().all(|&v| v == 0.0));
499    }
500
501    #[test]
502    fn test_data_array_set_coord() {
503        let mut arr = XarrayDataArray::new("t", vec!["x".into()], vec![3]);
504        let coord = XarrayCoordinate::new("x", vec![0.0, 1.0, 2.0], "m");
505        arr.set_coord(coord);
506        assert_eq!(arr.coords.len(), 1);
507    }
508
509    #[test]
510    fn test_data_array_set_coord_replace() {
511        let mut arr = XarrayDataArray::new("t", vec!["x".into()], vec![3]);
512        arr.set_coord(XarrayCoordinate::new("x", vec![0.0, 1.0, 2.0], "m"));
513        arr.set_coord(XarrayCoordinate::new("x", vec![0.0, 0.5, 1.0], "m"));
514        assert_eq!(arr.coords.len(), 1);
515        assert!((arr.coords[0].values[1] - 0.5).abs() < 1e-12);
516    }
517
518    // ── XarrayDataset tests ───────────────────────────────────────────────
519
520    #[test]
521    fn test_dataset_new_empty() {
522        let ds = XarrayDataset::new("test");
523        assert_eq!(ds.variable_count(), 0);
524    }
525
526    #[test]
527    fn test_dataset_add_variable_increases_count() {
528        let mut ds = XarrayDataset::new("test");
529        ds.add_variable(XarrayDataArray::new("u", vec!["x".into()], vec![4]));
530        assert_eq!(ds.variable_count(), 1);
531    }
532
533    #[test]
534    fn test_dataset_add_two_variables() {
535        let mut ds = XarrayDataset::new("test");
536        ds.add_variable(XarrayDataArray::new("u", vec!["x".into()], vec![4]));
537        ds.add_variable(XarrayDataArray::new("v", vec!["x".into()], vec![4]));
538        assert_eq!(ds.variable_count(), 2);
539    }
540
541    #[test]
542    fn test_dataset_get_variable() {
543        let mut ds = XarrayDataset::new("test");
544        ds.add_variable(XarrayDataArray::new("temp", vec!["x".into()], vec![4]));
545        let var = ds.get_variable("temp");
546        assert!(var.is_some());
547        assert_eq!(var.unwrap().name, "temp");
548    }
549
550    #[test]
551    fn test_dataset_get_variable_missing() {
552        let ds = XarrayDataset::new("test");
553        assert!(ds.get_variable("nosuchvar").is_none());
554    }
555
556    // ── time_average tests ────────────────────────────────────────────────
557
558    #[test]
559    fn test_time_average_reduces_shape() {
560        let arr = XarrayDataArray::new("u", vec!["time".into(), "x".into()], vec![4, 3]);
561        let avg = time_average(&arr, 0);
562        assert_eq!(avg.shape[0], 1);
563        assert_eq!(avg.shape[1], 3);
564    }
565
566    #[test]
567    fn test_time_average_correct_value() {
568        let mut arr = XarrayDataArray::new("u", vec!["t".into(), "x".into()], vec![4, 1]);
569        for t in 0..4 {
570            arr.set(&[t, 0], t as f64);
571        }
572        let avg = time_average(&arr, 0);
573        assert!((avg.get(&[0, 0]) - 1.5).abs() < 1e-12);
574    }
575
576    #[test]
577    fn test_time_average_second_dim() {
578        let arr = XarrayDataArray::new("u", vec!["x".into(), "y".into()], vec![3, 4]);
579        let avg = time_average(&arr, 1);
580        assert_eq!(avg.shape, vec![3, 1]);
581    }
582
583    // ── spatial_gradient tests ────────────────────────────────────────────
584
585    #[test]
586    fn test_spatial_gradient_constant_array_zero() {
587        let mut arr = XarrayDataArray::new("u", vec!["x".into()], vec![5]);
588        for i in 0..5 {
589            arr.set(&[i], 3.0);
590        }
591        let grad = spatial_gradient(&arr, 0, 1.0);
592        assert!(grad.data.iter().all(|&g| g.abs() < 1e-12));
593    }
594
595    #[test]
596    fn test_spatial_gradient_linear_array() {
597        let mut arr = XarrayDataArray::new("u", vec!["x".into()], vec![5]);
598        for i in 0..5 {
599            arr.set(&[i], i as f64);
600        }
601        let grad = spatial_gradient(&arr, 0, 1.0);
602        // Central differences should all give ~1.0 for interior points
603        assert!((grad.get(&[2]) - 1.0).abs() < 1e-12);
604    }
605
606    #[test]
607    fn test_spatial_gradient_shape_preserved() {
608        let arr = XarrayDataArray::new("u", vec!["x".into(), "y".into()], vec![3, 4]);
609        let grad = spatial_gradient(&arr, 0, 0.1);
610        assert_eq!(grad.shape, arr.shape);
611    }
612
613    // ── resample tests ────────────────────────────────────────────────────
614
615    #[test]
616    fn test_resample_same_shape() {
617        let mut arr = XarrayDataArray::new("u", vec!["x".into()], vec![4]);
618        for i in 0..4 {
619            arr.set(&[i], i as f64);
620        }
621        let out = resample_linear(&arr, vec![4]);
622        assert_eq!(out.shape, vec![4]);
623    }
624
625    #[test]
626    fn test_resample_upscale_shape() {
627        let arr = XarrayDataArray::new("u", vec!["x".into()], vec![3]);
628        let out = resample_linear(&arr, vec![6]);
629        assert_eq!(out.shape, vec![6]);
630    }
631
632    #[test]
633    fn test_resample_downscale_shape() {
634        let arr = XarrayDataArray::new("u", vec!["x".into()], vec![8]);
635        let out = resample_linear(&arr, vec![4]);
636        assert_eq!(out.shape, vec![4]);
637    }
638
639    // ── CSV round-trip tests ──────────────────────────────────────────────
640
641    #[test]
642    fn test_write_read_csv_roundtrip() {
643        let mut ds = XarrayDataset::new("test");
644        let mut arr = XarrayDataArray::new("temperature", vec!["x".into(), "y".into()], vec![2, 3]);
645        arr.set(&[0, 1], 3.125);
646        arr.set(&[1, 2], 2.72);
647        ds.add_variable(arr);
648        let path = "/tmp/xarray_test_roundtrip";
649        write_csv_xarray(&ds, path).expect("write failed");
650        let loaded = read_csv_xarray(&format!("{path}_temperature.csv")).expect("read failed");
651        assert!((loaded.get(&[0, 1]) - 3.125).abs() < 1e-9);
652        assert!((loaded.get(&[1, 2]) - 2.72).abs() < 1e-9);
653    }
654
655    #[test]
656    fn test_write_csv_creates_file() {
657        let mut ds = XarrayDataset::new("test2");
658        ds.add_variable(XarrayDataArray::new("v", vec!["x".into()], vec![3]));
659        write_csv_xarray(&ds, "/tmp/xarray_test2").expect("write failed");
660        assert!(std::path::Path::new("/tmp/xarray_test2_v.csv").exists());
661    }
662
663    // ── VTK export test ───────────────────────────────────────────────────
664
665    #[test]
666    fn test_vtk_export_creates_file() {
667        let mut ds = XarrayDataset::new("vtk_test");
668        let arr = XarrayDataArray::new(
669            "pressure",
670            vec!["x".into(), "y".into(), "z".into()],
671            vec![2, 2, 2],
672        );
673        ds.add_variable(arr);
674        xarray_to_vtk_structured(&ds, "pressure", "/tmp/xarray_test_pressure.vtk")
675            .expect("vtk failed");
676        assert!(std::path::Path::new("/tmp/xarray_test_pressure.vtk").exists());
677    }
678
679    #[test]
680    fn test_vtk_export_wrong_dims_returns_err() {
681        let mut ds = XarrayDataset::new("vtk_test2");
682        let arr = XarrayDataArray::new("u2d", vec!["x".into(), "y".into()], vec![2, 2]);
683        ds.add_variable(arr);
684        let res = xarray_to_vtk_structured(&ds, "u2d", "/tmp/xarray_test_u2d.vtk");
685        assert!(res.is_err());
686    }
687
688    #[test]
689    fn test_coordinate_new() {
690        let c = XarrayCoordinate::new("time", vec![0.0, 1.0, 2.0], "s");
691        assert_eq!(c.name, "time");
692        assert_eq!(c.values.len(), 3);
693    }
694}