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    /// C's `openMode & NDFileModeMultiple` (NDFileNetCDF.cpp:118) — the sole
98    /// input to the numArrays dimension. NDPluginFile passes the Multiple bit
99    /// for Capture and Stream and withholds it for Single (NDPluginFile.cpp:245,
100    /// :281, :335), so it is fixed when the file is opened and cannot be
101    /// re-derived later from how many frames happened to arrive: a Capture file
102    /// that captured exactly one frame is still NC_UNLIMITED.
103    open_multiple: bool,
104}
105
106impl NetcdfWriter {
107    pub fn new() -> Self {
108        Self {
109            current_path: None,
110            frames: Vec::new(),
111            open_multiple: false,
112        }
113    }
114}
115
116/// nc_type of the `array_data` variable, i.e. C's `switch (pArray->dataType)`
117/// (NDFileNetCDF.cpp:152-178).
118///
119/// netCDF-3 has no unsigned types, so C maps *both* Int8 and UInt8 to NC_BYTE
120/// and lets the `dataType` global attribute carry the sign back to the reader
121/// (:88-92). It has no 64-bit integer either, so Int64/UInt64 are cast to
122/// NC_DOUBLE (:169-172).
123///
124/// Beware the netcdf3 crate's spelling: `DataType::I8` is NC_BYTE (nc_type 1),
125/// but `DataType::U8` is NC_CHAR (nc_type 2) — a *text* type. C never stores
126/// image data as NC_CHAR, so `U8` must not appear here.
127fn nc_data_type(dt: NDDataType) -> ADResult<netcdf3::DataType> {
128    match dt {
129        NDDataType::Int8 | NDDataType::UInt8 => Ok(netcdf3::DataType::I8),
130        NDDataType::Int16 | NDDataType::UInt16 => Ok(netcdf3::DataType::I16),
131        NDDataType::Int32 | NDDataType::UInt32 => Ok(netcdf3::DataType::I32),
132        NDDataType::Float32 => Ok(netcdf3::DataType::F32),
133        NDDataType::Float64 => Ok(netcdf3::DataType::F64),
134        NDDataType::Int64 | NDDataType::UInt64 => Ok(netcdf3::DataType::F64),
135    }
136}
137
138/// Write a single frame's data to a fixed-dimension variable.
139fn write_var_data(writer: &mut FileWriter, data: &NDDataBuffer) -> ADResult<()> {
140    let err = |e: netcdf3::error::WriteError| {
141        ADError::UnsupportedConversion(format!("NetCDF write error: {:?}", e))
142    };
143    match data {
144        NDDataBuffer::I8(v) => writer.write_var_i8(VAR_NAME, v).map_err(err),
145        // NC_BYTE variable: C `nc_put_vara_uchar` into an NC_BYTE variable is a
146        // straight bit-pattern copy (NDFileNetCDF.cpp:385-388), so values above
147        // 127 land as negative bytes on disk.
148        NDDataBuffer::U8(v) => {
149            let reinterp: Vec<i8> = v.iter().map(|&x| x as i8).collect();
150            writer.write_var_i8(VAR_NAME, &reinterp).map_err(err)
151        }
152        NDDataBuffer::I16(v) => writer.write_var_i16(VAR_NAME, v).map_err(err),
153        NDDataBuffer::U16(v) => {
154            let reinterp: Vec<i16> = v.iter().map(|&x| x as i16).collect();
155            writer.write_var_i16(VAR_NAME, &reinterp).map_err(err)
156        }
157        NDDataBuffer::I32(v) => writer.write_var_i32(VAR_NAME, v).map_err(err),
158        NDDataBuffer::U32(v) => {
159            let reinterp: Vec<i32> = v.iter().map(|&x| x as i32).collect();
160            writer.write_var_i32(VAR_NAME, &reinterp).map_err(err)
161        }
162        NDDataBuffer::F32(v) => writer.write_var_f32(VAR_NAME, v).map_err(err),
163        NDDataBuffer::F64(v) => writer.write_var_f64(VAR_NAME, v).map_err(err),
164        NDDataBuffer::I64(v) => {
165            let reinterp: Vec<f64> = v.iter().map(|&x| x as f64).collect();
166            writer.write_var_f64(VAR_NAME, &reinterp).map_err(err)
167        }
168        NDDataBuffer::U64(v) => {
169            let reinterp: Vec<f64> = v.iter().map(|&x| x as f64).collect();
170            writer.write_var_f64(VAR_NAME, &reinterp).map_err(err)
171        }
172    }
173}
174
175/// Write a single record (one frame) to a record variable.
176fn write_record_data(
177    writer: &mut FileWriter,
178    record_index: usize,
179    data: &NDDataBuffer,
180) -> ADResult<()> {
181    let err = |e: netcdf3::error::WriteError| {
182        ADError::UnsupportedConversion(format!("NetCDF write error: {:?}", e))
183    };
184    match data {
185        NDDataBuffer::I8(v) => writer
186            .write_record_i8(VAR_NAME, record_index, v)
187            .map_err(err),
188        // NC_BYTE variable — see `write_var_data`.
189        NDDataBuffer::U8(v) => {
190            let reinterp: Vec<i8> = v.iter().map(|&x| x as i8).collect();
191            writer
192                .write_record_i8(VAR_NAME, record_index, &reinterp)
193                .map_err(err)
194        }
195        NDDataBuffer::I16(v) => writer
196            .write_record_i16(VAR_NAME, record_index, v)
197            .map_err(err),
198        NDDataBuffer::U16(v) => {
199            let reinterp: Vec<i16> = v.iter().map(|&x| x as i16).collect();
200            writer
201                .write_record_i16(VAR_NAME, record_index, &reinterp)
202                .map_err(err)
203        }
204        NDDataBuffer::I32(v) => writer
205            .write_record_i32(VAR_NAME, record_index, v)
206            .map_err(err),
207        NDDataBuffer::U32(v) => {
208            let reinterp: Vec<i32> = v.iter().map(|&x| x as i32).collect();
209            writer
210                .write_record_i32(VAR_NAME, record_index, &reinterp)
211                .map_err(err)
212        }
213        NDDataBuffer::F32(v) => writer
214            .write_record_f32(VAR_NAME, record_index, v)
215            .map_err(err),
216        NDDataBuffer::F64(v) => writer
217            .write_record_f64(VAR_NAME, record_index, v)
218            .map_err(err),
219        NDDataBuffer::I64(v) => {
220            let reinterp: Vec<f64> = v.iter().map(|&x| x as f64).collect();
221            writer
222                .write_record_f64(VAR_NAME, record_index, &reinterp)
223                .map_err(err)
224        }
225        NDDataBuffer::U64(v) => {
226            let reinterp: Vec<f64> = v.iter().map(|&x| x as f64).collect();
227            writer
228                .write_record_f64(VAR_NAME, record_index, &reinterp)
229                .map_err(err)
230        }
231    }
232}
233
234const ATTR_STRING_DIM: &str = "attrStringSize";
235const ATTR_STRING_SIZE: usize = 256;
236
237/// nc_type of the `Attr_<name>` variable, i.e. C's second
238/// `switch (attrDataType)` (NDFileNetCDF.cpp:283-310).
239///
240/// String attributes are NC_CHAR — `DataType::U8` in the netcdf3 crate's
241/// spelling — and are the *only* NC_CHAR variables C writes. Everything else
242/// follows the same signed/64-bit collapse as [`nc_data_type`], with
243/// `Undefined` falling back to NC_BYTE (:305).
244fn attr_nc_type(value: &NDAttrValue) -> netcdf3::DataType {
245    match value {
246        NDAttrValue::Int8(_) | NDAttrValue::UInt8(_) | NDAttrValue::Undefined => {
247            netcdf3::DataType::I8
248        }
249        NDAttrValue::Int16(_) | NDAttrValue::UInt16(_) => netcdf3::DataType::I16,
250        NDAttrValue::Int32(_) | NDAttrValue::UInt32(_) => netcdf3::DataType::I32,
251        NDAttrValue::Float32(_) => netcdf3::DataType::F32,
252        NDAttrValue::Float64(_) | NDAttrValue::Int64(_) | NDAttrValue::UInt64(_) => {
253            netcdf3::DataType::F64
254        }
255        NDAttrValue::String(_) => netcdf3::DataType::U8,
256    }
257}
258
259/// Write one frame's value into the `Attr_<name>` variable at `record_index`.
260/// For single-frame files `record_index` is 0 and the variable is non-record.
261fn write_attr_value(
262    writer: &mut FileWriter,
263    var_name: &str,
264    record_index: usize,
265    multi: bool,
266    value: &NDAttrValue,
267) -> ADResult<()> {
268    let werr = |e: netcdf3::error::WriteError| {
269        ADError::UnsupportedConversion(format!("NetCDF attr write error: {:?}", e))
270    };
271    // String values are stored as a fixed-width NC_CHAR row. C writes only
272    // `strlen(attrString)` characters (NDFileNetCDF.cpp:462-465) and leaves the
273    // tail at the NC_CHAR fill value, which is NUL — the same bytes this
274    // NUL-padded full-width write produces.
275    if let NDAttrValue::String(s) = value {
276        let mut bytes: Vec<u8> = s.bytes().take(ATTR_STRING_SIZE).collect();
277        bytes.resize(ATTR_STRING_SIZE, 0);
278        return if multi {
279            writer
280                .write_record_u8(var_name, record_index, &bytes)
281                .map_err(werr)
282        } else {
283            writer.write_var_u8(var_name, &bytes).map_err(werr)
284        };
285    }
286    match attr_nc_type(value) {
287        netcdf3::DataType::I8 => {
288            let v = value.as_i64().unwrap_or(0) as i8;
289            if multi {
290                writer
291                    .write_record_i8(var_name, record_index, &[v])
292                    .map_err(werr)
293            } else {
294                writer.write_var_i8(var_name, &[v]).map_err(werr)
295            }
296        }
297        netcdf3::DataType::I16 => {
298            let v = value.as_i64().unwrap_or(0) as i16;
299            if multi {
300                writer
301                    .write_record_i16(var_name, record_index, &[v])
302                    .map_err(werr)
303            } else {
304                writer.write_var_i16(var_name, &[v]).map_err(werr)
305            }
306        }
307        netcdf3::DataType::I32 => {
308            let v = value.as_i64().unwrap_or(0) as i32;
309            if multi {
310                writer
311                    .write_record_i32(var_name, record_index, &[v])
312                    .map_err(werr)
313            } else {
314                writer.write_var_i32(var_name, &[v]).map_err(werr)
315            }
316        }
317        netcdf3::DataType::F32 => {
318            let v = value.as_f64().unwrap_or(0.0) as f32;
319            if multi {
320                writer
321                    .write_record_f32(var_name, record_index, &[v])
322                    .map_err(werr)
323            } else {
324                writer.write_var_f32(var_name, &[v]).map_err(werr)
325            }
326        }
327        netcdf3::DataType::F64 => {
328            let v = value.as_f64().unwrap_or(0.0);
329            if multi {
330                writer
331                    .write_record_f64(var_name, record_index, &[v])
332                    .map_err(werr)
333            } else {
334                writer.write_var_f64(var_name, &[v]).map_err(werr)
335            }
336        }
337        // NC_CHAR is reached only for string attributes, handled above.
338        netcdf3::DataType::U8 => unreachable!("attr_nc_type returns U8 only for strings"),
339    }
340}
341
342/// Build the netCDF-3 header, mirroring C `NDFileNetCDF::openFile`
343/// (NDFileNetCDF.cpp:83-333) statement for statement.
344///
345/// A netCDF-3 header stores its dimensions, its global attributes and its
346/// variables as three ordered lists, and every reader that walks a file by
347/// index — rather than by name — sees that order. So the order in which C
348/// defines things *is* file format, and this function is the one place that
349/// owns it: dimensions and definitions are emitted here in C's order, and
350/// nowhere else, so no later edit can re-order the header by adding a
351/// definition next to the code that happens to need it.
352///
353/// Returns the data set plus the `Attr_<name>` variable names in definition
354/// order, so the write pass visits the attribute variables in the same order.
355fn define_data_set(
356    first: &FrameData,
357    num_frames: usize,
358    multi: bool,
359) -> ADResult<(DataSet, Vec<String>)> {
360    let map_def = |e: netcdf3::error::InvalidDataSet| {
361        ADError::UnsupportedConversion(format!("NetCDF definition error: {:?}", e))
362    };
363
364    let mut ds = DataSet::new();
365    let ndims = first.dims.len();
366
367    // --- Global attributes, part 1 (C :88-101, :107-110, :137-151) ---------
368    // C emits dataType and NDNetCDFFileVersion before it defines any
369    // dimension, and the dim* metadata attributes right after; the gatt list
370    // therefore starts with these seven, in this order.
371    ds.add_global_attr_i32("dataType", vec![first.data_type as i32])
372        .map_err(map_def)?;
373    ds.add_global_attr_f64("NDNetCDFFileVersion", vec![ND_NETCDF_FILE_VERSION])
374        .map_err(map_def)?;
375    ds.add_global_attr_i32("numArrayDims", vec![ndims as i32])
376        .map_err(map_def)?;
377    // C reads dims[i] here — natural order, *not* the reversed order used for
378    // the dimension definitions below (:125-131).
379    let dim_size: Vec<i32> = first.dim_meta.iter().map(|d| d.size as i32).collect();
380    ds.add_global_attr_i32("dimSize", dim_size)
381        .map_err(map_def)?;
382    let dim_offset: Vec<i32> = first.dim_meta.iter().map(|d| d.offset as i32).collect();
383    ds.add_global_attr_i32("dimOffset", dim_offset)
384        .map_err(map_def)?;
385    let dim_binning: Vec<i32> = first.dim_meta.iter().map(|d| d.binning as i32).collect();
386    ds.add_global_attr_i32("dimBinning", dim_binning)
387        .map_err(map_def)?;
388    let dim_reverse: Vec<i32> = first
389        .dim_meta
390        .iter()
391        .map(|d| if d.reverse { 1 } else { 0 })
392        .collect();
393    ds.add_global_attr_i32("dimReverse", dim_reverse)
394        .map_err(map_def)?;
395
396    // --- Dimensions (C :113-136) ------------------------------------------
397    // numArrays first: NC_UNLIMITED for a multi-array file, fixed size 1
398    // otherwise (:118-120).
399    if multi {
400        ds.set_unlimited_dim(DIM_UNLIMITED, num_frames)
401            .map_err(map_def)?;
402    } else {
403        ds.add_fixed_dim(DIM_UNLIMITED, 1).map_err(map_def)?;
404    }
405    // Then the array dimensions, reversed: netCDF's first dimension varies
406    // slowest, the opposite of the NDArray convention (:122-127).
407    let mut dim_names: Vec<String> = Vec::new();
408    for i in 0..ndims {
409        let name = format!("dim{}", i);
410        ds.add_fixed_dim(&name, first.dims[ndims - 1 - i])
411            .map_err(map_def)?;
412        dim_names.push(name);
413    }
414    // attrStringSize last — defined unconditionally (:134-136), even when no
415    // string attribute uses it. It is part of the header C always writes.
416    ds.add_fixed_dim(ATTR_STRING_DIM, ATTR_STRING_SIZE)
417        .map_err(map_def)?;
418
419    // --- Variables (C :181-206) -------------------------------------------
420    // The four per-array metadata variables come first, array_data fifth.
421    ds.add_var("uniqueId", &[DIM_UNLIMITED], netcdf3::DataType::I32)
422        .map_err(map_def)?;
423    ds.add_var("timeStamp", &[DIM_UNLIMITED], netcdf3::DataType::F64)
424        .map_err(map_def)?;
425    ds.add_var("epicsTSSec", &[DIM_UNLIMITED], netcdf3::DataType::I32)
426        .map_err(map_def)?;
427    ds.add_var("epicsTSNsec", &[DIM_UNLIMITED], netcdf3::DataType::I32)
428        .map_err(map_def)?;
429
430    // array_data always carries the leading numArrays dimension, so a
431    // single-array file is still rank ndims+1 (:203-205).
432    let mut var_dims: Vec<&str> = vec![DIM_UNLIMITED];
433    var_dims.extend(dim_names.iter().map(|s| s.as_str()));
434    ds.add_var(VAR_NAME, &var_dims, nc_data_type(first.data_type)?)
435        .map_err(map_def)?;
436
437    // --- Per-attribute variables and their text attributes (C :208-330) ----
438    // One pass over the attribute list, exactly as C does: the four
439    // Attr_<name>_* global text attributes, then the Attr_<name> variable.
440    // The attribute set is the first frame's — C snapshots the list at
441    // openFile time and requires it not to change (:417).
442    let mut attr_var_names: Vec<String> = Vec::new();
443    for attr in &first.attrs {
444        ds.add_global_attr_string(
445            &format!("Attr_{}_DataType", attr.name),
446            &attr.data_type_string,
447        )
448        .map_err(map_def)?;
449        ds.add_global_attr_string(
450            &format!("Attr_{}_Description", attr.name),
451            &attr.description,
452        )
453        .map_err(map_def)?;
454        ds.add_global_attr_string(&format!("Attr_{}_Source", attr.name), &attr.source)
455            .map_err(map_def)?;
456        ds.add_global_attr_string(&format!("Attr_{}_SourceType", attr.name), &attr.source_type)
457            .map_err(map_def)?;
458
459        let var_name = format!("Attr_{}", attr.name);
460        // A string attribute is a 2-D NC_CHAR variable [numArrays,
461        // attrStringSize]; everything else is 1-D over numArrays (:312-321).
462        if matches!(attr.value, NDAttrValue::String(_)) {
463            ds.add_var(
464                &var_name,
465                &[DIM_UNLIMITED, ATTR_STRING_DIM],
466                attr_nc_type(&attr.value),
467            )
468            .map_err(map_def)?;
469        } else {
470            ds.add_var(&var_name, &[DIM_UNLIMITED], attr_nc_type(&attr.value))
471                .map_err(map_def)?;
472        }
473        attr_var_names.push(var_name);
474    }
475
476    Ok((ds, attr_var_names))
477}
478
479impl NDFileWriter for NetcdfWriter {
480    fn open_file(&mut self, path: &Path, mode: NDFileMode, _array: &NDArray) -> ADResult<()> {
481        self.current_path = Some(path.to_path_buf());
482        self.frames.clear();
483        // C: NDPluginFile opens Single with `NDFileModeWrite` and Capture/Stream
484        // with `NDFileModeWrite | NDFileModeMultiple` (NDPluginFile.cpp:245, :281,
485        // :335) — this writer reports supportsMultipleArrays, so those two modes
486        // always carry the Multiple bit.
487        self.open_multiple = mode != NDFileMode::Single;
488        Ok(())
489    }
490
491    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
492        // Validate data type early
493        nc_data_type(array.data.data_type())?;
494
495        let dims: Vec<usize> = array.dims.iter().map(|d| d.size).collect();
496        let dim_meta: Vec<DimMeta> = array
497            .dims
498            .iter()
499            .map(|d| DimMeta {
500                size: d.size,
501                offset: d.offset,
502                binning: d.binning,
503                reverse: d.reverse,
504            })
505            .collect();
506        let attrs: Vec<AttrData> = array
507            .attributes
508            .iter()
509            .map(|a| AttrData {
510                name: a.name.clone(),
511                description: a.description.clone(),
512                // C `NDFileNetCDF` writes `NDAttribute::getSource()` verbatim
513                // (NDFileNetCDF.cpp getAttributesFromFile); never synthesize it.
514                source: a.source.source_string().to_string(),
515                source_type: attr_source_type_string(&a.source).to_string(),
516                data_type_string: attr_data_type_string(&a.value).to_string(),
517                value: a.value.clone(),
518            })
519            .collect();
520
521        self.frames.push(FrameData {
522            dims,
523            dim_meta,
524            data: array.data.clone(),
525            data_type: array.data.data_type(),
526            attrs,
527            unique_id: array.unique_id,
528            time_stamp: array.time_stamp,
529            epics_ts_sec: array.timestamp.sec as i32,
530            epics_ts_nsec: array.timestamp.nsec as i32,
531        });
532        Ok(())
533    }
534
535    fn close_file(&mut self) -> ADResult<()> {
536        let path = match self.current_path.take() {
537            Some(p) => p,
538            None => return Ok(()),
539        };
540
541        if self.frames.is_empty() {
542            return Ok(());
543        }
544
545        let map_write = |e: netcdf3::error::WriteError| {
546            ADError::UnsupportedConversion(format!("NetCDF write error: {:?}", e))
547        };
548
549        let first = &self.frames[0];
550        // C keys the numArrays dimension on the *open mode*, never on how many
551        // frames the file ended up holding (NDFileNetCDF.cpp:117-119).
552        let multi = self.open_multiple;
553        let (ds, attr_var_names) = define_data_set(first, self.frames.len(), multi)?;
554
555        // Write
556        let mut writer = FileWriter::open(&path).map_err(map_write)?;
557        writer
558            .set_def(&ds, Version::Classic, 0)
559            .map_err(map_write)?;
560
561        if multi {
562            for (i, frame) in self.frames.iter().enumerate() {
563                write_record_data(&mut writer, i, &frame.data)?;
564                writer
565                    .write_record_i32("uniqueId", i, &[frame.unique_id])
566                    .map_err(map_write)?;
567                writer
568                    .write_record_f64("timeStamp", i, &[frame.time_stamp])
569                    .map_err(map_write)?;
570                writer
571                    .write_record_i32("epicsTSSec", i, &[frame.epics_ts_sec])
572                    .map_err(map_write)?;
573                writer
574                    .write_record_i32("epicsTSNsec", i, &[frame.epics_ts_nsec])
575                    .map_err(map_write)?;
576                // Per-attribute values: align to the first frame's attribute
577                // order; missing attributes in later frames are skipped.
578                for (attr, var_name) in first.attrs.iter().zip(&attr_var_names) {
579                    let value = frame
580                        .attrs
581                        .iter()
582                        .find(|a| a.name == attr.name)
583                        .map(|a| &a.value)
584                        .unwrap_or(&attr.value);
585                    write_attr_value(&mut writer, var_name, i, true, value)?;
586                }
587            }
588        } else {
589            write_var_data(&mut writer, &self.frames[0].data)?;
590            writer
591                .write_var_i32("uniqueId", &[first.unique_id])
592                .map_err(map_write)?;
593            writer
594                .write_var_f64("timeStamp", &[first.time_stamp])
595                .map_err(map_write)?;
596            writer
597                .write_var_i32("epicsTSSec", &[first.epics_ts_sec])
598                .map_err(map_write)?;
599            writer
600                .write_var_i32("epicsTSNsec", &[first.epics_ts_nsec])
601                .map_err(map_write)?;
602            for (attr, var_name) in first.attrs.iter().zip(&attr_var_names) {
603                write_attr_value(&mut writer, var_name, 0, false, &attr.value)?;
604            }
605        }
606
607        writer.close().map_err(map_write)?;
608        self.frames.clear();
609        Ok(())
610    }
611
612    fn read_file(&mut self) -> ADResult<NDArray> {
613        let path = self
614            .current_path
615            .as_ref()
616            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
617
618        let map_read = |e: netcdf3::error::ReadError| {
619            ADError::UnsupportedConversion(format!("NetCDF read error: {:?}", e))
620        };
621
622        let mut reader = FileReader::open(path).map_err(map_read)?;
623
624        // Extract metadata from data_set() before any mutable read calls
625        let (is_record, dims, original_type_ordinal) = {
626            let ds = reader.data_set();
627            let var = ds.get_var(VAR_NAME).ok_or_else(|| {
628                ADError::UnsupportedConversion(format!(
629                    "variable '{}' not found in NetCDF file",
630                    VAR_NAME
631                ))
632            })?;
633
634            let is_record = ds.is_record_var(VAR_NAME).unwrap_or(false);
635
636            let var_dims_rc = var.get_dims();
637            let mut dims: Vec<NDDimension> = Vec::new();
638            for d in &var_dims_rc {
639                // Skip the leading numArrays dimension. It is unlimited for
640                // multi-frame files and a fixed dim of size 1 for single-frame
641                // files, so match it by name as well as the unlimited flag.
642                if d.is_unlimited() || d.name() == DIM_UNLIMITED {
643                    continue;
644                }
645                dims.push(NDDimension::new(d.size()));
646            }
647
648            let original_type_ordinal = ds
649                .get_global_attr_i32("dataType")
650                .and_then(|slice| slice.first().copied());
651
652            (is_record, dims, original_type_ordinal)
653        };
654
655        // Read first frame (record 0 if record variable, else full var)
656        let data_vec = if is_record {
657            reader.read_record(VAR_NAME, 0).map_err(map_read)?
658        } else {
659            reader.read_var(VAR_NAME).map_err(map_read)?
660        };
661
662        let (nd_type, buf) = match data_vec {
663            netcdf3::DataVector::I8(v) => (NDDataType::Int8, NDDataBuffer::I8(v)),
664            netcdf3::DataVector::U8(v) => (NDDataType::UInt8, NDDataBuffer::U8(v)),
665            netcdf3::DataVector::I16(v) => (NDDataType::Int16, NDDataBuffer::I16(v)),
666            netcdf3::DataVector::I32(v) => (NDDataType::Int32, NDDataBuffer::I32(v)),
667            netcdf3::DataVector::F32(v) => (NDDataType::Float32, NDDataBuffer::F32(v)),
668            netcdf3::DataVector::F64(v) => (NDDataType::Float64, NDDataBuffer::F64(v)),
669        };
670
671        // Check global attr "dataType" to recover original NDDataType
672        let actual_type = original_type_ordinal
673            .and_then(|v| NDDataType::from_ordinal(v as u8))
674            .unwrap_or(nd_type);
675
676        // Re-interpret if the original type was unsigned and stored as signed.
677        // netCDF-3 has no unsigned types, so `dataType` is the only record of
678        // the sign; UInt8 comes back from an NC_BYTE variable as i8.
679        let buf = match (actual_type, buf) {
680            (NDDataType::UInt8, NDDataBuffer::I8(v)) => {
681                NDDataBuffer::U8(v.into_iter().map(|x| x as u8).collect())
682            }
683            (NDDataType::UInt16, NDDataBuffer::I16(v)) => {
684                NDDataBuffer::U16(v.into_iter().map(|x| x as u16).collect())
685            }
686            (NDDataType::UInt32, NDDataBuffer::I32(v)) => {
687                NDDataBuffer::U32(v.into_iter().map(|x| x as u32).collect())
688            }
689            (_, buf) => buf,
690        };
691
692        let mut arr = NDArray::new(dims, actual_type);
693        arr.data = buf;
694        Ok(arr)
695    }
696
697    fn supports_multiple_arrays(&self) -> bool {
698        true
699    }
700}
701
702/// NetCDF file processor wrapping NDPluginFileBase + NetcdfWriter.
703pub struct NetcdfFileProcessor {
704    ctrl: FilePluginController<NetcdfWriter>,
705}
706
707impl NetcdfFileProcessor {
708    pub fn new() -> Self {
709        Self {
710            ctrl: FilePluginController::new(NetcdfWriter::new()),
711        }
712    }
713}
714
715impl Default for NetcdfFileProcessor {
716    fn default() -> Self {
717        Self::new()
718    }
719}
720
721impl NDPluginProcess for NetcdfFileProcessor {
722    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
723        self.ctrl.process_array(array)
724    }
725
726    fn plugin_type(&self) -> &str {
727        "NDFileNetCDF"
728    }
729
730    /// C `NDPluginFile.cpp:948` (base of every file writer) sets
731    /// `NDArrayCallbacks = 0`: file plugins write to disk, not downstream.
732    fn does_array_callbacks(&self) -> bool {
733        false
734    }
735
736    fn register_params(
737        &mut self,
738        base: &mut asyn_rs::port::PortDriverBase,
739    ) -> asyn_rs::error::AsynResult<()> {
740        self.ctrl.register_params(base)
741    }
742
743    fn on_param_change(
744        &mut self,
745        reason: usize,
746        params: &PluginParamSnapshot,
747    ) -> ParamChangeResult {
748        self.ctrl.on_param_change(reason, params)
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
756    use std::sync::atomic::{AtomicU32, Ordering};
757
758    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
759
760    fn temp_path(prefix: &str) -> PathBuf {
761        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
762        std::env::temp_dir().join(format!("adcore_test_{}_{}.nc", prefix, n))
763    }
764
765    #[test]
766    fn test_write_u8_mono() {
767        let path = temp_path("nc_u8");
768        let mut writer = NetcdfWriter::new();
769
770        let mut arr = NDArray::new(
771            vec![NDDimension::new(4), NDDimension::new(4)],
772            NDDataType::UInt8,
773        );
774        if let NDDataBuffer::U8(v) = &mut arr.data {
775            for i in 0..16 {
776                v[i] = i as u8;
777            }
778        }
779
780        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
781        writer.write_file(&arr).unwrap();
782        writer.close_file().unwrap();
783
784        // Verify file exists and has NetCDF magic bytes: "CDF\x01" or "CDF\x02"
785        let data = std::fs::read(&path).unwrap();
786        assert!(data.len() > 16);
787        assert_eq!(&data[0..3], b"CDF", "Expected NetCDF magic bytes");
788
789        std::fs::remove_file(&path).ok();
790    }
791
792    #[test]
793    fn test_write_u16() {
794        let path = temp_path("nc_u16");
795        let mut writer = NetcdfWriter::new();
796
797        let mut arr = NDArray::new(
798            vec![NDDimension::new(4), NDDimension::new(4)],
799            NDDataType::UInt16,
800        );
801        if let NDDataBuffer::U16(v) = &mut arr.data {
802            for i in 0..16 {
803                v[i] = (i * 1000) as u16;
804            }
805        }
806
807        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
808        writer.write_file(&arr).unwrap();
809        writer.close_file().unwrap();
810
811        let data = std::fs::read(&path).unwrap();
812        assert!(data.len() > 32);
813        assert_eq!(&data[0..3], b"CDF");
814
815        std::fs::remove_file(&path).ok();
816    }
817
818    #[test]
819    fn test_roundtrip_u8() {
820        let path = temp_path("nc_rt_u8");
821        let mut writer = NetcdfWriter::new();
822
823        let mut arr = NDArray::new(
824            vec![NDDimension::new(4), NDDimension::new(4)],
825            NDDataType::UInt8,
826        );
827        if let NDDataBuffer::U8(v) = &mut arr.data {
828            for i in 0..16 {
829                v[i] = (i * 10) as u8;
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::U8(orig), NDDataBuffer::U8(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_roundtrip_i16() {
850        let path = temp_path("nc_rt_i16");
851        let mut writer = NetcdfWriter::new();
852
853        let mut arr = NDArray::new(
854            vec![NDDimension::new(4), NDDimension::new(4)],
855            NDDataType::Int16,
856        );
857        if let NDDataBuffer::I16(v) = &mut arr.data {
858            for i in 0..16 {
859                v[i] = (i as i16) * 100 - 500;
860            }
861        }
862
863        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
864        writer.write_file(&arr).unwrap();
865        writer.close_file().unwrap();
866
867        writer.current_path = Some(path.clone());
868        let read_back = writer.read_file().unwrap();
869        if let (NDDataBuffer::I16(orig), NDDataBuffer::I16(read)) = (&arr.data, &read_back.data) {
870            assert_eq!(orig, read);
871        } else {
872            panic!("data type mismatch on roundtrip");
873        }
874
875        std::fs::remove_file(&path).ok();
876    }
877
878    #[test]
879    fn test_roundtrip_f32() {
880        let path = temp_path("nc_rt_f32");
881        let mut writer = NetcdfWriter::new();
882
883        let mut arr = NDArray::new(
884            vec![NDDimension::new(4), NDDimension::new(4)],
885            NDDataType::Float32,
886        );
887        if let NDDataBuffer::F32(v) = &mut arr.data {
888            for i in 0..16 {
889                v[i] = i as f32 * 0.5;
890            }
891        }
892
893        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
894        writer.write_file(&arr).unwrap();
895        writer.close_file().unwrap();
896
897        writer.current_path = Some(path.clone());
898        let read_back = writer.read_file().unwrap();
899        if let (NDDataBuffer::F32(orig), NDDataBuffer::F32(read)) = (&arr.data, &read_back.data) {
900            assert_eq!(orig, read);
901        } else {
902            panic!("data type mismatch on roundtrip");
903        }
904
905        std::fs::remove_file(&path).ok();
906    }
907
908    #[test]
909    fn test_multiple_frames() {
910        let path = temp_path("nc_multi");
911        let mut writer = NetcdfWriter::new();
912
913        let mut arr1 = NDArray::new(
914            vec![NDDimension::new(4), NDDimension::new(4)],
915            NDDataType::UInt8,
916        );
917        if let NDDataBuffer::U8(v) = &mut arr1.data {
918            for i in 0..16 {
919                v[i] = i as u8;
920            }
921        }
922
923        let mut arr2 = NDArray::new(
924            vec![NDDimension::new(4), NDDimension::new(4)],
925            NDDataType::UInt8,
926        );
927        if let NDDataBuffer::U8(v) = &mut arr2.data {
928            for i in 0..16 {
929                v[i] = (i as u8).wrapping_add(100);
930            }
931        }
932
933        let mut arr3 = NDArray::new(
934            vec![NDDimension::new(4), NDDimension::new(4)],
935            NDDataType::UInt8,
936        );
937        if let NDDataBuffer::U8(v) = &mut arr3.data {
938            for i in 0..16 {
939                v[i] = (i as u8).wrapping_add(200);
940            }
941        }
942
943        writer.open_file(&path, NDFileMode::Stream, &arr1).unwrap();
944        writer.write_file(&arr1).unwrap();
945        writer.write_file(&arr2).unwrap();
946        writer.write_file(&arr3).unwrap();
947        writer.close_file().unwrap();
948
949        // Read back first frame
950        writer.current_path = Some(path.clone());
951        let read_back = writer.read_file().unwrap();
952        if let NDDataBuffer::U8(v) = &read_back.data {
953            assert_eq!(v.len(), 16);
954            for i in 0..16 {
955                assert_eq!(v[i], i as u8, "mismatch at index {}", i);
956            }
957        } else {
958            panic!("expected U8 data");
959        }
960
961        std::fs::remove_file(&path).ok();
962    }
963
964    #[test]
965    fn test_num_arrays_dim_keyed_on_open_mode_not_frame_count() {
966        // R8-73. C picks the numArrays dimension from the open mode alone —
967        // `if (openMode & NDFileModeMultiple) dim0 = NC_UNLIMITED`
968        // (NDFileNetCDF.cpp:117-119) — and NDPluginFile passes that bit for
969        // Capture and Stream but not Single (NDPluginFile.cpp:245, :281, :335).
970        // A Capture/Stream file that ends up holding exactly ONE frame is
971        // therefore still NC_UNLIMITED; deriving it from `frames.len() > 1` made
972        // it a fixed dim of 1, a header divergence.
973        let frame = || NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
974        let num_arrays_is_unlimited = |path: &PathBuf| -> bool {
975            let reader = FileReader::open(path).unwrap();
976            reader
977                .data_set()
978                .get_dim(DIM_UNLIMITED)
979                .expect("numArrays dimension")
980                .is_unlimited()
981        };
982
983        // One frame, Capture mode → NC_UNLIMITED (this is the R8-73 case).
984        let path = temp_path("nc_mode_capture_one");
985        let mut writer = NetcdfWriter::new();
986        writer
987            .open_file(&path, NDFileMode::Capture, &frame())
988            .unwrap();
989        writer.write_file(&frame()).unwrap();
990        writer.close_file().unwrap();
991        assert!(
992            num_arrays_is_unlimited(&path),
993            "Capture with 1 frame must still be NC_UNLIMITED"
994        );
995        std::fs::remove_file(&path).ok();
996
997        // One frame, Stream mode → NC_UNLIMITED.
998        let path = temp_path("nc_mode_stream_one");
999        let mut writer = NetcdfWriter::new();
1000        writer
1001            .open_file(&path, NDFileMode::Stream, &frame())
1002            .unwrap();
1003        writer.write_file(&frame()).unwrap();
1004        writer.close_file().unwrap();
1005        assert!(
1006            num_arrays_is_unlimited(&path),
1007            "Stream with 1 frame must still be NC_UNLIMITED"
1008        );
1009        std::fs::remove_file(&path).ok();
1010
1011        // One frame, Single mode → fixed dim of 1 (C's `dim0 = 1`).
1012        let path = temp_path("nc_mode_single_one");
1013        let mut writer = NetcdfWriter::new();
1014        writer
1015            .open_file(&path, NDFileMode::Single, &frame())
1016            .unwrap();
1017        writer.write_file(&frame()).unwrap();
1018        writer.close_file().unwrap();
1019        assert!(
1020            !num_arrays_is_unlimited(&path),
1021            "Single must be a fixed numArrays dimension"
1022        );
1023        std::fs::remove_file(&path).ok();
1024    }
1025
1026    #[test]
1027    fn test_attributes_stored_as_per_frame_variables() {
1028        let path = temp_path("nc_attrs");
1029        let mut writer = NetcdfWriter::new();
1030
1031        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
1032        arr.attributes.add(NDAttribute::new_static(
1033            "exposure",
1034            "Exposure time",
1035            NDAttrSource::Driver,
1036            NDAttrValue::Float64(0.5),
1037        ));
1038        arr.attributes.add(NDAttribute::new_static(
1039            "gain",
1040            "Detector gain",
1041            NDAttrSource::Driver,
1042            NDAttrValue::Int32(42),
1043        ));
1044
1045        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1046        writer.write_file(&arr).unwrap();
1047        writer.close_file().unwrap();
1048
1049        let mut reader = FileReader::open(&path).unwrap();
1050        {
1051            let ds = reader.data_set();
1052            // Per-attribute Attr_<name> variables exist with the leading dim.
1053            assert!(ds.get_var("Attr_exposure").is_some());
1054            assert!(ds.get_var("Attr_gain").is_some());
1055            // Four descriptive global text attributes per NDAttribute.
1056            assert_eq!(
1057                ds.get_global_attr_as_string("Attr_exposure_DataType"),
1058                Some("Float64".to_string())
1059            );
1060            assert_eq!(
1061                ds.get_global_attr_as_string("Attr_gain_DataType"),
1062                Some("Int32".to_string())
1063            );
1064            assert_eq!(
1065                ds.get_global_attr_as_string("Attr_exposure_Description"),
1066                Some("Exposure time".to_string())
1067            );
1068            assert_eq!(
1069                ds.get_global_attr_as_string("Attr_gain_SourceType"),
1070                Some("NDAttrSourceDriver".to_string())
1071            );
1072        }
1073        // The per-frame value is recoverable from the variable.
1074        if let netcdf3::DataVector::F64(v) = reader.read_var("Attr_exposure").unwrap() {
1075            assert_eq!(v, vec![0.5]);
1076        } else {
1077            panic!("Attr_exposure should be F64");
1078        }
1079        if let netcdf3::DataVector::I32(v) = reader.read_var("Attr_gain").unwrap() {
1080            assert_eq!(v, vec![42]);
1081        } else {
1082            panic!("Attr_gain should be I32");
1083        }
1084
1085        drop(reader);
1086        std::fs::remove_file(&path).ok();
1087    }
1088
1089    #[test]
1090    fn test_single_frame_array_data_has_leading_numarrays_dim() {
1091        let path = temp_path("nc_rank");
1092        let mut writer = NetcdfWriter::new();
1093
1094        let arr = NDArray::new(
1095            vec![NDDimension::new(4), NDDimension::new(3)],
1096            NDDataType::UInt8,
1097        );
1098        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1099        writer.write_file(&arr).unwrap();
1100        writer.close_file().unwrap();
1101
1102        let reader = FileReader::open(&path).unwrap();
1103        let ds = reader.data_set();
1104        let var = ds.get_var("array_data").unwrap();
1105        // C++ always defines array_data with rank ndims+1; a 2-D NDArray
1106        // single-frame file must therefore have a 3-D array_data variable.
1107        assert_eq!(var.get_dims().len(), 3);
1108        assert_eq!(var.get_dims()[0].name(), "numArrays");
1109        assert_eq!(var.get_dims()[0].size(), 1);
1110
1111        drop(reader);
1112        std::fs::remove_file(&path).ok();
1113    }
1114
1115    #[test]
1116    fn test_global_attrs_match_c_set() {
1117        // C (NDFileNetCDF.cpp:92-101) writes dataType then the
1118        // NDNetCDFFileVersion=3.1 double as global attributes. uniqueId is a
1119        // per-frame variable (:183) and numArrays is the unlimited dimension
1120        // (:119) — neither must appear as a global attribute.
1121        let path = temp_path("nc_globals");
1122        let mut writer = NetcdfWriter::new();
1123
1124        let arr = NDArray::new(
1125            vec![NDDimension::new(4), NDDimension::new(3)],
1126            NDDataType::UInt8,
1127        );
1128        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1129        writer.write_file(&arr).unwrap();
1130        writer.close_file().unwrap();
1131
1132        let reader = FileReader::open(&path).unwrap();
1133        let ds = reader.data_set();
1134
1135        assert_eq!(
1136            ds.get_global_attr_f64("NDNetCDFFileVersion"),
1137            Some([3.1f64].as_slice()),
1138            "NDNetCDFFileVersion global must be the 3.1 double"
1139        );
1140        assert!(ds.has_global_attr("dataType"));
1141        assert!(
1142            !ds.has_global_attr("uniqueId"),
1143            "uniqueId is a variable in C, not a global attribute"
1144        );
1145        assert!(
1146            !ds.has_global_attr("numArrays"),
1147            "numArrays is a dimension in C, not a global attribute"
1148        );
1149        // uniqueId must still be present as a variable.
1150        assert!(ds.get_var("uniqueId").is_some());
1151
1152        drop(reader);
1153        std::fs::remove_file(&path).ok();
1154    }
1155
1156    #[test]
1157    fn test_all_four_metadata_variables_written_single_frame() {
1158        let path = temp_path("nc_meta");
1159        let mut writer = NetcdfWriter::new();
1160
1161        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
1162        arr.unique_id = 99;
1163        arr.time_stamp = 12.5;
1164        arr.timestamp.sec = 555;
1165        arr.timestamp.nsec = 777;
1166
1167        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1168        writer.write_file(&arr).unwrap();
1169        writer.close_file().unwrap();
1170
1171        let mut reader = FileReader::open(&path).unwrap();
1172        for name in ["uniqueId", "timeStamp", "epicsTSSec", "epicsTSNsec"] {
1173            assert!(
1174                reader.data_set().get_var(name).is_some(),
1175                "{name} variable missing"
1176            );
1177        }
1178        match reader.read_var("uniqueId").unwrap() {
1179            netcdf3::DataVector::I32(v) => assert_eq!(v, vec![99]),
1180            other => panic!("uniqueId wrong type: {other:?}"),
1181        }
1182        match reader.read_var("epicsTSSec").unwrap() {
1183            netcdf3::DataVector::I32(v) => assert_eq!(v, vec![555]),
1184            other => panic!("epicsTSSec wrong type: {other:?}"),
1185        }
1186        match reader.read_var("epicsTSNsec").unwrap() {
1187            netcdf3::DataVector::I32(v) => assert_eq!(v, vec![777]),
1188            other => panic!("epicsTSNsec wrong type: {other:?}"),
1189        }
1190
1191        drop(reader);
1192        std::fs::remove_file(&path).ok();
1193    }
1194
1195    #[test]
1196    fn test_nddatatype_ordinals_match_c() {
1197        // The `dataType` global attribute stores `NDDataType as i32`, which the
1198        // reader uses to recover the original type. The discriminants must
1199        // match the C `NDDataType_t` enum (NDInt8=0 .. NDFloat64=9).
1200        assert_eq!(NDDataType::Int8 as i32, 0);
1201        assert_eq!(NDDataType::UInt8 as i32, 1);
1202        assert_eq!(NDDataType::Int16 as i32, 2);
1203        assert_eq!(NDDataType::UInt16 as i32, 3);
1204        assert_eq!(NDDataType::Int32 as i32, 4);
1205        assert_eq!(NDDataType::UInt32 as i32, 5);
1206        assert_eq!(NDDataType::Int64 as i32, 6);
1207        assert_eq!(NDDataType::UInt64 as i32, 7);
1208        assert_eq!(NDDataType::Float32 as i32, 8);
1209        assert_eq!(NDDataType::Float64 as i32, 9);
1210    }
1211
1212    // ---------------------------------------------------------------------
1213    // R8-68: the on-disk header. These tests parse the raw CDF-1 bytes rather
1214    // than going through the netcdf3 crate, because the defect they pin was a
1215    // wrong nc_type *code* in the file — something an API-level round-trip
1216    // through the same crate cannot see.
1217    // ---------------------------------------------------------------------
1218
1219    /// netCDF-3 nc_type codes (classic format spec).
1220    const NC_BYTE: u32 = 1;
1221    const NC_CHAR: u32 = 2;
1222    const NC_SHORT: u32 = 3;
1223    const NC_INT: u32 = 4;
1224    const NC_DOUBLE: u32 = 6;
1225
1226    struct RawVar {
1227        name: String,
1228        nc_type: u32,
1229        dim_ids: Vec<u32>,
1230        begin: usize,
1231    }
1232
1233    struct RawHeader {
1234        dims: Vec<(String, u32)>,
1235        gatt_names: Vec<String>,
1236        vars: Vec<RawVar>,
1237    }
1238
1239    impl RawHeader {
1240        fn var(&self, name: &str) -> &RawVar {
1241            self.vars
1242                .iter()
1243                .find(|v| v.name == name)
1244                .unwrap_or_else(|| panic!("no variable {} in file", name))
1245        }
1246        fn var_names(&self) -> Vec<&str> {
1247            self.vars.iter().map(|v| v.name.as_str()).collect()
1248        }
1249        fn dim_names(&self) -> Vec<&str> {
1250            self.dims.iter().map(|(n, _)| n.as_str()).collect()
1251        }
1252    }
1253
1254    /// Minimal reader for the CDF-1 header: magic, numrecs, dim_list,
1255    /// gatt_list, var_list. Independent of the netcdf3 crate on purpose.
1256    struct Cursor<'a> {
1257        b: &'a [u8],
1258        p: usize,
1259    }
1260
1261    impl<'a> Cursor<'a> {
1262        fn u32(&mut self) -> u32 {
1263            let v = u32::from_be_bytes(self.b[self.p..self.p + 4].try_into().unwrap());
1264            self.p += 4;
1265            v
1266        }
1267        /// name = nelems + chars, zero-padded to a 4-byte boundary.
1268        fn name(&mut self) -> String {
1269            let n = self.u32() as usize;
1270            let s = String::from_utf8(self.b[self.p..self.p + n].to_vec()).unwrap();
1271            self.p += n.div_ceil(4) * 4;
1272            s
1273        }
1274        fn skip_att_list(&mut self) {
1275            let tag = self.u32();
1276            let n = self.u32() as usize;
1277            assert!(
1278                tag == 0x0C || (tag == 0 && n == 0),
1279                "bad att_list tag {tag}"
1280            );
1281            for _ in 0..n {
1282                let _name = self.name();
1283                let nc_type = self.u32();
1284                let nelems = self.u32() as usize;
1285                let size = match nc_type {
1286                    1 | 2 => 1,
1287                    3 => 2,
1288                    4 | 5 => 4,
1289                    6 => 8,
1290                    t => panic!("bad nc_type {t}"),
1291                };
1292                self.p += (nelems * size).div_ceil(4) * 4;
1293            }
1294        }
1295    }
1296
1297    fn parse_header(bytes: &[u8]) -> RawHeader {
1298        assert_eq!(&bytes[0..4], b"CDF\x01", "not a CDF-1 file");
1299        let mut c = Cursor { b: bytes, p: 4 };
1300        let _numrecs = c.u32();
1301
1302        // dim_list
1303        let tag = c.u32();
1304        let ndims = c.u32() as usize;
1305        assert!(tag == 0x0A || (tag == 0 && ndims == 0));
1306        let mut dims = Vec::new();
1307        for _ in 0..ndims {
1308            let name = c.name();
1309            let len = c.u32();
1310            dims.push((name, len));
1311        }
1312
1313        // gatt_list — capture the names in file order, skip the values.
1314        let tag = c.u32();
1315        let natts = c.u32() as usize;
1316        assert!(tag == 0x0C || (tag == 0 && natts == 0));
1317        let mut gatt_names = Vec::new();
1318        for _ in 0..natts {
1319            let name = c.name();
1320            let nc_type = c.u32();
1321            let nelems = c.u32() as usize;
1322            let size = match nc_type {
1323                1 | 2 => 1,
1324                3 => 2,
1325                4 | 5 => 4,
1326                6 => 8,
1327                t => panic!("bad nc_type {t}"),
1328            };
1329            c.p += (nelems * size).div_ceil(4) * 4;
1330            gatt_names.push(name);
1331        }
1332
1333        // var_list
1334        let tag = c.u32();
1335        let nvars = c.u32() as usize;
1336        assert!(tag == 0x0B || (tag == 0 && nvars == 0));
1337        let mut vars = Vec::new();
1338        for _ in 0..nvars {
1339            let name = c.name();
1340            let rank = c.u32() as usize;
1341            let dim_ids: Vec<u32> = (0..rank).map(|_| c.u32()).collect();
1342            c.skip_att_list();
1343            let nc_type = c.u32();
1344            let _vsize = c.u32();
1345            let begin = c.u32() as usize;
1346            vars.push(RawVar {
1347                name,
1348                nc_type,
1349                dim_ids,
1350                begin,
1351            });
1352        }
1353        RawHeader {
1354            dims,
1355            gatt_names,
1356            vars,
1357        }
1358    }
1359
1360    fn write_one(path: &PathBuf, arr: &NDArray) -> Vec<u8> {
1361        let mut writer = NetcdfWriter::new();
1362        writer.open_file(path, NDFileMode::Single, arr).unwrap();
1363        writer.write_file(arr).unwrap();
1364        writer.close_file().unwrap();
1365        let bytes = std::fs::read(path).unwrap();
1366        std::fs::remove_file(path).ok();
1367        bytes
1368    }
1369
1370    /// C maps NDUInt8 to NC_BYTE (NDFileNetCDF.cpp:155-158). The port wrote
1371    /// NC_CHAR, which is a text type: readers get characters, not numbers.
1372    #[test]
1373    fn test_r8_68_uint8_array_data_is_nc_byte() {
1374        let path = temp_path("nc_r8_68_byte");
1375        let mut arr = NDArray::new(
1376            vec![NDDimension::new(2), NDDimension::new(2)],
1377            NDDataType::UInt8,
1378        );
1379        if let NDDataBuffer::U8(v) = &mut arr.data {
1380            v.copy_from_slice(&[0, 1, 200, 255]);
1381        }
1382        let bytes = write_one(&path, &arr);
1383        let hdr = parse_header(&bytes);
1384
1385        let var = hdr.var("array_data");
1386        assert_eq!(
1387            var.nc_type, NC_BYTE,
1388            "array_data must be NC_BYTE, not NC_CHAR"
1389        );
1390
1391        // C's nc_put_vara_uchar into an NC_BYTE variable copies the bit
1392        // pattern, so 200 and 255 land as 0xC8 and 0xFF on disk.
1393        assert_eq!(&bytes[var.begin..var.begin + 4], &[0x00, 0x01, 0xC8, 0xFF]);
1394    }
1395
1396    /// Int8 keeps NC_BYTE too — both signednesses collapse onto it in C, and
1397    /// the `dataType` global attribute carries the sign.
1398    #[test]
1399    fn test_r8_68_int8_array_data_is_nc_byte() {
1400        let path = temp_path("nc_r8_68_i8");
1401        let arr = NDArray::new(
1402            vec![NDDimension::new(2), NDDimension::new(2)],
1403            NDDataType::Int8,
1404        );
1405        let hdr = parse_header(&write_one(&path, &arr));
1406        assert_eq!(hdr.var("array_data").nc_type, NC_BYTE);
1407    }
1408
1409    /// C defines a string attribute's variable as NC_CHAR
1410    /// (NDFileNetCDF.cpp:302-304); the port used NC_BYTE. Non-string
1411    /// attributes keep C's numeric types.
1412    #[test]
1413    fn test_r8_68_attr_variable_nc_types_match_c() {
1414        let path = temp_path("nc_r8_68_attrs");
1415        let mut arr = NDArray::new(
1416            vec![NDDimension::new(2), NDDimension::new(2)],
1417            NDDataType::UInt16,
1418        );
1419        arr.attributes.add(NDAttribute::new_static(
1420            "Str",
1421            "a string",
1422            NDAttrSource::Driver,
1423            NDAttrValue::String("hello".into()),
1424        ));
1425        arr.attributes.add(NDAttribute::new_static(
1426            "I8",
1427            "a byte",
1428            NDAttrSource::Driver,
1429            NDAttrValue::Int8(-3),
1430        ));
1431        arr.attributes.add(NDAttribute::new_static(
1432            "I16",
1433            "a short",
1434            NDAttrSource::Driver,
1435            NDAttrValue::Int16(-3),
1436        ));
1437        arr.attributes.add(NDAttribute::new_static(
1438            "I32",
1439            "an int",
1440            NDAttrSource::Driver,
1441            NDAttrValue::Int32(-3),
1442        ));
1443        arr.attributes.add(NDAttribute::new_static(
1444            "I64",
1445            "a long",
1446            NDAttrSource::Driver,
1447            NDAttrValue::Int64(-3),
1448        ));
1449        let bytes = write_one(&path, &arr);
1450        let hdr = parse_header(&bytes);
1451
1452        assert_eq!(
1453            hdr.var("Attr_Str").nc_type,
1454            NC_CHAR,
1455            "string attr must be NC_CHAR"
1456        );
1457        assert_eq!(hdr.var("Attr_I8").nc_type, NC_BYTE);
1458        assert_eq!(hdr.var("Attr_I16").nc_type, NC_SHORT);
1459        assert_eq!(hdr.var("Attr_I32").nc_type, NC_INT);
1460        // netCDF-3 has no 64-bit integer: C casts to double (:299-301).
1461        assert_eq!(hdr.var("Attr_I64").nc_type, NC_DOUBLE);
1462
1463        // The string variable is 2-D: [numArrays, attrStringSize] (:313-316).
1464        let str_var = hdr.var("Attr_Str");
1465        assert_eq!(str_var.dim_ids.len(), 2);
1466        let attr_dim = str_var.dim_ids[1] as usize;
1467        assert_eq!(hdr.dims[attr_dim], ("attrStringSize".to_string(), 256));
1468        // Text on disk, NUL-padded to the fixed width.
1469        assert_eq!(&bytes[str_var.begin..str_var.begin + 6], b"hello\0");
1470    }
1471
1472    /// C defines attrStringSize unconditionally (NDFileNetCDF.cpp:134-136).
1473    /// The port defined it only when a string attribute existed, so files with
1474    /// no string attribute had a dimension list C never writes.
1475    #[test]
1476    fn test_r8_68_attr_string_dim_defined_without_string_attrs() {
1477        let path = temp_path("nc_r8_68_nodim");
1478        let arr = NDArray::new(
1479            vec![NDDimension::new(2), NDDimension::new(2)],
1480            NDDataType::UInt16,
1481        );
1482        let hdr = parse_header(&write_one(&path, &arr));
1483        assert_eq!(
1484            hdr.dim_names(),
1485            vec!["numArrays", "dim0", "dim1", "attrStringSize"]
1486        );
1487        assert_eq!(hdr.dims[3].1, 256);
1488    }
1489
1490    /// The header's three lists are ordered, and C's order is the format.
1491    /// Dimensions: numArrays, the reversed array dims, attrStringSize.
1492    /// Variables: the four metadata variables, array_data, then the
1493    /// attributes. Global attributes: the seven fixed ones, then four per
1494    /// attribute (NDFileNetCDF.cpp:88-330).
1495    #[test]
1496    fn test_r8_68_definition_order_matches_c() {
1497        let path = temp_path("nc_r8_68_order");
1498        let mut arr = NDArray::new(
1499            vec![NDDimension::new(4), NDDimension::new(2)],
1500            NDDataType::UInt16,
1501        );
1502        arr.attributes.add(NDAttribute::new_static(
1503            "Gain",
1504            "detector gain",
1505            NDAttrSource::Driver,
1506            NDAttrValue::Float64(2.5),
1507        ));
1508        let hdr = parse_header(&write_one(&path, &arr));
1509
1510        assert_eq!(
1511            hdr.dim_names(),
1512            vec!["numArrays", "dim0", "dim1", "attrStringSize"]
1513        );
1514        // Reversed: netCDF's first dimension varies slowest (:122-127).
1515        assert_eq!(hdr.dims[1].1, 2);
1516        assert_eq!(hdr.dims[2].1, 4);
1517
1518        assert_eq!(
1519            hdr.var_names(),
1520            vec![
1521                "uniqueId",
1522                "timeStamp",
1523                "epicsTSSec",
1524                "epicsTSNsec",
1525                "array_data",
1526                "Attr_Gain",
1527            ]
1528        );
1529
1530        assert_eq!(
1531            hdr.gatt_names,
1532            vec![
1533                "dataType",
1534                "NDNetCDFFileVersion",
1535                "numArrayDims",
1536                "dimSize",
1537                "dimOffset",
1538                "dimBinning",
1539                "dimReverse",
1540                "Attr_Gain_DataType",
1541                "Attr_Gain_Description",
1542                "Attr_Gain_Source",
1543                "Attr_Gain_SourceType",
1544            ]
1545        );
1546    }
1547}