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