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