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