Skip to main content

ad_plugins_rs/
file_netcdf.rs

1use std::path::{Path, PathBuf};
2
3use ad_core_rs::attributes::{NDAttrSource, NDAttrValue};
4use ad_core_rs::error::{ADError, ADResult};
5use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
6use ad_core_rs::ndarray_pool::NDArrayPool;
7use ad_core_rs::plugin::file_base::{NDFileMode, NDFileWriter};
8use ad_core_rs::plugin::file_controller::FilePluginController;
9use ad_core_rs::plugin::runtime::{
10    NDPluginProcess, ParamChangeResult, PluginParamSnapshot, ProcessResult,
11};
12
13use netcdf3::{DataSet, FileReader, FileWriter, Version};
14
15const VAR_NAME: &str = "array_data";
16const DIM_UNLIMITED: &str = "numArrays";
17
18/// Dimension metadata captured from NDArray dimensions.
19struct DimMeta {
20    size: usize,
21    offset: usize,
22    binning: usize,
23    reverse: bool,
24}
25
26/// A single captured NDAttribute, preserving its typed value and metadata.
27struct AttrData {
28    name: String,
29    description: String,
30    /// Source string (e.g. PV name), C++ `getSource()`.
31    source: String,
32    /// C++ `getSourceInfo()` source-type string.
33    source_type: String,
34    /// C++ `dataTypeString` (e.g. "Int32", "Float64", "String").
35    data_type_string: String,
36    value: NDAttrValue,
37}
38
39/// A single buffered frame captured from an NDArray.
40struct FrameData {
41    dims: Vec<usize>,
42    dim_meta: Vec<DimMeta>,
43    data: NDDataBuffer,
44    data_type: NDDataType,
45    attrs: Vec<AttrData>,
46    unique_id: i32,
47    time_stamp: f64,
48    epics_ts_sec: i32,
49    epics_ts_nsec: i32,
50}
51
52/// Map an `NDAttrSource` to the C++ `getSourceInfo()` source-type string.
53fn attr_source_type_string(src: &NDAttrSource) -> &'static str {
54    match src {
55        NDAttrSource::Driver => "NDAttrSourceDriver",
56        NDAttrSource::EpicsPV => "NDAttrSourceEPICSPV",
57        NDAttrSource::Param { .. } => "NDAttrSourceParam",
58        NDAttrSource::Function => "NDAttrSourceFunct",
59        NDAttrSource::Constant => "NDAttrSourceConst",
60        NDAttrSource::Undefined => "Undefined",
61    }
62}
63
64/// Map an `NDAttrSource` to the C++ `getSource()` source string.
65fn attr_source_string(src: &NDAttrSource) -> String {
66    match src {
67        NDAttrSource::Driver => "Driver".to_string(),
68        NDAttrSource::EpicsPV => "EPICS_PV".to_string(),
69        NDAttrSource::Param { param_name, .. } => param_name.clone(),
70        NDAttrSource::Function => "Function".to_string(),
71        NDAttrSource::Constant => "Const".to_string(),
72        NDAttrSource::Undefined => String::new(),
73    }
74}
75
76/// C++ `dataTypeString` for an NDAttribute value (NDFileNetCDF.cpp:213-258).
77fn attr_data_type_string(value: &NDAttrValue) -> &'static str {
78    match value {
79        NDAttrValue::Int8(_) => "Int8",
80        NDAttrValue::UInt8(_) => "UInt8",
81        NDAttrValue::Int16(_) => "Int16",
82        NDAttrValue::UInt16(_) => "UInt16",
83        NDAttrValue::Int32(_) => "Int32",
84        NDAttrValue::UInt32(_) => "UInt32",
85        NDAttrValue::Int64(_) => "Int64",
86        NDAttrValue::UInt64(_) => "UInt64",
87        NDAttrValue::Float32(_) => "Float32",
88        NDAttrValue::Float64(_) => "Float64",
89        NDAttrValue::String(_) => "String",
90        NDAttrValue::Undefined => "Undefined",
91    }
92}
93
94/// NetCDF-3 file writer.
95///
96/// Because `netcdf3::FileWriter` is `!Send` (uses `Rc` internally), we cannot
97/// store it as a field on a `Send + Sync` struct.  Instead we buffer frame data
98/// in memory and materialise the `FileWriter` only inside `close_file()`, where
99/// it is created, used, and dropped within a single method call.  The same
100/// approach is used for `read_file()` with `FileReader`.
101pub struct NetcdfWriter {
102    current_path: Option<PathBuf>,
103    frames: Vec<FrameData>,
104}
105
106impl NetcdfWriter {
107    pub fn new() -> Self {
108        Self {
109            current_path: None,
110            frames: Vec::new(),
111        }
112    }
113}
114
115/// Map NDDataType → netcdf3 DataType.  Returns error for 64-bit integers
116/// which NetCDF-3 classic format does not support.
117fn nc_data_type(dt: NDDataType) -> ADResult<netcdf3::DataType> {
118    match dt {
119        NDDataType::Int8 => Ok(netcdf3::DataType::I8),
120        NDDataType::UInt8 => Ok(netcdf3::DataType::U8),
121        NDDataType::Int16 | NDDataType::UInt16 => Ok(netcdf3::DataType::I16),
122        NDDataType::Int32 | NDDataType::UInt32 => Ok(netcdf3::DataType::I32),
123        NDDataType::Float32 => Ok(netcdf3::DataType::F32),
124        NDDataType::Float64 => Ok(netcdf3::DataType::F64),
125        NDDataType::Int64 | NDDataType::UInt64 => Ok(netcdf3::DataType::F64),
126    }
127}
128
129/// Write a single frame's data to a fixed-dimension variable.
130fn write_var_data(writer: &mut FileWriter, data: &NDDataBuffer) -> ADResult<()> {
131    let err = |e: netcdf3::error::WriteError| {
132        ADError::UnsupportedConversion(format!("NetCDF write error: {:?}", e))
133    };
134    match data {
135        NDDataBuffer::I8(v) => writer.write_var_i8(VAR_NAME, v).map_err(err),
136        NDDataBuffer::U8(v) => writer.write_var_u8(VAR_NAME, v).map_err(err),
137        NDDataBuffer::I16(v) => writer.write_var_i16(VAR_NAME, v).map_err(err),
138        NDDataBuffer::U16(v) => {
139            let reinterp: Vec<i16> = v.iter().map(|&x| x as i16).collect();
140            writer.write_var_i16(VAR_NAME, &reinterp).map_err(err)
141        }
142        NDDataBuffer::I32(v) => writer.write_var_i32(VAR_NAME, v).map_err(err),
143        NDDataBuffer::U32(v) => {
144            let reinterp: Vec<i32> = v.iter().map(|&x| x as i32).collect();
145            writer.write_var_i32(VAR_NAME, &reinterp).map_err(err)
146        }
147        NDDataBuffer::F32(v) => writer.write_var_f32(VAR_NAME, v).map_err(err),
148        NDDataBuffer::F64(v) => writer.write_var_f64(VAR_NAME, v).map_err(err),
149        NDDataBuffer::I64(v) => {
150            let reinterp: Vec<f64> = v.iter().map(|&x| x as f64).collect();
151            writer.write_var_f64(VAR_NAME, &reinterp).map_err(err)
152        }
153        NDDataBuffer::U64(v) => {
154            let reinterp: Vec<f64> = v.iter().map(|&x| x as f64).collect();
155            writer.write_var_f64(VAR_NAME, &reinterp).map_err(err)
156        }
157    }
158}
159
160/// Write a single record (one frame) to a record variable.
161fn write_record_data(
162    writer: &mut FileWriter,
163    record_index: usize,
164    data: &NDDataBuffer,
165) -> ADResult<()> {
166    let err = |e: netcdf3::error::WriteError| {
167        ADError::UnsupportedConversion(format!("NetCDF write error: {:?}", e))
168    };
169    match data {
170        NDDataBuffer::I8(v) => writer
171            .write_record_i8(VAR_NAME, record_index, v)
172            .map_err(err),
173        NDDataBuffer::U8(v) => writer
174            .write_record_u8(VAR_NAME, record_index, v)
175            .map_err(err),
176        NDDataBuffer::I16(v) => writer
177            .write_record_i16(VAR_NAME, record_index, v)
178            .map_err(err),
179        NDDataBuffer::U16(v) => {
180            let reinterp: Vec<i16> = v.iter().map(|&x| x as i16).collect();
181            writer
182                .write_record_i16(VAR_NAME, record_index, &reinterp)
183                .map_err(err)
184        }
185        NDDataBuffer::I32(v) => writer
186            .write_record_i32(VAR_NAME, record_index, v)
187            .map_err(err),
188        NDDataBuffer::U32(v) => {
189            let reinterp: Vec<i32> = v.iter().map(|&x| x as i32).collect();
190            writer
191                .write_record_i32(VAR_NAME, record_index, &reinterp)
192                .map_err(err)
193        }
194        NDDataBuffer::F32(v) => writer
195            .write_record_f32(VAR_NAME, record_index, v)
196            .map_err(err),
197        NDDataBuffer::F64(v) => writer
198            .write_record_f64(VAR_NAME, record_index, v)
199            .map_err(err),
200        NDDataBuffer::I64(v) => {
201            let reinterp: Vec<f64> = v.iter().map(|&x| x as f64).collect();
202            writer
203                .write_record_f64(VAR_NAME, record_index, &reinterp)
204                .map_err(err)
205        }
206        NDDataBuffer::U64(v) => {
207            let reinterp: Vec<f64> = v.iter().map(|&x| x as f64).collect();
208            writer
209                .write_record_f64(VAR_NAME, record_index, &reinterp)
210                .map_err(err)
211        }
212    }
213}
214
215const ATTR_STRING_DIM: &str = "attrStringSize";
216const ATTR_STRING_SIZE: usize = 256;
217
218/// netCDF-3 storage type for an NDAttribute value (NDFileNetCDF.cpp:283-310).
219fn attr_nc_type(value: &NDAttrValue) -> netcdf3::DataType {
220    match value {
221        NDAttrValue::Int8(_) | NDAttrValue::UInt8(_) | NDAttrValue::Undefined => {
222            netcdf3::DataType::I8
223        }
224        NDAttrValue::Int16(_) | NDAttrValue::UInt16(_) => netcdf3::DataType::I16,
225        NDAttrValue::Int32(_) | NDAttrValue::UInt32(_) => netcdf3::DataType::I32,
226        NDAttrValue::Float32(_) => netcdf3::DataType::F32,
227        NDAttrValue::Float64(_) | NDAttrValue::Int64(_) | NDAttrValue::UInt64(_) => {
228            netcdf3::DataType::F64
229        }
230        NDAttrValue::String(_) => netcdf3::DataType::I8,
231    }
232}
233
234/// Write one frame's value into the `Attr_<name>` variable at `record_index`.
235/// For single-frame files `record_index` is 0 and the variable is non-record.
236fn write_attr_value(
237    writer: &mut FileWriter,
238    var_name: &str,
239    record_index: usize,
240    multi: bool,
241    value: &NDAttrValue,
242) -> ADResult<()> {
243    let werr = |e: netcdf3::error::WriteError| {
244        ADError::UnsupportedConversion(format!("NetCDF attr write error: {:?}", e))
245    };
246    // String values are stored as a fixed-width char row.
247    if let NDAttrValue::String(s) = value {
248        let mut bytes: Vec<i8> = s.bytes().take(ATTR_STRING_SIZE).map(|b| b as i8).collect();
249        bytes.resize(ATTR_STRING_SIZE, 0);
250        return if multi {
251            writer
252                .write_record_i8(var_name, record_index, &bytes)
253                .map_err(werr)
254        } else {
255            writer.write_var_i8(var_name, &bytes).map_err(werr)
256        };
257    }
258    match attr_nc_type(value) {
259        netcdf3::DataType::I8 => {
260            let v = value.as_i64().unwrap_or(0) as i8;
261            if multi {
262                writer
263                    .write_record_i8(var_name, record_index, &[v])
264                    .map_err(werr)
265            } else {
266                writer.write_var_i8(var_name, &[v]).map_err(werr)
267            }
268        }
269        netcdf3::DataType::I16 => {
270            let v = value.as_i64().unwrap_or(0) as i16;
271            if multi {
272                writer
273                    .write_record_i16(var_name, record_index, &[v])
274                    .map_err(werr)
275            } else {
276                writer.write_var_i16(var_name, &[v]).map_err(werr)
277            }
278        }
279        netcdf3::DataType::I32 => {
280            let v = value.as_i64().unwrap_or(0) as i32;
281            if multi {
282                writer
283                    .write_record_i32(var_name, record_index, &[v])
284                    .map_err(werr)
285            } else {
286                writer.write_var_i32(var_name, &[v]).map_err(werr)
287            }
288        }
289        netcdf3::DataType::F32 => {
290            let v = value.as_f64().unwrap_or(0.0) as f32;
291            if multi {
292                writer
293                    .write_record_f32(var_name, record_index, &[v])
294                    .map_err(werr)
295            } else {
296                writer.write_var_f32(var_name, &[v]).map_err(werr)
297            }
298        }
299        netcdf3::DataType::F64 => {
300            let v = value.as_f64().unwrap_or(0.0);
301            if multi {
302                writer
303                    .write_record_f64(var_name, record_index, &[v])
304                    .map_err(werr)
305            } else {
306                writer.write_var_f64(var_name, &[v]).map_err(werr)
307            }
308        }
309        netcdf3::DataType::U8 => unreachable!("attr_nc_type never returns U8"),
310    }
311}
312
313impl NDFileWriter for NetcdfWriter {
314    fn open_file(&mut self, path: &Path, _mode: NDFileMode, _array: &NDArray) -> ADResult<()> {
315        self.current_path = Some(path.to_path_buf());
316        self.frames.clear();
317        Ok(())
318    }
319
320    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
321        // Validate data type early
322        nc_data_type(array.data.data_type())?;
323
324        let dims: Vec<usize> = array.dims.iter().map(|d| d.size).collect();
325        let dim_meta: Vec<DimMeta> = array
326            .dims
327            .iter()
328            .map(|d| DimMeta {
329                size: d.size,
330                offset: d.offset,
331                binning: d.binning,
332                reverse: d.reverse,
333            })
334            .collect();
335        let attrs: Vec<AttrData> = array
336            .attributes
337            .iter()
338            .map(|a| AttrData {
339                name: a.name.clone(),
340                description: a.description.clone(),
341                source: attr_source_string(&a.source),
342                source_type: attr_source_type_string(&a.source).to_string(),
343                data_type_string: attr_data_type_string(&a.value).to_string(),
344                value: a.value.clone(),
345            })
346            .collect();
347
348        self.frames.push(FrameData {
349            dims,
350            dim_meta,
351            data: array.data.clone(),
352            data_type: array.data.data_type(),
353            attrs,
354            unique_id: array.unique_id,
355            time_stamp: array.time_stamp,
356            epics_ts_sec: array.timestamp.sec as i32,
357            epics_ts_nsec: array.timestamp.nsec as i32,
358        });
359        Ok(())
360    }
361
362    fn close_file(&mut self) -> ADResult<()> {
363        let path = match self.current_path.take() {
364            Some(p) => p,
365            None => return Ok(()),
366        };
367
368        if self.frames.is_empty() {
369            return Ok(());
370        }
371
372        let map_def = |e: netcdf3::error::InvalidDataSet| {
373            ADError::UnsupportedConversion(format!("NetCDF definition error: {:?}", e))
374        };
375        let map_write = |e: netcdf3::error::WriteError| {
376            ADError::UnsupportedConversion(format!("NetCDF write error: {:?}", e))
377        };
378
379        let first = &self.frames[0];
380        let nc_dt = nc_data_type(first.data_type)?;
381        let multi = self.frames.len() > 1;
382
383        // Build DataSet definition
384        let mut ds = DataSet::new();
385
386        // Leading "numArrays" dimension: NC_UNLIMITED for multi-frame files,
387        // a fixed dimension of size 1 for single-frame files. C++ NDFileNetCDF
388        // always defines `array_data` with rank `ndims+1` and dim0 = numArrays
389        // (NDFileNetCDF.cpp:117-119), so a single-frame file is still rank
390        // `ndims+1`, not `ndims`.
391        if multi {
392            ds.set_unlimited_dim(DIM_UNLIMITED, self.frames.len())
393                .map_err(map_def)?;
394        } else {
395            ds.add_fixed_dim(DIM_UNLIMITED, 1).map_err(map_def)?;
396        }
397
398        // Fixed dimensions in reversed order (matching C++ NDFileNetCDF)
399        let ndims = first.dims.len();
400        let mut dim_names: Vec<String> = Vec::new();
401        for i in 0..ndims {
402            let dim_idx = ndims - 1 - i;
403            let name = format!("dim{}", i);
404            ds.add_fixed_dim(&name, first.dims[dim_idx])
405                .map_err(map_def)?;
406            dim_names.push(name);
407        }
408
409        // String-attribute fixed dimension (NDFileNetCDF.cpp:135).
410        let has_string_attr = self.frames.iter().any(|f| {
411            f.attrs
412                .iter()
413                .any(|a| matches!(a.value, NDAttrValue::String(_)))
414        });
415        if has_string_attr {
416            ds.add_fixed_dim(ATTR_STRING_DIM, ATTR_STRING_SIZE)
417                .map_err(map_def)?;
418        }
419
420        // `array_data` always carries the leading numArrays dimension.
421        let var_dims: Vec<String> = {
422            let mut v = vec![DIM_UNLIMITED.to_string()];
423            v.extend(dim_names.iter().cloned());
424            v
425        };
426        let var_dim_refs: Vec<&str> = var_dims.iter().map(|s| s.as_str()).collect();
427        ds.add_var(VAR_NAME, &var_dim_refs, nc_dt)
428            .map_err(map_def)?;
429
430        // Per-frame metadata variables — always defined, leading numArrays dim.
431        ds.add_var("uniqueId", &[DIM_UNLIMITED], netcdf3::DataType::I32)
432            .map_err(map_def)?;
433        ds.add_var("timeStamp", &[DIM_UNLIMITED], netcdf3::DataType::F64)
434            .map_err(map_def)?;
435        ds.add_var("epicsTSSec", &[DIM_UNLIMITED], netcdf3::DataType::I32)
436            .map_err(map_def)?;
437        ds.add_var("epicsTSNsec", &[DIM_UNLIMITED], netcdf3::DataType::I32)
438            .map_err(map_def)?;
439
440        // Per-attribute record variables `Attr_<name>` plus the four
441        // global text attributes describing each one (NDFileNetCDF.cpp:210-330).
442        // The attribute set is taken from the first frame (C++ snapshots the
443        // attribute list at openFile time).
444        let mut attr_var_names: Vec<String> = Vec::new();
445        for attr in &first.attrs {
446            let var_name = format!("Attr_{}", attr.name);
447            let nc_type = attr_nc_type(&attr.value);
448            let is_string = matches!(attr.value, NDAttrValue::String(_));
449            if is_string {
450                ds.add_var(
451                    &var_name,
452                    &[DIM_UNLIMITED, ATTR_STRING_DIM],
453                    netcdf3::DataType::I8,
454                )
455                .map_err(map_def)?;
456            } else {
457                ds.add_var(&var_name, &[DIM_UNLIMITED], nc_type)
458                    .map_err(map_def)?;
459            }
460            attr_var_names.push(var_name);
461
462            ds.add_global_attr_string(
463                &format!("Attr_{}_DataType", attr.name),
464                &attr.data_type_string,
465            )
466            .map_err(map_def)?;
467            ds.add_global_attr_string(
468                &format!("Attr_{}_Description", attr.name),
469                &attr.description,
470            )
471            .map_err(map_def)?;
472            ds.add_global_attr_string(&format!("Attr_{}_Source", attr.name), &attr.source)
473                .map_err(map_def)?;
474            ds.add_global_attr_string(&format!("Attr_{}_SourceType", attr.name), &attr.source_type)
475                .map_err(map_def)?;
476        }
477
478        // Global attributes
479        ds.add_global_attr_i32("uniqueId", vec![first.unique_id])
480            .map_err(map_def)?;
481        ds.add_global_attr_i32("dataType", vec![first.data_type as i32])
482            .map_err(map_def)?;
483        ds.add_global_attr_i32("numArrays", vec![self.frames.len() as i32])
484            .map_err(map_def)?;
485
486        // Dimension metadata global attributes
487        ds.add_global_attr_i32("numArrayDims", vec![ndims as i32])
488            .map_err(map_def)?;
489        let dim_size: Vec<i32> = first.dim_meta.iter().map(|d| d.size as i32).collect();
490        ds.add_global_attr_i32("dimSize", dim_size)
491            .map_err(map_def)?;
492        let dim_offset: Vec<i32> = first.dim_meta.iter().map(|d| d.offset as i32).collect();
493        ds.add_global_attr_i32("dimOffset", dim_offset)
494            .map_err(map_def)?;
495        let dim_binning: Vec<i32> = first.dim_meta.iter().map(|d| d.binning as i32).collect();
496        ds.add_global_attr_i32("dimBinning", dim_binning)
497            .map_err(map_def)?;
498        let dim_reverse: Vec<i32> = first
499            .dim_meta
500            .iter()
501            .map(|d| if d.reverse { 1 } else { 0 })
502            .collect();
503        ds.add_global_attr_i32("dimReverse", dim_reverse)
504            .map_err(map_def)?;
505
506        // Write
507        let mut writer = FileWriter::open(&path).map_err(map_write)?;
508        writer
509            .set_def(&ds, Version::Classic, 0)
510            .map_err(map_write)?;
511
512        if multi {
513            for (i, frame) in self.frames.iter().enumerate() {
514                write_record_data(&mut writer, i, &frame.data)?;
515                writer
516                    .write_record_i32("uniqueId", i, &[frame.unique_id])
517                    .map_err(map_write)?;
518                writer
519                    .write_record_f64("timeStamp", i, &[frame.time_stamp])
520                    .map_err(map_write)?;
521                writer
522                    .write_record_i32("epicsTSSec", i, &[frame.epics_ts_sec])
523                    .map_err(map_write)?;
524                writer
525                    .write_record_i32("epicsTSNsec", i, &[frame.epics_ts_nsec])
526                    .map_err(map_write)?;
527                // Per-attribute values: align to the first frame's attribute
528                // order; missing attributes in later frames are skipped.
529                for (attr, var_name) in first.attrs.iter().zip(&attr_var_names) {
530                    let value = frame
531                        .attrs
532                        .iter()
533                        .find(|a| a.name == attr.name)
534                        .map(|a| &a.value)
535                        .unwrap_or(&attr.value);
536                    write_attr_value(&mut writer, var_name, i, true, value)?;
537                }
538            }
539        } else {
540            write_var_data(&mut writer, &self.frames[0].data)?;
541            writer
542                .write_var_i32("uniqueId", &[first.unique_id])
543                .map_err(map_write)?;
544            writer
545                .write_var_f64("timeStamp", &[first.time_stamp])
546                .map_err(map_write)?;
547            writer
548                .write_var_i32("epicsTSSec", &[first.epics_ts_sec])
549                .map_err(map_write)?;
550            writer
551                .write_var_i32("epicsTSNsec", &[first.epics_ts_nsec])
552                .map_err(map_write)?;
553            for (attr, var_name) in first.attrs.iter().zip(&attr_var_names) {
554                write_attr_value(&mut writer, var_name, 0, false, &attr.value)?;
555            }
556        }
557
558        writer.close().map_err(map_write)?;
559        self.frames.clear();
560        Ok(())
561    }
562
563    fn read_file(&mut self) -> ADResult<NDArray> {
564        let path = self
565            .current_path
566            .as_ref()
567            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
568
569        let map_read = |e: netcdf3::error::ReadError| {
570            ADError::UnsupportedConversion(format!("NetCDF read error: {:?}", e))
571        };
572
573        let mut reader = FileReader::open(path).map_err(map_read)?;
574
575        // Extract metadata from data_set() before any mutable read calls
576        let (is_record, dims, original_type_ordinal) = {
577            let ds = reader.data_set();
578            let var = ds.get_var(VAR_NAME).ok_or_else(|| {
579                ADError::UnsupportedConversion(format!(
580                    "variable '{}' not found in NetCDF file",
581                    VAR_NAME
582                ))
583            })?;
584
585            let is_record = ds.is_record_var(VAR_NAME).unwrap_or(false);
586
587            let var_dims_rc = var.get_dims();
588            let mut dims: Vec<NDDimension> = Vec::new();
589            for d in &var_dims_rc {
590                // Skip the leading numArrays dimension. It is unlimited for
591                // multi-frame files and a fixed dim of size 1 for single-frame
592                // files, so match it by name as well as the unlimited flag.
593                if d.is_unlimited() || d.name() == DIM_UNLIMITED {
594                    continue;
595                }
596                dims.push(NDDimension::new(d.size()));
597            }
598
599            let original_type_ordinal = ds
600                .get_global_attr_i32("dataType")
601                .and_then(|slice| slice.first().copied());
602
603            (is_record, dims, original_type_ordinal)
604        };
605
606        // Read first frame (record 0 if record variable, else full var)
607        let data_vec = if is_record {
608            reader.read_record(VAR_NAME, 0).map_err(map_read)?
609        } else {
610            reader.read_var(VAR_NAME).map_err(map_read)?
611        };
612
613        let (nd_type, buf) = match data_vec {
614            netcdf3::DataVector::I8(v) => (NDDataType::Int8, NDDataBuffer::I8(v)),
615            netcdf3::DataVector::U8(v) => (NDDataType::UInt8, NDDataBuffer::U8(v)),
616            netcdf3::DataVector::I16(v) => (NDDataType::Int16, NDDataBuffer::I16(v)),
617            netcdf3::DataVector::I32(v) => (NDDataType::Int32, NDDataBuffer::I32(v)),
618            netcdf3::DataVector::F32(v) => (NDDataType::Float32, NDDataBuffer::F32(v)),
619            netcdf3::DataVector::F64(v) => (NDDataType::Float64, NDDataBuffer::F64(v)),
620        };
621
622        // Check global attr "dataType" to recover original NDDataType
623        let actual_type = original_type_ordinal
624            .and_then(|v| NDDataType::from_ordinal(v as u8))
625            .unwrap_or(nd_type);
626
627        // Re-interpret if the original type was unsigned and stored as signed
628        let buf = match (actual_type, buf) {
629            (NDDataType::UInt16, NDDataBuffer::I16(v)) => {
630                NDDataBuffer::U16(v.into_iter().map(|x| x as u16).collect())
631            }
632            (NDDataType::UInt32, NDDataBuffer::I32(v)) => {
633                NDDataBuffer::U32(v.into_iter().map(|x| x as u32).collect())
634            }
635            (_, buf) => buf,
636        };
637
638        let mut arr = NDArray::new(dims, actual_type);
639        arr.data = buf;
640        Ok(arr)
641    }
642
643    fn supports_multiple_arrays(&self) -> bool {
644        true
645    }
646}
647
648/// NetCDF file processor wrapping NDPluginFileBase + NetcdfWriter.
649pub struct NetcdfFileProcessor {
650    ctrl: FilePluginController<NetcdfWriter>,
651}
652
653impl NetcdfFileProcessor {
654    pub fn new() -> Self {
655        Self {
656            ctrl: FilePluginController::new(NetcdfWriter::new()),
657        }
658    }
659}
660
661impl Default for NetcdfFileProcessor {
662    fn default() -> Self {
663        Self::new()
664    }
665}
666
667impl NDPluginProcess for NetcdfFileProcessor {
668    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
669        self.ctrl.process_array(array)
670    }
671
672    fn plugin_type(&self) -> &str {
673        "NDFileNetCDF"
674    }
675
676    fn register_params(
677        &mut self,
678        base: &mut asyn_rs::port::PortDriverBase,
679    ) -> asyn_rs::error::AsynResult<()> {
680        self.ctrl.register_params(base)
681    }
682
683    fn on_param_change(
684        &mut self,
685        reason: usize,
686        params: &PluginParamSnapshot,
687    ) -> ParamChangeResult {
688        self.ctrl.on_param_change(reason, params)
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
696    use std::sync::atomic::{AtomicU32, Ordering};
697
698    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
699
700    fn temp_path(prefix: &str) -> PathBuf {
701        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
702        std::env::temp_dir().join(format!("adcore_test_{}_{}.nc", prefix, n))
703    }
704
705    #[test]
706    fn test_write_u8_mono() {
707        let path = temp_path("nc_u8");
708        let mut writer = NetcdfWriter::new();
709
710        let mut arr = NDArray::new(
711            vec![NDDimension::new(4), NDDimension::new(4)],
712            NDDataType::UInt8,
713        );
714        if let NDDataBuffer::U8(v) = &mut arr.data {
715            for i in 0..16 {
716                v[i] = i as u8;
717            }
718        }
719
720        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
721        writer.write_file(&arr).unwrap();
722        writer.close_file().unwrap();
723
724        // Verify file exists and has NetCDF magic bytes: "CDF\x01" or "CDF\x02"
725        let data = std::fs::read(&path).unwrap();
726        assert!(data.len() > 16);
727        assert_eq!(&data[0..3], b"CDF", "Expected NetCDF magic bytes");
728
729        std::fs::remove_file(&path).ok();
730    }
731
732    #[test]
733    fn test_write_u16() {
734        let path = temp_path("nc_u16");
735        let mut writer = NetcdfWriter::new();
736
737        let mut arr = NDArray::new(
738            vec![NDDimension::new(4), NDDimension::new(4)],
739            NDDataType::UInt16,
740        );
741        if let NDDataBuffer::U16(v) = &mut arr.data {
742            for i in 0..16 {
743                v[i] = (i * 1000) as u16;
744            }
745        }
746
747        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
748        writer.write_file(&arr).unwrap();
749        writer.close_file().unwrap();
750
751        let data = std::fs::read(&path).unwrap();
752        assert!(data.len() > 32);
753        assert_eq!(&data[0..3], b"CDF");
754
755        std::fs::remove_file(&path).ok();
756    }
757
758    #[test]
759    fn test_roundtrip_u8() {
760        let path = temp_path("nc_rt_u8");
761        let mut writer = NetcdfWriter::new();
762
763        let mut arr = NDArray::new(
764            vec![NDDimension::new(4), NDDimension::new(4)],
765            NDDataType::UInt8,
766        );
767        if let NDDataBuffer::U8(v) = &mut arr.data {
768            for i in 0..16 {
769                v[i] = (i * 10) as u8;
770            }
771        }
772
773        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
774        writer.write_file(&arr).unwrap();
775        writer.close_file().unwrap();
776
777        writer.current_path = Some(path.clone());
778        let read_back = writer.read_file().unwrap();
779        if let (NDDataBuffer::U8(orig), NDDataBuffer::U8(read)) = (&arr.data, &read_back.data) {
780            assert_eq!(orig, read);
781        } else {
782            panic!("data type mismatch on roundtrip");
783        }
784
785        std::fs::remove_file(&path).ok();
786    }
787
788    #[test]
789    fn test_roundtrip_i16() {
790        let path = temp_path("nc_rt_i16");
791        let mut writer = NetcdfWriter::new();
792
793        let mut arr = NDArray::new(
794            vec![NDDimension::new(4), NDDimension::new(4)],
795            NDDataType::Int16,
796        );
797        if let NDDataBuffer::I16(v) = &mut arr.data {
798            for i in 0..16 {
799                v[i] = (i as i16) * 100 - 500;
800            }
801        }
802
803        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
804        writer.write_file(&arr).unwrap();
805        writer.close_file().unwrap();
806
807        writer.current_path = Some(path.clone());
808        let read_back = writer.read_file().unwrap();
809        if let (NDDataBuffer::I16(orig), NDDataBuffer::I16(read)) = (&arr.data, &read_back.data) {
810            assert_eq!(orig, read);
811        } else {
812            panic!("data type mismatch on roundtrip");
813        }
814
815        std::fs::remove_file(&path).ok();
816    }
817
818    #[test]
819    fn test_roundtrip_f32() {
820        let path = temp_path("nc_rt_f32");
821        let mut writer = NetcdfWriter::new();
822
823        let mut arr = NDArray::new(
824            vec![NDDimension::new(4), NDDimension::new(4)],
825            NDDataType::Float32,
826        );
827        if let NDDataBuffer::F32(v) = &mut arr.data {
828            for i in 0..16 {
829                v[i] = i as f32 * 0.5;
830            }
831        }
832
833        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
834        writer.write_file(&arr).unwrap();
835        writer.close_file().unwrap();
836
837        writer.current_path = Some(path.clone());
838        let read_back = writer.read_file().unwrap();
839        if let (NDDataBuffer::F32(orig), NDDataBuffer::F32(read)) = (&arr.data, &read_back.data) {
840            assert_eq!(orig, read);
841        } else {
842            panic!("data type mismatch on roundtrip");
843        }
844
845        std::fs::remove_file(&path).ok();
846    }
847
848    #[test]
849    fn test_multiple_frames() {
850        let path = temp_path("nc_multi");
851        let mut writer = NetcdfWriter::new();
852
853        let mut arr1 = NDArray::new(
854            vec![NDDimension::new(4), NDDimension::new(4)],
855            NDDataType::UInt8,
856        );
857        if let NDDataBuffer::U8(v) = &mut arr1.data {
858            for i in 0..16 {
859                v[i] = i as u8;
860            }
861        }
862
863        let mut arr2 = NDArray::new(
864            vec![NDDimension::new(4), NDDimension::new(4)],
865            NDDataType::UInt8,
866        );
867        if let NDDataBuffer::U8(v) = &mut arr2.data {
868            for i in 0..16 {
869                v[i] = (i as u8).wrapping_add(100);
870            }
871        }
872
873        let mut arr3 = NDArray::new(
874            vec![NDDimension::new(4), NDDimension::new(4)],
875            NDDataType::UInt8,
876        );
877        if let NDDataBuffer::U8(v) = &mut arr3.data {
878            for i in 0..16 {
879                v[i] = (i as u8).wrapping_add(200);
880            }
881        }
882
883        writer.open_file(&path, NDFileMode::Stream, &arr1).unwrap();
884        writer.write_file(&arr1).unwrap();
885        writer.write_file(&arr2).unwrap();
886        writer.write_file(&arr3).unwrap();
887        writer.close_file().unwrap();
888
889        // Read back first frame
890        writer.current_path = Some(path.clone());
891        let read_back = writer.read_file().unwrap();
892        if let NDDataBuffer::U8(v) = &read_back.data {
893            assert_eq!(v.len(), 16);
894            for i in 0..16 {
895                assert_eq!(v[i], i as u8, "mismatch at index {}", i);
896            }
897        } else {
898            panic!("expected U8 data");
899        }
900
901        std::fs::remove_file(&path).ok();
902    }
903
904    #[test]
905    fn test_attributes_stored_as_per_frame_variables() {
906        let path = temp_path("nc_attrs");
907        let mut writer = NetcdfWriter::new();
908
909        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
910        arr.attributes.add(NDAttribute::new_static(
911            "exposure",
912            "Exposure time",
913            NDAttrSource::Driver,
914            NDAttrValue::Float64(0.5),
915        ));
916        arr.attributes.add(NDAttribute::new_static(
917            "gain",
918            "Detector gain",
919            NDAttrSource::Driver,
920            NDAttrValue::Int32(42),
921        ));
922
923        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
924        writer.write_file(&arr).unwrap();
925        writer.close_file().unwrap();
926
927        let mut reader = FileReader::open(&path).unwrap();
928        {
929            let ds = reader.data_set();
930            // Per-attribute Attr_<name> variables exist with the leading dim.
931            assert!(ds.get_var("Attr_exposure").is_some());
932            assert!(ds.get_var("Attr_gain").is_some());
933            // Four descriptive global text attributes per NDAttribute.
934            assert_eq!(
935                ds.get_global_attr_as_string("Attr_exposure_DataType"),
936                Some("Float64".to_string())
937            );
938            assert_eq!(
939                ds.get_global_attr_as_string("Attr_gain_DataType"),
940                Some("Int32".to_string())
941            );
942            assert_eq!(
943                ds.get_global_attr_as_string("Attr_exposure_Description"),
944                Some("Exposure time".to_string())
945            );
946            assert_eq!(
947                ds.get_global_attr_as_string("Attr_gain_SourceType"),
948                Some("NDAttrSourceDriver".to_string())
949            );
950        }
951        // The per-frame value is recoverable from the variable.
952        if let netcdf3::DataVector::F64(v) = reader.read_var("Attr_exposure").unwrap() {
953            assert_eq!(v, vec![0.5]);
954        } else {
955            panic!("Attr_exposure should be F64");
956        }
957        if let netcdf3::DataVector::I32(v) = reader.read_var("Attr_gain").unwrap() {
958            assert_eq!(v, vec![42]);
959        } else {
960            panic!("Attr_gain should be I32");
961        }
962
963        drop(reader);
964        std::fs::remove_file(&path).ok();
965    }
966
967    #[test]
968    fn test_single_frame_array_data_has_leading_numarrays_dim() {
969        let path = temp_path("nc_rank");
970        let mut writer = NetcdfWriter::new();
971
972        let arr = NDArray::new(
973            vec![NDDimension::new(4), NDDimension::new(3)],
974            NDDataType::UInt8,
975        );
976        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
977        writer.write_file(&arr).unwrap();
978        writer.close_file().unwrap();
979
980        let reader = FileReader::open(&path).unwrap();
981        let ds = reader.data_set();
982        let var = ds.get_var("array_data").unwrap();
983        // C++ always defines array_data with rank ndims+1; a 2-D NDArray
984        // single-frame file must therefore have a 3-D array_data variable.
985        assert_eq!(var.get_dims().len(), 3);
986        assert_eq!(var.get_dims()[0].name(), "numArrays");
987        assert_eq!(var.get_dims()[0].size(), 1);
988
989        drop(reader);
990        std::fs::remove_file(&path).ok();
991    }
992
993    #[test]
994    fn test_all_four_metadata_variables_written_single_frame() {
995        let path = temp_path("nc_meta");
996        let mut writer = NetcdfWriter::new();
997
998        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
999        arr.unique_id = 99;
1000        arr.time_stamp = 12.5;
1001        arr.timestamp.sec = 555;
1002        arr.timestamp.nsec = 777;
1003
1004        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1005        writer.write_file(&arr).unwrap();
1006        writer.close_file().unwrap();
1007
1008        let mut reader = FileReader::open(&path).unwrap();
1009        for name in ["uniqueId", "timeStamp", "epicsTSSec", "epicsTSNsec"] {
1010            assert!(
1011                reader.data_set().get_var(name).is_some(),
1012                "{name} variable missing"
1013            );
1014        }
1015        match reader.read_var("uniqueId").unwrap() {
1016            netcdf3::DataVector::I32(v) => assert_eq!(v, vec![99]),
1017            other => panic!("uniqueId wrong type: {other:?}"),
1018        }
1019        match reader.read_var("epicsTSSec").unwrap() {
1020            netcdf3::DataVector::I32(v) => assert_eq!(v, vec![555]),
1021            other => panic!("epicsTSSec wrong type: {other:?}"),
1022        }
1023        match reader.read_var("epicsTSNsec").unwrap() {
1024            netcdf3::DataVector::I32(v) => assert_eq!(v, vec![777]),
1025            other => panic!("epicsTSNsec wrong type: {other:?}"),
1026        }
1027
1028        drop(reader);
1029        std::fs::remove_file(&path).ok();
1030    }
1031
1032    #[test]
1033    fn test_nddatatype_ordinals_match_c() {
1034        // The `dataType` global attribute stores `NDDataType as i32`, which the
1035        // reader uses to recover the original type. The discriminants must
1036        // match the C `NDDataType_t` enum (NDInt8=0 .. NDFloat64=9).
1037        assert_eq!(NDDataType::Int8 as i32, 0);
1038        assert_eq!(NDDataType::UInt8 as i32, 1);
1039        assert_eq!(NDDataType::Int16 as i32, 2);
1040        assert_eq!(NDDataType::UInt16 as i32, 3);
1041        assert_eq!(NDDataType::Int32 as i32, 4);
1042        assert_eq!(NDDataType::UInt32 as i32, 5);
1043        assert_eq!(NDDataType::Int64 as i32, 6);
1044        assert_eq!(NDDataType::UInt64 as i32, 7);
1045        assert_eq!(NDDataType::Float32 as i32, 8);
1046        assert_eq!(NDDataType::Float64 as i32, 9);
1047    }
1048}