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